Files
imagepipeline/imagepipeline/modules/xcf_stack.py
T
Frank Schwenk cbca473f06 fix(xcf_stack): run last, configurable timeout, tolerate GIMP exit hang
Defer xcf_stack to the final pipeline step via runs_last, expose per-image
timeout (default 120s), accept XCF output when gimp-console hangs on quit,
and discover .xcf outputs for resume.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:18:32 +02:00

101 lines
3.4 KiB
Python

from __future__ import annotations
from pathlib import Path
from imagepipeline.core.context import ModuleContext
from imagepipeline.core.params import Param
from imagepipeline.modules.base import SubprocessModule
from imagepipeline.modules.registry import register
from imagepipeline.utils.files import find_image_by_stem
from imagepipeline.utils.gimp import stack_images_to_xcf
@register
class XcfStackModule(SubprocessModule):
name = "xcf_stack"
runs_last = True
description = (
"Stack all prior pipeline step outputs as GIMP layers into one XCF per image"
)
command_candidates = ("gimp-console", "gimp")
@classmethod
def expected_output_filenames(
cls,
*,
matched_groups: list[list[Path]],
input_paths: list[Path],
params: dict,
) -> list[str]:
return [f"{path.stem}.xcf" for path in input_paths]
@classmethod
def parameters(cls) -> dict[str, Param]:
return {
"include_input": Param(
"bool",
default=True,
help="Include pipeline input/ directory as bottom layer",
),
"skip_missing": Param(
"bool",
default=False,
help="Skip missing step outputs instead of failing",
),
"timeout": Param(
"float",
default=120.0,
help="Seconds to wait for gimp-console per image (default 120)",
),
}
def run(self, ctx: ModuleContext) -> None:
include_input = ctx.params["include_input"]
skip_missing = ctx.params["skip_missing"]
timeout = ctx.params["timeout"]
layer_sources: list[tuple[str, Path]] = []
if include_input:
layer_sources.append(("input", ctx.pipeline_output_root / "input"))
layer_sources.extend(ctx.prior_steps)
if not layer_sources:
raise ValueError(
"xcf_stack has no layer sources "
"(include_input=False and no prior_steps)"
)
ctx.output_dir.mkdir(parents=True, exist_ok=True)
total = len(ctx.input_paths)
for index, input_path in enumerate(ctx.input_paths, start=1):
self.log_image(ctx, index, total, input_path)
stem = input_path.stem
layers: list[tuple[str, Path]] = []
for step_id, directory in layer_sources:
image_path = find_image_by_stem(directory, stem)
if image_path is None:
if not skip_missing:
raise ValueError(
f"xcf_stack: no image with stem '{stem}' in step "
f"'{step_id}' ({directory})"
)
continue
layers.append((step_id, image_path))
if not layers:
checked = ", ".join(step_id for step_id, _ in layer_sources)
raise ValueError(
f"xcf_stack: no layers found for stem '{stem}' "
f"(checked: {checked})"
)
dst = ctx.output_dir / f"{stem}.xcf"
stack_images_to_xcf(layers, dst, timeout=timeout)
def list_output_images(self, ctx: ModuleContext) -> list[Path]:
return sorted(
p for p in ctx.output_dir.iterdir() if p.is_file() and p.suffix.lower() == ".xcf"
)