From 100302bf01f4055e0e0ae160abc2a0c4529f14a6 Mon Sep 17 00:00:00 2001 From: Frank Schwenk Date: Sun, 12 Jul 2026 11:32:03 +0200 Subject: [PATCH] fix: xcf_stack GIMP 3 headless script execution Use gimp-console when available, GIMP 3 Script-Fu API, and temp .scm batch files so layer stacking works without a display. Co-authored-by: Cursor --- imagepipeline/modules/xcf_stack.py | 2 +- imagepipeline/utils/gimp.py | 108 ++++++++++++++++++++--------- imagepipeline/utils/subprocess.py | 2 + tests/test_xcf_stack.py | 23 ++---- 4 files changed, 82 insertions(+), 53 deletions(-) diff --git a/imagepipeline/modules/xcf_stack.py b/imagepipeline/modules/xcf_stack.py index ba8dff5..e0b9f59 100644 --- a/imagepipeline/modules/xcf_stack.py +++ b/imagepipeline/modules/xcf_stack.py @@ -16,7 +16,7 @@ class XcfStackModule(SubprocessModule): description = ( "Stack all prior pipeline step outputs as GIMP layers into one XCF per image" ) - command_candidates = ("gimp",) + command_candidates = ("gimp-console", "gimp") default_timeout = 600.0 @classmethod diff --git a/imagepipeline/utils/gimp.py b/imagepipeline/utils/gimp.py index b8a469a..c1837fe 100644 --- a/imagepipeline/utils/gimp.py +++ b/imagepipeline/utils/gimp.py @@ -1,13 +1,18 @@ from __future__ import annotations +import os +import subprocess +import tempfile from pathlib import Path -from imagepipeline.utils.subprocess import require_command, run_command +from imagepipeline.utils.subprocess import require_command + +_GIMP_CANDIDATES = ("gimp-console", "gimp") def require_gimp() -> str: - """Return the GIMP executable name, raising DependencyError if missing.""" - return require_command("gimp") + """Return a GIMP executable for headless batch use.""" + return require_command(*_GIMP_CANDIDATES) def _scheme_string(value: str) -> str: @@ -23,34 +28,35 @@ def _scheme_string(value: str) -> str: def _build_stack_script(layers: list[tuple[str, Path]], outfile: Path) -> str: + """Build a GIMP 3 Script-Fu snippet that stacks images into one XCF.""" first_name, first_path = layers[0] first_path_str = _scheme_string(str(first_path)) outfile_str = _scheme_string(str(outfile)) lines = [ + "(script-fu-use-v3)", "(let* (", - f" (loaded (gimp-file-load RUN-NONINTERACTIVE {first_path_str} {first_path_str}))", - " (image (car loaded))", - " (bottom-layer (cadr loaded))", + f" (image (gimp-file-load RUN-NONINTERACTIVE {first_path_str}))", + " (bottom (vector-ref (gimp-image-get-selected-drawables image) 0))", ")", - f" (gimp-layer-set-name bottom-layer {_scheme_string(first_name)})", + f" (gimp-item-set-name bottom {_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)})", + " (let ((layer", + f" (gimp-file-load-layer RUN-NONINTERACTIVE image {path_str})))", + " (gimp-image-insert-layer image layer image 0)", + f" (gimp-item-set-name layer {_scheme_string(layer_name)})", " )", ] ) lines.extend( [ - f" (gimp-xcf-save RUN-NONINTERACTIVE image bottom-layer {outfile_str} {outfile_str})", + f" (gimp-file-save RUN-NONINTERACTIVE image {outfile_str})", " (gimp-image-delete image)", ")", ] @@ -58,6 +64,27 @@ def _build_stack_script(layers: list[tuple[str, Path]], outfile: Path) -> str: return "\n".join(lines) +def _run_gimp_batch( + cmd: list[str], + *, + timeout: float | None, + env: dict[str, str], +) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError( + f"Command timed out after {timeout}s: {' '.join(cmd)}" + ) from exc + + def stack_images_to_xcf( layers: list[tuple[str, Path]], outfile: Path, @@ -67,7 +94,7 @@ def stack_images_to_xcf( """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. + order. Invokes GIMP headless via Script-Fu (GIMP 3 compatible). """ if not layers: raise ValueError("stack_images_to_xcf requires at least one layer") @@ -86,26 +113,39 @@ def stack_images_to_xcf( 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)", - ] + env = {**os.environ, "GIMP_NO_DISPLAY": "1"} + + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".scm", + delete=False, + encoding="utf-8", + ) as handle: + handle.write(script) + script_path = Path(handle.name) 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}" - ) + cmd = [ + gimp, + "-d", + "-i", + "--quit", + "--batch-interpreter=plug-in-script-fu-eval", + "--batch", + f"(load {_scheme_string(str(script_path))})", + ] + try: + result = _run_gimp_batch(cmd, timeout=timeout, env=env) + except RuntimeError: + raise + if not outfile.is_file(): + stderr = (result.stderr or "").strip() + stdout = (result.stdout or "").strip() + detail = stderr or stdout or f"exit code {result.returncode}" + layer_summary = ", ".join(name for name, _ in resolved_layers) + raise RuntimeError( + f"GIMP failed to stack layers [{layer_summary}] into {outfile}: " + f"{detail}" + ) + finally: + script_path.unlink(missing_ok=True) diff --git a/imagepipeline/utils/subprocess.py b/imagepipeline/utils/subprocess.py index 471c806..bfeec50 100644 --- a/imagepipeline/utils/subprocess.py +++ b/imagepipeline/utils/subprocess.py @@ -16,6 +16,7 @@ def run_command( *, timeout: float | None = None, cwd: Path | None = None, + env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: try: return subprocess.run( @@ -25,6 +26,7 @@ def run_command( text=True, timeout=timeout, cwd=str(cwd) if cwd else None, + env=env, ) except subprocess.CalledProcessError as exc: stderr = (exc.stderr or "").strip() diff --git a/tests/test_xcf_stack.py b/tests/test_xcf_stack.py index d5bf6d9..d9b3ac5 100644 --- a/tests/test_xcf_stack.py +++ b/tests/test_xcf_stack.py @@ -12,26 +12,10 @@ 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_gimp = bool(shutil.which("gimp-console") or 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") @@ -174,7 +158,7 @@ class TestXcfStackRun: mock_stack.assert_not_called() -@pytest.mark.skipif(not has_working_gimp, reason="GIMP headless not available") +@pytest.mark.skipif(not has_gimp, reason="GIMP not installed") @pytest.mark.skipif(not has_magick, reason="ImageMagick not installed") class TestStackImagesToXcfIntegration: def test_stacks_pngs_into_xcf(self, tmp_path: Path) -> None: @@ -198,3 +182,6 @@ class TestStackImagesToXcfIntegration: assert outfile.is_file() assert outfile.stat().st_size > 0 + names = outfile.read_bytes() + assert b"bottom" in names + assert b"top" in names