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
+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