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>
This commit is contained in:
Frank Schwenk
2026-07-12 12:18:32 +02:00
parent 100302bf01
commit cbca473f06
5 changed files with 113 additions and 15 deletions
+68
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
from unittest.mock import patch
@@ -40,6 +41,38 @@ class TestModuleRegistration:
def test_get_module_returns_xcf_stack_class(self) -> None:
assert get_module("xcf_stack") is XcfStackModule
def test_xcf_stack_runs_last(self) -> None:
from imagepipeline.core.runner import PipelineRunner
from imagepipeline.core.step import StepDefinition
from imagepipeline.modules.imagemagick_grayscale import ImageMagickGrayscale
steps = [
StepDefinition(
step_id="xcf_stack_01",
module_name="xcf_stack",
module=XcfStackModule,
input_refs=["input"],
params={},
output_dir_name="xcf_stack_01",
),
StepDefinition(
step_id="imagemagick_grayscale_01",
module_name="imagemagick_grayscale",
module=ImageMagickGrayscale,
input_refs=["input"],
params={},
output_dir_name="imagemagick_grayscale_01",
),
]
runner = PipelineRunner(
name="order",
input_dir=Path("/tmp/unused"),
output_base=Path("/tmp/unused"),
steps=steps,
)
ordered = runner._topological_sort()
assert ordered[-1].module_name == "xcf_stack"
class TestExpectedOutputFilenames:
def test_returns_xcf_for_jpg_input(self) -> None:
@@ -50,6 +83,14 @@ class TestExpectedOutputFilenames:
)
assert names == ["photo.xcf"]
def test_default_timeout(self) -> None:
params = XcfStackModule.validate_module_params({})
assert params["timeout"] == 120.0
def test_custom_timeout(self) -> None:
params = XcfStackModule.validate_module_params({"timeout": 45})
assert params["timeout"] == 45.0
def _make_stack_fixture(tmp_path: Path) -> dict[str, Path]:
root = tmp_path
@@ -185,3 +226,30 @@ class TestStackImagesToXcfIntegration:
names = outfile.read_bytes()
assert b"bottom" in names
assert b"top" in names
class TestGimpTimeoutHandling:
def test_accepts_outfile_when_gimp_hangs_on_exit(self, tmp_path: Path) -> None:
from unittest.mock import MagicMock, patch
from imagepipeline.utils.gimp import stack_images_to_xcf
layer = tmp_path / "layer.png"
layer.write_bytes(b"png")
outfile = tmp_path / "stack.xcf"
outfile.write_bytes(b"xcf-data")
def fake_popen(*_args, **_kwargs):
process = MagicMock()
process.communicate.side_effect = [
subprocess.TimeoutExpired(cmd="gimp", timeout=1),
("", ""),
]
process.kill = MagicMock()
return process
with patch("imagepipeline.utils.gimp.subprocess.Popen", side_effect=fake_popen):
with patch("imagepipeline.utils.gimp.require_gimp", return_value="gimp-console"):
stack_images_to_xcf([("layer", layer)], outfile, timeout=1.0)
assert outfile.read_bytes() == b"xcf-data"