feat: add xcf_stack module for GIMP layer export

Collect prior pipeline step outputs per image and stack them into XCF files via headless GIMP Script-Fu.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-07-12 11:15:23 +02:00
parent ce431d7eec
commit 13c3c653b8
7 changed files with 412 additions and 0 deletions
+1
View File
@@ -18,6 +18,7 @@ class ModuleContext:
pipeline_output_root: Path
step_id: str
matched_groups: list[list[Path]] = field(default_factory=list)
prior_steps: list[tuple[str, Path]] = field(default_factory=list)
logger: PipelineLogger | None = None
@property
+1
View File
@@ -220,6 +220,7 @@ class PipelineRunner:
pipeline_output_root=self.output_root,
step_id=step.step_id,
matched_groups=matched_groups,
prior_steps=[(sid, res.output_dir) for sid, res in self._results.items()],
logger=self.logger,
)
+1
View File
@@ -15,3 +15,4 @@ import imagepipeline.modules.imagemagick_resize # noqa: F401
import imagepipeline.modules.imagemagick_scale_crop # noqa: F401
import imagepipeline.modules.openrouter_edit # noqa: F401
import imagepipeline.modules.rembg # noqa: F401
import imagepipeline.modules.xcf_stack # noqa: F401
+89
View File
@@ -0,0 +1,89 @@
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"
description = (
"Stack all prior pipeline step outputs as GIMP layers into one XCF per image"
)
command_candidates = ("gimp",)
default_timeout = 600.0
@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",
),
}
def run(self, ctx: ModuleContext) -> None:
include_input = ctx.params["include_input"]
skip_missing = ctx.params["skip_missing"]
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=self.default_timeout)
+9
View File
@@ -22,6 +22,15 @@ def stem_key(path: Path) -> str:
return path.stem.lower()
def find_image_by_stem(directory: Path, stem: str) -> Path | None:
"""Return first image in directory whose stem matches stem (case-insensitive)."""
target = stem.lower()
for path in directory.iterdir():
if is_image(path) and stem_key(path) == target:
return path
return None
def match_by_stem(sources: list[list[Path]]) -> list[list[Path]]:
"""Match image paths across multiple source lists by filename stem."""
if not sources:
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
from pathlib import Path
from imagepipeline.utils.subprocess import require_command, run_command
def require_gimp() -> str:
"""Return the GIMP executable name, raising DependencyError if missing."""
return require_command("gimp")
def _scheme_string(value: str) -> str:
"""Escape a Python string for use inside a Scheme double-quoted literal."""
escaped = (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
return f'"{escaped}"'
def _build_stack_script(layers: list[tuple[str, Path]], outfile: Path) -> str:
first_name, first_path = layers[0]
first_path_str = _scheme_string(str(first_path))
outfile_str = _scheme_string(str(outfile))
lines = [
"(let* (",
f" (loaded (gimp-file-load RUN-NONINTERACTIVE {first_path_str} {first_path_str}))",
" (image (car loaded))",
" (bottom-layer (cadr loaded))",
")",
f" (gimp-layer-set-name bottom-layer {_scheme_string(first_name)})",
]
for layer_name, layer_path in layers[1:]:
path_str = _scheme_string(str(layer_path))
lines.extend(
[
" (let ((layer (car (gimp-file-load-layer RUN-NONINTERACTIVE image "
f"{path_str}))))",
" (gimp-image-insert-layer image layer 0 0)",
f" (gimp-layer-set-name layer {_scheme_string(layer_name)})",
" )",
]
)
lines.extend(
[
f" (gimp-xcf-save RUN-NONINTERACTIVE image bottom-layer {outfile_str} {outfile_str})",
" (gimp-image-delete image)",
")",
]
)
return "\n".join(lines)
def stack_images_to_xcf(
layers: list[tuple[str, Path]],
outfile: Path,
*,
timeout: float | None = None,
) -> None:
"""Stack images bottom-to-top into a single GIMP XCF file.
``layers`` is a list of ``(layer_name, image_path)`` tuples in bottom-to-top
order. Invokes GIMP headless via Script-Fu.
"""
if not layers:
raise ValueError("stack_images_to_xcf requires at least one layer")
gimp = require_gimp()
resolved_layers: list[tuple[str, Path]] = []
for layer_name, image_path in layers:
resolved = image_path.resolve()
if not resolved.is_file():
raise FileNotFoundError(
f"Layer image not found for '{layer_name}': {resolved}"
)
resolved_layers.append((layer_name, resolved))
outfile = outfile.resolve()
outfile.parent.mkdir(parents=True, exist_ok=True)
script = _build_stack_script(resolved_layers, outfile)
cmd = [
gimp,
"-idf",
"--batch-interpreter",
"plug-in-script-fu-eval",
"-b",
script,
"-b",
"(gimp-quit 0)",
]
try:
run_command(cmd, timeout=timeout)
except RuntimeError as exc:
layer_summary = ", ".join(name for name, _ in resolved_layers)
raise RuntimeError(
f"GIMP failed to stack layers [{layer_summary}] into {outfile}: {exc}"
) from exc
if not outfile.is_file():
raise RuntimeError(
f"GIMP completed but XCF output was not created: {outfile}"
)
+200
View File
@@ -0,0 +1,200 @@
from __future__ import annotations
import shutil
from pathlib import Path
from unittest.mock import patch
import pytest
from imagepipeline.core.context import ModuleContext
from imagepipeline.modules.registry import get_module, list_modules
from imagepipeline.modules.xcf_stack import XcfStackModule
from imagepipeline.utils.files import find_image_by_stem
from tests.conftest import make_png
has_gimp = bool(shutil.which("gimp"))
has_magick = bool(shutil.which("magick") or shutil.which("convert"))
def _gimp_headless_works() -> bool:
gimp = shutil.which("gimp")
if not gimp:
return False
try:
from imagepipeline.utils.subprocess import run_command
run_command([gimp, "-idf", "-b", "(gimp-quit 0)"], timeout=10)
return True
except RuntimeError:
return False
has_working_gimp = _gimp_headless_works()
class TestFindImageByStem:
def test_finds_matching_stem_case_insensitively(self, tmp_path: Path) -> None:
make_png(tmp_path / "Photo.PNG")
found = find_image_by_stem(tmp_path, "photo")
assert found == tmp_path / "Photo.PNG"
def test_returns_none_when_missing(self, tmp_path: Path) -> None:
make_png(tmp_path / "other.png")
assert find_image_by_stem(tmp_path, "photo") is None
def test_handles_extension_mismatch(self, tmp_path: Path) -> None:
make_png(tmp_path / "photo.png")
# Input may be photo.jpg while step output is photo.png (stem-only match).
found = find_image_by_stem(tmp_path, Path("photo.jpg").stem)
assert found == tmp_path / "photo.png"
class TestModuleRegistration:
def test_xcf_stack_registered(self) -> None:
assert "xcf_stack" in list_modules()
def test_get_module_returns_xcf_stack_class(self) -> None:
assert get_module("xcf_stack") is XcfStackModule
class TestExpectedOutputFilenames:
def test_returns_xcf_for_jpg_input(self) -> None:
names = XcfStackModule.expected_output_filenames(
matched_groups=[],
input_paths=[Path("photo.jpg")],
params={},
)
assert names == ["photo.xcf"]
def _make_stack_fixture(tmp_path: Path) -> dict[str, Path]:
root = tmp_path
input_dir = root / "input"
step_a = root / "step_a"
step_b = root / "step_b"
output_dir = root / "xcf_out"
for directory in (input_dir, step_a, step_b, output_dir):
directory.mkdir(parents=True, exist_ok=True)
make_png(input_dir / "photo.png")
make_png(step_a / "photo.png", rgb=(10, 20, 30))
make_png(step_b / "photo.png", rgb=(30, 20, 10))
return {
"root": root,
"input_dir": input_dir,
"step_a": step_a,
"step_b": step_b,
"output_dir": output_dir,
}
class TestXcfStackRun:
@patch("imagepipeline.modules.xcf_stack.stack_images_to_xcf")
def test_collects_layers_from_input_and_prior_steps(
self, mock_stack: object, tmp_path: Path
) -> None:
paths = _make_stack_fixture(tmp_path)
input_path = paths["root"] / "refs" / "photo.jpg"
input_path.parent.mkdir()
input_path.touch()
ctx = ModuleContext(
input_paths=[input_path],
output_dir=paths["output_dir"],
params=XcfStackModule.validate_module_params({}),
pipeline_output_root=paths["root"],
step_id="xcf_stack_01",
prior_steps=[
("step_a", paths["step_a"]),
("step_b", paths["step_b"]),
],
logger=None,
)
XcfStackModule().run(ctx)
mock_stack.assert_called_once()
layers, outfile = mock_stack.call_args[0]
assert outfile == paths["output_dir"] / "photo.xcf"
assert layers == [
("input", paths["input_dir"] / "photo.png"),
("step_a", paths["step_a"] / "photo.png"),
("step_b", paths["step_b"] / "photo.png"),
]
@patch("imagepipeline.modules.xcf_stack.stack_images_to_xcf")
def test_skip_missing_true_skips_missing_step(
self, mock_stack: object, tmp_path: Path
) -> None:
paths = _make_stack_fixture(tmp_path)
(paths["step_b"] / "photo.png").unlink()
input_path = paths["root"] / "photo.jpg"
ctx = ModuleContext(
input_paths=[input_path],
output_dir=paths["output_dir"],
params=XcfStackModule.validate_module_params({"skip_missing": True}),
pipeline_output_root=paths["root"],
step_id="xcf_stack_01",
prior_steps=[
("step_a", paths["step_a"]),
("step_b", paths["step_b"]),
],
logger=None,
)
XcfStackModule().run(ctx)
layers, _outfile = mock_stack.call_args[0]
step_ids = [step_id for step_id, _ in layers]
assert step_ids == ["input", "step_a"]
@patch("imagepipeline.modules.xcf_stack.stack_images_to_xcf")
def test_skip_missing_false_raises_on_missing_step(
self, mock_stack: object, tmp_path: Path
) -> None:
paths = _make_stack_fixture(tmp_path)
(paths["step_b"] / "photo.png").unlink()
input_path = paths["root"] / "photo.jpg"
ctx = ModuleContext(
input_paths=[input_path],
output_dir=paths["output_dir"],
params=XcfStackModule.validate_module_params({"skip_missing": False}),
pipeline_output_root=paths["root"],
step_id="xcf_stack_01",
prior_steps=[
("step_a", paths["step_a"]),
("step_b", paths["step_b"]),
],
logger=None,
)
with pytest.raises(ValueError, match="no image with stem 'photo' in step 'step_b'"):
XcfStackModule().run(ctx)
mock_stack.assert_not_called()
@pytest.mark.skipif(not has_working_gimp, reason="GIMP headless not available")
@pytest.mark.skipif(not has_magick, reason="ImageMagick not installed")
class TestStackImagesToXcfIntegration:
def test_stacks_pngs_into_xcf(self, tmp_path: Path) -> None:
from imagepipeline.utils.gimp import stack_images_to_xcf
bottom = tmp_path / "bottom.png"
top = tmp_path / "top.png"
magick = shutil.which("magick") or shutil.which("convert")
assert magick is not None
from imagepipeline.utils.subprocess import run_command
run_command([magick, "-size", "8x8", "xc:#ff0000", str(bottom)])
run_command([magick, "-size", "8x8", "xc:#0000ff", str(top)])
outfile = tmp_path / "stack.xcf"
stack_images_to_xcf(
[("bottom", bottom), ("top", top)],
outfile,
timeout=120.0,
)
assert outfile.is_file()
assert outfile.stat().st_size > 0