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 <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-07-12 11:32:03 +02:00
parent 1c36fc1968
commit 100302bf01
4 changed files with 82 additions and 53 deletions
+1 -1
View File
@@ -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
+74 -34
View File
@@ -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)
+2
View File
@@ -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()
+5 -18
View File
@@ -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