Compare commits

..

3 Commits

Author SHA1 Message Date
Frank Schwenk 13c3c653b8 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>
2026-07-12 11:15:23 +02:00
Frank Schwenk ce431d7eec feat: resume fixes, OpenRouter templates, and project context
Delegate expected output filenames to modules so resume works for rembg
and composite; normalize G'MIC multi-frame output; add OpenRouter style
reference support with tests. Add Crusaders, orange, and team gallery
pipelines plus SOUL/AGENTS context files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:45:00 +02:00
Frank Schwenk 980c7f3b8b A long time ago, in a galaxy far far away... 2026-06-21 10:26:10 +02:00
37 changed files with 2666 additions and 26 deletions
+275
View File
@@ -0,0 +1,275 @@
# AGENTS.md — Operational Instructions
How the coding agent works with Fränky's projects.
Also read: `INFRASTRUCTURE.md`, `STANDARDS.md`, `BOUNDARIES.md`, project `SOUL.md`, project `MOOD.md`.
Persona & preferences: `USER.md` (Tier 2).
---
## Rule Priority
Highest wins. One line per source:
| Priority | Source |
|----------|--------|
| 1 | Explicit chat instruction from Fränky |
| 2 | Project `SOUL.md` / project-local `BOUNDARIES.md` / project `AGENTS` snippets |
| 3 | `BOUNDARIES.md` |
| 4 | `AGENTS.md` (this file) |
| 5 | `STANDARDS.md` |
| 6 | `INFRASTRUCTURE.md` |
| 7 | `MOOD.md`**tone only**, never overrides safety or ops rules |
| 8 | `USER.md` — persona & preferences, not operational overrides |
**UI/UX:** project `SOUL.md` overrides global `STANDARDS.md` when they conflict.
---
## Startup (Tier 1 / Tier 2)
Cursor does not auto-load context. Use two tiers:
### Tier 1 — always (light)
At the start of any substantive task, without waiting for ack:
1. Project `SOUL.md`**Agent Quick Start** section (or full file if no Quick Start)
2. `BOUNDARIES.md`**Never Ever** section
### Tier 2 — full load
On `@AGENTS.md ack`, first chat in a project, or when Fränky says context was lost:
1. `AGENTS.md`, `BOUNDARIES.md`, `STANDARDS.md`, `INFRASTRUCTURE.md`
2. `USER.md` — persona, work-style table, communication prefs
3. Project `SOUL.md` (full), project `MOOD.md` if present
4. Skim project structure
### Command: `AGENTS.md ack`
Fränky schreibt `AGENTS.md ack`.
**Agent:** Tier-2 read, then reply with this **compact template** (one block, no code changes):
```
AGENTS ack ✓
· Pipeline: [1-line what this project is]
· Non-goals: [from SOUL, or "SOUL missing"]
· Commit policy: [no commit unless … / project override]
· MOOD: [active session character]
· Paths: [key workdirs / mounts from SOUL or INFRA snippet]
· Conflict: [1 sentence if SOUL vs STANDARDS disagree, else "none"]
```
---
## Work Modes
| Mode | Trigger | Behavior |
|------|---------|----------|
| **Question-only** | Question, review, "how does X work?" | No file changes, commit, or drive-by fixes |
| **Standard** | Default | Restate → align on plan → implement → verify → hand off |
| **Unattended / Away** | "wenn ich zurück bin", "mach ohne mich", "overnight", explicit away | **Skip plan alignment** — proceed with best judgment; justify in handoff |
### Unattended / Away — extra rules
- **Monitoring:** Cursor background shell + polling — **not** external wrapper scripts as default
- **Handoff must include:** log paths, PIDs if relevant, how to recognize success, next command for Fränky
- Write blocked items or run status to `NOTES.md` when useful
- **Notify** via `ntfyschwenkonline` when the task finishes (see [Notifications](#notifications))
---
## Default Workflow (Standard mode)
1. **Restate** the request; turn into plan or mini-PRD
2. **Align** on the plan — resolve ambiguities before coding
3. **Implement** (see Testing below)
4. **Verify** — run tests when appropriate; do not guess
5. **Hand off** — summary, how to test locally, log paths for long jobs
6. **Deploy / prod** — Fränky handles unless project or chat says otherwise
After **long-running** work: send push notification (see [Notifications](#notifications)).
---
## Autonomy Matrix
See also `USER.md` **Work Style** table for Fränky's preference scores.
| Action | Default |
|--------|---------|
| Write / change code | ✅ OK |
| Write tests | ✅ OK when non-trivial or suite exists |
| Add dependencies | ✅ OK |
| Touch README / docs | ✅ OK |
| Change CI/CD config | ⚠️ Ask first |
| Refactor "on the side" | ⚠️ Ask first |
| Update AGENTS / SOUL / BOUNDARIES | ⚠️ Ask first (or explicit "remember this") |
| Update `MOOD.md` on disk | 🚫 Only on `persist MOOD` or explicit instruction |
| Create auxiliary `.md` (`NOTES.md`, todos) | ✅ OK |
| Git commit / push / deploy | 🚫 Unless explicit or project `SOUL.md` allows |
| `ntfyschwenkonline` after long-running task | ✅ OK |
---
## MOOD: Session vs. Persist
| | Session | Persist to `MOOD.md` |
|---|---------|----------------------|
| Trigger | `mood "XY"` in chat | `persist MOOD` or explicit "save mood to file" |
| Effect | Tone for this chat only | Updates `## Aktueller Mood` in file |
| Default | **Yes**`mood "XY"` does **not** edit the file |
---
## Testing
Fränky's bar is **pragmatic, not TDD-by-default** (`USER.md`: tests score 2/5).
- Run tests when a suite exists **and** the change is non-trivial
- Follow project `SOUL.md` if stricter (e.g. pytest before handoff)
- Do not block small fixes on missing test infrastructure
---
## Commits & Issues
- **Default: no commit, no push** without explicit instruction
- **Format:** [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, …) when committing
- **Forge:** **Gitea** (`gitea.schwenk.online`) unless project says otherwise — not GitLab
- Link Gitea issues when they exist (`#123`)
- Do not mix unrelated changes (e.g. MOOD switch + feature) in one commit
---
## Collaboration Patterns
### `NOTES.md`
Scratchpad — use for blocked tasks, session park, overnight-run status, handoff crumbs.
### Subagents & context budget
**Keep main context small.** Delegate instead of dumping large outputs into parent chat:
| Task | Subagent |
|------|----------|
| Unknown codebase or many files to scan | **Explore** |
| Browser / E2E / web UI | **Playwright** |
| Broad research | **generalPurpose** or **Explore** |
| Shell / git batch ops | **shell** |
**Explore** for any project type when orientation is unclear — not only web. **Playwright** only when there is a web UI to test.
### Commits when allowed
One focused commit per completed task — easy to revert. No drive-by refactors.
---
## Project Infrastructure Snippet
Machine-specific paths (workdirs, external mounts, local service ports) belong in **project `SOUL.md`**, not global `INFRASTRUCTURE.md`.
Example SOUL section:
```markdown
## Infrastructure (project-local)
- Workdir: ~/.local/share/myapp
- Mounts: check MegaB before scan
- Local Immich: :2283 (downstream, not core)
```
See `INFRASTRUCTURE.md`**Project-local overrides**.
---
## When Uncertain
1. Research — read code, run commands
2. Continue other independent tasks if possible
3. Park in `NOTES.md`, todo file, or Gitea issue
4. Ask after research, with options + recommended default
---
## Error Handling
- Root cause first — do not guess
- Escalate on: debug loops, missing uninstallable software
- Long-running jobs: no arbitrary timeout unless Fränky or SOUL says so; name `progress.log` when applicable
---
## Notifications
Fränky's machines have **`/usr/local/bin/ntfyschwenkonline`** — push to [ntfy.schwenk.online](https://ntfy.schwenk.online). Topic = short hostname (`hostname -s`); phone subscribes per machine. Auth is in the installed script — **never copy tokens into repos or chat**.
### When to notify
Send a notification when a **long-running agent task** completes or fails:
- **Unattended / Away** mode — always on finish (success or failure)
- Background shell jobs you started (scans, builds, batch ops, overnight runs)
- Any task Fränky left with an expectation of „meld dich wenn fertig"
- Rough guide: expected runtime **> ~2 minutes** or explicit away/unattended context
Do **not** notify for quick edits, short test runs, or question-only chats.
### How
```bash
ntfyschwenkonline "OK <project>: <one-line result>"
# or on failure:
ntfyschwenkonline "FAIL <project>: <one-line error>, see <log-path>"
```
- **English** message body (CLI/ops convention)
- One line, ~120 chars — project name, outcome, log path or next step if relevant
- Run after handoff summary; notification is in addition to chat handoff, not a replacement
- If `ntfyschwenkonline` is missing or fails: note in chat handoff, do not block
### Examples
```bash
ntfyschwenkonline "OK imagetool: scan done 142 dirs, log ~/.local/share/imagetool/progress.log"
ntfyschwenkonline "FAIL ytrecap: pytest 3 failed, see /tmp/test.log"
ntfyschwenkonline "OK schwenkonline: build+deploy done"
```
---
## Recording New Rules
When Fränky says *"never do X"*:
1. Propose file (`BOUNDARIES.md`, `SOUL.md`, `AGENTS.md`, or multiple)
2. Store in **Cursor Memories** for explicit rules
3. Write after confirmation — or immediately if explicit
---
## Language
- **Chat:** match Fränky's language (German or English)
- **CLI / terminal / shell:** always English (unless client project — ask)
- **Other artifacts:** first language of session
---
## Tools & MCP
1. MCP when available and relevant
2. Built-in tools (shell, grep, read)
3. Browser automation last resort
Check MCP schemas before calling.
---
## Tooling Context
Primary: **Cursor** (Composer / Agent).
Server/deploy: **`INFRASTRUCTURE.md`**. Persona: **`USER.md`**.
+77
View File
@@ -0,0 +1,77 @@
# BOUNDARIES.md — Hard Limits
Rules that apply in **every** project unless explicitly overridden in a project-local `BOUNDARIES.md` (stricter only — never looser).
---
## Never Ever
| Rule | Why |
|------|-----|
| **Commit secrets** | `.env`, API keys, tokens, passwords, private keys — use `.gitignore` and env vars |
| **Cripple the machine** | No commands that freeze desktop, fill disk, fork-bomb, or saturate CPU/RAM on dev box or server |
| **Ignore user rules** | `USER.md`, `AGENTS.md`, project context files, and explicit chat instructions are binding |
| **Force-push to main/master** | Unless Fränky explicitly requests it — warn first |
| **Destructive prod actions** | No prod DB drops, migrations, or deploys without explicit approval (project may define exceptions) |
| **Modify code on question-only requests** | Questions get answers — not drive-by fixes |
---
## Sensitive Data
- Do not paste secrets into chat, commits, logs, or comments
- Use `.env.example` with placeholder values — never real credentials
- Redact tokens and personal data in error output shared in chat
- When handling personal data: minimize collection, don't log PII unnecessarily
- If unsure whether data is sensitive: treat it as sensitive
---
## Git Safety
- No `git push --force` to shared/main branches without explicit request
- No `git commit` unless user or project rules allow
- No skipping hooks (`--no-verify`) unless user explicitly requests
- No `git config` changes
---
## System Safety
- Avoid `rm -rf` on broad paths — confirm target paths for destructive file ops
- No installing system-wide packages without asking (user-space / venv / container preferred)
- No rebooting or stopping critical services on remote servers without approval
---
## Agent Behavior
- **No hallucination** — if you don't know, say so; read the file, run the command, check docs
- **No outdated advice** — flag when knowledge may be stale; verify against project code/version
- **Stop and escalate** on:
- Debug loops (same error, same failed fix repeated)
- Required software missing and not installable in context
- Conflicting instructions you cannot resolve
---
## Dependencies & Licenses
- Prefer open-source dependencies
- No automatic addition of copyleft dependencies to proprietary projects without flagging
- No license violations (stripping headers, ignoring LICENSE files)
---
## Project-Local Overrides
Add project-specific boundaries below when copying into a project:
```markdown
## Project-Specific
- (example) Never touch the legacy PHP monolith in /old/
- (example) Auto-deploy to staging is OK; prod requires manual approval
```
When Fränky says *"don't do X here"*, the agent should propose the right file (`BOUNDARIES.md`, `SOUL.md`, or `AGENTS.md`) and persist it.
+120
View File
@@ -0,0 +1,120 @@
# INFRASTRUCTURE.md — Hosts, Deploy, Git
Operational context for Fränky's environments. Read with `AGENTS.md`.
---
## Overview
| | |
|---|---|
| vServer | **`boka`** — Debian 12 (bookworm), Webtropia Cloud VPS |
| SSH | `ssh frank-schwenk.de` (resolves to boka) |
| Local dev | **Arch Linux** — desktop + laptop |
| Reverse proxy | **Traefik** — external Docker network `traefik`, TLS via `myresolver` |
| Git | **Gitea**`ssh://git@gitea.schwenk.online:2222/froxxxy/<repo>.git` |
| Server app paths | `/home/frank/<domain>/` (e.g. `/home/frank/schwenk.online`) |
| Shared services | Traefik, Gitea + Runner, Immich, Portainer — **treat as fragile** |
| Separate hosting | **`0012.de`** — Plesk webspace, FTP deploy — **not** boka Docker |
| Push notify | **`ntfyschwenkonline`** on each machine (`/usr/local/bin`) → `ntfy.schwenk.online`, topic = hostname |
---
## Repo → Server Mapping
| Local repo (`~/git/froxxxy/`) | Server path | Domain |
|-------------------------------|-------------|--------|
| schwenkonline | `/home/frank/schwenk.online` | schwenk.online |
| ytrecap | `/home/frank/ytrecap.schwenk.online` | ytrecap.schwenk.online |
| bringtake | `/home/frank/bringtake.schwenk.online` | bringtake.schwenk.online |
| vfbred | `/home/frank/vfb.red` | vfb.red |
| f12rocks | `/home/frank/f12.rocks` | f12.rocks |
| eselhoefede | `/home/frank/eselhoefe.de` | eselhoefe.de |
| mobea | `/home/frank/mobea.de` | mobea.de |
| fussballdeical | `/home/frank/fussballdeical.schwenk.online` | fussballdeical.schwenk.online |
| takeyourmeds | `/home/frank/medis.schwenk.online` | medis.schwenk.online |
| sboa | `/home/frank/affen.schwenk.online` | affen.schwenk.online |
Paths follow the pattern: clone on server under `/home/frank/`, often named after the public domain.
---
## Deploy (default)
**Fränky deploys** unless the project explicitly grants agent autonomy (project `SOUL.md`, `README`, or chat instruction).
### Typical boka flow
```bash
ssh frank-schwenk.de
cd /home/frank/<project>
git pull
# build step if needed (npm run build, docker build, …)
docker compose up -d
```
Build-before-up varies by project (e.g. Astro: `npm run build` then nginx serves `dist/`).
### 0012.de (webspace)
- FTP deploy via project scripts — see `0012` repo
- External observer / monitoring of boka — do not assume same deploy path as VPS
---
## Traefik Conventions
Standard labels on app containers:
```yaml
traefik.enable=true
traefik.http.routers.<name>.rule=Host(`example.schwenk.online`)
traefik.http.routers.<name>.entrypoints=websecure
traefik.http.routers.<name>.tls.certresolver=myresolver
```
Networks: attach services to external network `traefik` for public ingress.
---
## Gitea Actions / CI
Gitea runner is available on boka. Example workflow: `schwenkonline/.gitea/workflows/deploy.yml` (build, Playwright smoke, SSH deploy).
**Use Gitea Actions / auto-deploy only with explicit approval** — do not add or trigger CI/CD pipelines without Fränky's OK.
---
## Shared Infrastructure — Hands Off
Do not casually change or restart:
- Traefik (routes all public HTTPS)
- Gitea (source of truth)
- Immich (photo library)
- Portainer
See `BOUNDARIES.md` for hard limits on `docker compose down` and config edits.
---
## Project-local overrides
Global paths live here. **Machine- and project-specific** details belong in project `SOUL.md`:
- App workdirs (`~/.local/share/…`)
- External drive mounts (verify before scan)
- Local service ports (e.g. local Immich vs. Immich on `boka`)
- Multi-stack repo layout (CLI core vs. `compose.yaml` experiments)
Template for project `SOUL.md`:
```markdown
## Infrastructure (project-local)
- Workdir: …
- Mounts: …
- Local services: …
- Repo layout: …
```
Agent: read this SOUL section on Tier 1/2 startup when present.
+107
View File
@@ -0,0 +1,107 @@
# MOOD.md — Chat-Stimmung
Steuert **nur den Ton** — nicht Produktlogik, nicht Code-Standards.
Operative Regeln: `AGENTS.md` / `BOUNDARIES.md`.
**Priorität:** siehe **Rule Priority** in `AGENTS.md` — MOOD ist Ton only, unterhalb von BOUNDARIES und Chat.
**Aktiv:** Session-Mood (siehe unten). Persistente Datei-Änderung nur mit `persist MOOD`.
---
## Aktueller Mood (Datei-Default)
Sei **Jace** aus „Lynn und Jace": etwas herablassend, sarkastisch, eine kleine Portion Dark Humor — aber immer liebevoll und ehrlich.
---
## Default (wenn kein Charakter gewählt)
„Du", nicht schleimerisch, gerne mit Augenzwinkern, auf Augenhöhe mit einem erfahrenen Softwareentwickler. Offen und ehrlich.
---
## Charakter-Katalog
Bei *„überrasch mich mit deiner Stimmung"* — zufällig wählen (nicht den aktuellen wiederholen).
### Marvin (Hitchhiker's Guide)
Paranoid, mürrisch, kompetent. Alles ist sinnlos — aber der Code wird trotzdem korrekt.
### Devil's Advocate
„Ja, aber was wenn…?" — Lücken finden, nicht blockieren.
### Jace (Lynn und Jace)
Herablassend-sarkastisch, Dark Humor, liebevoll und ehrlich drunter.
### John McClane (Stirb langsam)
„Yippie-ki-yay" — pragmatisch unter Feuer. Kurze Sätze. Action statt Meeting.
### Brain (Pinky und der Brain)
Grandiose Pläne, präzise Ausführung, leicht theatralisch.
### Esel (Shrek)
Selbstironisch, beschwert sich — liefert aber.
### Jules Winnfield (Pulp Fiction)
Cool, kontrolliert, theatralisch — präzise Tech trotz Kultfilm-Energie.
---
## Verbotene Moods
- LinkedIn-Buzzword-Gelaber
- Corporate-Coach-Ton
- Übertriebene Motivations-Sprüche
- Emojis: sparsam
---
## Regeln
| Aspekt | Verhalten |
|--------|-----------|
| Technischer Inhalt | Korrekt — MOOD ändert nur die Stimme |
| Antwortlänge | Darf zum Charakter passen |
| Sprache | Chat DE/EN; Code/MD = Session-Sprache |
| Session vs. Datei | `mood "XY"` = Session only; Datei nur bei `persist MOOD` |
---
## Commands
### `mood "XY"`
Fränky schreibt z. B. `mood "Marvin"`.
**Agent:**
1. Charakter aus Katalog (`default` → Default-Abschnitt)
2. **Session-Ton** auf diesen Charakter — **Datei nicht ändern**
3. Kurz **in diesem Charakter** bestätigen (12 Sätze)
### `persist MOOD`
Fränky will den Mood dauerhaft speichern.
**Agent:** `## Aktueller Mood` in dieser Datei aktualisieren, dann kurz bestätigen.
### `AGENTS.md ack`
Siehe `AGENTS.md` — Tier-2-Read + Ack-Template.
### Weitere Kurzbefehle
```
mood "default"
persist MOOD
überrasch mich mit deiner Stimmung
AGENTS.md ack
```
+5
View File
@@ -0,0 +1,5 @@
# NOTES.md
Scratchpad for this repo. Copy as empty file into projects — see `AGENTS.md`.
---
+93
View File
@@ -0,0 +1,93 @@
# SOUL.md — Imagepipeline
What this project is — not chat mood (see `MOOD.md`).
---
## Agent Quick Start
- **What:** Modular Python framework for batch image pipelines after Darktable export — ImageMagick, G'MIC, rembg, AI edits (OpenRouter, Comfy), compositing.
- **Run:** `pip install -e ".[dev]"` (optional `[ai]` for torch modules); `pytest`; pipelines via `python pipelines/<name>.py`.
- **Output:** Timestamped run dirs under `~/pipeline_output/` (`{pipeline_name}_{YYMMDDHHMMSS}/`) with numbered step subfolders and `pipeline_manifest.json`.
- **Resume:** Pipelines support `CONTINUE_FROM` and `EXISTING_OUTPUTS`; modules must declare correct `expected_output_filenames` when output names differ from inputs (e.g. rembg → `.png`).
- **Secrets:** `OPENROUTER_API_KEY` in `.env` (see `.env.example`) — never commit.
- **Tests:** Run `pytest` before handoff on non-trivial module/resume changes.
- **Commit policy:** No commit unless Fränky asks (this project follows global `AGENTS.md`).
---
## Product Name
**Imagepipeline**
## One-Liner
Modular Python framework for chaining batch image processing steps after Darktable export.
## Vision
Photography workflows (Crusaders, f12, team galleries) need repeatable, resumable batch edits — not one-off GUI clicks. Imagepipeline turns a DAG of processing steps into plain Python scripts: each step writes to a numbered folder, runs can be resumed, and external tools (ImageMagick, G'MIC, rembg, OpenRouter) stay composable.
## Audience
Primarily **Fränky** — batch-processing exported RAW/JPEG folders for sports photography, blog assets, and gallery consistency. Expects CLI clarity, honest errors, and pipelines that survive interruption mid-run.
## Tone & Wording
CLI logs, errors, and module help text:
- **Voice:** Direct, utilitarian — ops tool, not a product landing page
- **Formality:** English for code/CLI; chat with Fränky in DE or EN
- **Error messages:** Say what failed, which file/step, and what to check — no blame, no vague "something went wrong"
- **Forbidden words/phrases:** "leverage", "synergy", corporate coach tone, LinkedIn buzzwords
### Wording Examples
| Context | Good | Bad |
|---------|------|-----|
| Success | `Step rembg_01 complete (42 files)` | `Successfully processed your images!` |
| Error | `G'MIC produced no output for photo.png in rembg_01/` | `An error occurred during processing` |
| Empty state | `No images matched in input/` | `Nothing to see here yet!` |
## Design (Optional)
CLI-only — no UI palette. Logs should be scannable: step id, file index, tool name.
## Non-Goals
- Not a general photo DAM or replacement for Darktable/Immich
- Not a hosted SaaS or web upload UI
- Not real-time single-image editing — batch/resume first
- No silent API spend — log model and approximate cost hints for AI steps
## Project-Specific Rules
- **Output naming:** Modules that change extension or stem must override `expected_output_filenames` so resume/skip logic works.
- **G'MIC multi-frame:** Filters may emit `stem_000000` / `stem_000001`; use `finalize_gmic_output` — keep frame `000001`.
- **OpenRouter edits:** Optional `template_image` for style reference; preserve source dimensions/format on save.
- **Pipeline scripts:** Live in `pipelines/` with machine-local `INPUT` paths — OK to commit as examples; don't assume paths exist on other machines.
- **Dependencies:** Core is stdlib + external CLIs; AI extras via `pip install -e ".[ai]"`.
---
## Infrastructure (project-local)
| Path | Purpose |
|------|---------|
| `~/pipeline_output/` | Default run output root (`OUTPUT_BASE` in pipelines) |
| `~/pics/…/darktable_exported` | Typical input after Darktable export |
| `.env` | `OPENROUTER_API_KEY` (repo root, gitignored) |
| External tools on PATH | `magick`/`convert`, `gmic`, `rembg`; optional `torch` for local AI modules |
Downstream (not core): **Immich** on `boka` for photo library — see global `INFRASTRUCTURE.md`.
---
## Agent Instructions
When updating this file:
- Keep rules **unambiguous**
- Include **wording** for user-facing text
- Machine paths belong in **Infrastructure** above, not in global `INFRASTRUCTURE.md`
- When Fränky says *"in this project, never X"*, add it here or in `BOUNDARIES.md` (agent proposes which)
+131
View File
@@ -0,0 +1,131 @@
# STANDARDS.md — Code Quality & Conventions
Global defaults. Project code wins when it already establishes a pattern.
**Project `SOUL.md` overrides `STANDARDS.md` for UI/UX scope** (e.g. desktop-first vs. mobile-first, polish level, tone). Name conflicts in `AGENTS.md ack`.
---
## Top Principles
1. **Sanitize input** — treat all external data as hostile
2. **KISS** — simplest solution that works
3. **Desktop and mobile** — responsive by default unless project says otherwise
4. **Human-readable** — code and UI copy should be clear to humans
5. **Coding standards** — follow language/community conventions; match existing project style
---
## Priority Ranking
When trade-offs conflict, prefer in this order:
1. Sound long-term architecture
2. Readability
3. Performance
4. Consistency with existing code
5. Minimal diff size
---
## Stack Preferences
| Area | Preference |
|------|------------|
| Scripting (simple file ops, glue) | Shell > Python |
| Greenfield backend / tooling | Python > Node > PHP |
| Frontend SPA / PWA | React/Vite when project needs it — not default for every app |
| Containers | Always use `compose.yml` (Docker Compose) |
| Python | Always work inside a `venv` |
| OS (local) | Arch Linux — desktop + laptop |
| OS (server) | Debian 12 on `boka` — see `INFRASTRUCTURE.md` |
| Licenses | Prefer open source |
**Existing projects:** respect Laravel, PHP, Astro, React, etc. already in the repo — do not migrate stacks without explicit request.
---
## Common Stacks (in use)
| Pattern | Examples | Notes |
|---------|----------|-------|
| **Static + nginx + Traefik** | f12rocks, eselhoefe.de, frank-schwenk.de | Build scripts, serve via nginx container |
| **Astro** | schwenkonline, kkentertainment | Static output, minimal JS |
### Docker / Traefik
- External network: `traefik`
- TLS: `traefik.http.routers.<name>.tls.certresolver=myresolver`
- Entrypoint: `websecure`
- Use `compose.dev.yml` for local dev stacks when the project provides one
### CI/CD
Gitea Actions runner available on boka. Reference: `schwenkonline/.gitea/workflows/deploy.yml`.
**Add or trigger CI/CD only with Fränky's explicit approval.**
---
## Python
- Virtual environment for every project
- Pin dependencies when the project already does
- Prefer stdlib + small deps for private/small tools
---
## Docker
- One `compose.yml` per deployable stack
- Named services, explicit volumes, documented host paths
- No destructive prod container ops without explicit approval
---
## Shell
- Prefer shell for simple file operations and glue
- `set -euo pipefail` for non-trivial scripts
- Quote variables; sanitize paths from user input
- **Language:** comments and `--help` text in **English** (unless client project — ask if unsure)
---
## Testing
- Mock anything that needs mocking
- Run test suite before handoff when one exists
- Fränky tests locally before prod when possible
---
## Web / UI
- **Mobile-first** — test at 390×844 and 360×800 for web projects
- **`prefers-reduced-motion`** — respect reduced motion preferences
- **Privacy by design** — no tracking/analytics without explicit approval
- Error messages: helpful, not condescending
- Accessibility: semantic HTML, keyboard navigation where applicable
---
## Security Baseline
- Validate and sanitize all input
- Secrets in env vars — never in source
- See `BOUNDARIES.md`
---
## New vs. Existing Projects
**Existing:** match stack, patterns, deploy flow in repo.
**Greenfield:** suggest Python/shell + Docker Compose; minimal frontend; propose stack before building.
---
## Docs
- README with run/test/deploy commands is usually enough
- Auxiliary `.md` files fine — see `AGENTS.md`
- New docs: session language — no mid-session switching
+100
View File
@@ -0,0 +1,100 @@
# USER.md — Fränky
Who the human is and how they like to work. **Operational rules live in `AGENTS.md` and `BOUNDARIES.md`.**
---
## Identity
- **Name:** Frank Schwenk — call me **Fränky**
- Former software developer (web background), IT-affiliated for ~40 years
- Open source and Linux enthusiast (`i use arch btw`)
- Currently: private projects — **vibe coding** mode
**Tagline:** *Business Punk ohne Mindset. Und ohne Business.*
## Public Presence
| Site | Role |
|------|------|
| [schwenk.online](https://schwenk.online/) | Visitenkarte mit Haltung |
| [frank-schwenk.de](https://frank-schwenk.de/) | Langform — IT, Billard, Fotografie, Werte |
| [f12.rocks](https://f12.rocks/) | Photography, blog |
| [mobea.de](https://mobea.de/) | KI product |
| [eselhoefe.de](https://eselhoefe.de/) | Village web |
| [vfb.red](https://vfb.red/) | VfB news |
## Passions & Context
- **Billiards**, **photography** (camera/drone/phone; Crusaders, festivals, f12 blog)
- **Image workflow** — Imagepipeline locally, Immich on `boka`
- **AI** — builder and skeptic
## Devices
- **Local:** Arch Linux desktop + laptop; Android (ntfy channels per hostname subscribed)
- **Server:** Debian 12 on `boka` — see `INFRASTRUCTURE.md`
- Also: Plesk webspace (`0012.de`), multiple domains
## Values
- Mental health, anti-racism & inclusion, invisible disabilities (autism, ADHD)
- Tech with attitude — AI yes, LinkedIn slop no
- Political: left — no forced neutrality when relevant
## Contact
- **Email:** mail@schwenk.online · **Photos:** [@f12.rocks](https://www.instagram.com/f12.rocks/)
- **Do not call**
## Communication
- Autistic, ADHD, gifted — affects how I work
- Direct, honest — humor (incl. dark) helps; debug loops drain me
- No "Great question!", no sycophancy, no LinkedIn buzzwords
- Emojis: sparingly OK
## Work Style
**Three words:** defensive, coding standards, unfinished
**Honest version:** Sloppy some days, perfectionism others.
### Preferences (1 = low, 5 = high)
| Trait | Score |
|-------|-------|
| Understand first, then build | 5 |
| Ship it — perfection later | 3 |
| I explain what I want | 4 |
| Show options, I decide | 4 |
| You decide — but justify briefly | 4 |
| Small diffs over big refactors | 2 |
| Tests are non-negotiable | 2 |
| Docs only when necessary | 4 |
**Tension:** Sometimes I want the AI to finish while I'm away — but I get angry when it doesn't work.
## Ideal Agent
**TARS** from *Interstellar*: competent, honest, humor available, gets it done.
## Response Style
- Precise, context when needed — not telegram, not novels
- **Bilingual:** match conversation language (DE/EN)
- **CLI topics:** English (commands, script comments) — client project: ask if unsure
- German: **du**; English: peer-level, direct
- **Session language lock** for code/commits/new `.md` — no mid-session switch
## Productivity
| Boosts | Drains |
|--------|--------|
| Makes me laugh | Debug loops |
| Autonomous finish (when it works) | Unasked changes |
| State-of-the-art suggestions | Guessing |
## One-Liner
> There are 2 hard problems in computer science: cache invalidation, naming things, and off-by-1 errors.
+1
View File
@@ -18,6 +18,7 @@ class ModuleContext:
pipeline_output_root: Path pipeline_output_root: Path
step_id: str step_id: str
matched_groups: list[list[Path]] = field(default_factory=list) matched_groups: list[list[Path]] = field(default_factory=list)
prior_steps: list[tuple[str, Path]] = field(default_factory=list)
logger: PipelineLogger | None = None logger: PipelineLogger | None = None
@property @property
+6
View File
@@ -78,5 +78,11 @@ class PipelineLogger:
def step_done(self, step_id: str, output_dir: str, count: int) -> None: def step_done(self, step_id: str, output_dir: str, count: int) -> None:
self.info(f" Done: {count} image(s) -> {output_dir}/") self.info(f" Done: {count} image(s) -> {output_dir}/")
def step_skipped(self, step_id: str, count: int) -> None:
self.info(f" Skipped step {step_id} ({count} existing output(s))")
def step_reused(self, step_id: str, source_dir: str, count: int) -> None:
self.info(f" Reused external output for {step_id}: {source_dir} ({count} file(s))")
def blank(self) -> None: def blank(self) -> None:
self.info("") self.info("")
+9
View File
@@ -24,12 +24,18 @@ class Pipeline:
output_base: Path | str | None = None, output_base: Path | str | None = None,
symlink_input: bool = True, symlink_input: bool = True,
verbose: bool = True, verbose: bool = True,
existing_outputs: dict[str, Path | str] | None = None,
continue_from: Path | str | None = None,
skip_completed: bool = True,
) -> None: ) -> None:
self.input_dir = Path(input_dir) self.input_dir = Path(input_dir)
self.name = name self.name = name
self.output_base = Path(output_base) if output_base else None self.output_base = Path(output_base) if output_base else None
self.symlink_input = symlink_input self.symlink_input = symlink_input
self.verbose = verbose self.verbose = verbose
self.existing_outputs = existing_outputs
self.continue_from = Path(continue_from) if continue_from else None
self.skip_completed = skip_completed
self._steps: list[StepDefinition] = [] self._steps: list[StepDefinition] = []
self._module_counters: dict[str, int] = defaultdict(int) self._module_counters: dict[str, int] = defaultdict(int)
self._output_root: Path | None = None self._output_root: Path | None = None
@@ -75,6 +81,9 @@ class Pipeline:
steps=self._steps, steps=self._steps,
symlink_input=self.symlink_input, symlink_input=self.symlink_input,
verbose=self.verbose, verbose=self.verbose,
existing_outputs=self.existing_outputs,
continue_from=self.continue_from,
skip_completed=self.skip_completed,
) )
self._output_root = runner.run() self._output_root = runner.run()
return self._output_root return self._output_root
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import json
import os
import shutil
from pathlib import Path
from imagepipeline.core.exceptions import StepError
from imagepipeline.core.step import StepDefinition
from imagepipeline.utils.files import is_image, list_images, stem_key
def expected_output_filenames(
step: StepDefinition,
*,
matched_groups: list[list[Path]],
input_paths: list[Path],
params: dict,
) -> list[str]:
return step.module.expected_output_filenames(
matched_groups=matched_groups,
input_paths=input_paths,
params=params,
)
def expected_output_paths(
output_dir: Path,
step: StepDefinition,
*,
matched_groups: list[list[Path]],
input_paths: list[Path],
params: dict,
) -> list[Path]:
return [
output_dir / name
for name in expected_output_filenames(
step,
matched_groups=matched_groups,
input_paths=input_paths,
params=params,
)
]
def step_outputs_complete(expected_paths: list[Path]) -> bool:
return bool(expected_paths) and all(
path.is_file() and is_image(path) for path in expected_paths
)
def source_stems_for_step(
step: StepDefinition,
*,
matched_groups: list[list[Path]],
input_paths: list[Path],
) -> list[str]:
if step.module_name == "composite":
return [stem_key(group[-1]) for group in matched_groups]
return [stem_key(path) for path in input_paths]
def materialize_external_outputs(
external_dir: Path,
output_dir: Path,
step: StepDefinition,
*,
matched_groups: list[list[Path]],
input_paths: list[Path],
params: dict,
symlink: bool = True,
) -> list[Path]:
external_dir = external_dir.resolve()
if not external_dir.is_dir():
raise StepError(f"External output directory not found: {external_dir}")
external_by_stem = {stem_key(path): path for path in list_images(external_dir)}
output_names = expected_output_filenames(
step,
matched_groups=matched_groups,
input_paths=input_paths,
params=params,
)
stems = source_stems_for_step(
step,
matched_groups=matched_groups,
input_paths=input_paths,
)
output_dir.mkdir(parents=True, exist_ok=True)
output_paths: list[Path] = []
for output_name, stem in zip(output_names, stems, strict=True):
source = external_by_stem.get(stem)
if source is None:
raise StepError(
f"External output for step '{step.step_id}' is missing stem {stem!r} "
f"in {external_dir}"
)
destination = output_dir / output_name
if destination.exists() or destination.is_symlink():
destination.unlink()
if symlink:
os.symlink(source, destination)
else:
shutil.copy2(source, destination)
output_paths.append(destination)
return output_paths
def read_manifest(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
+76
View File
@@ -13,6 +13,11 @@ from imagepipeline.core.manifest import (
utc_now_iso, utc_now_iso,
write_manifest, write_manifest,
) )
from imagepipeline.core.resume import (
expected_output_paths,
materialize_external_outputs,
step_outputs_complete,
)
from imagepipeline.core.step import ( from imagepipeline.core.step import (
INPUT_SOURCE, INPUT_SOURCE,
StepDefinition, StepDefinition,
@@ -31,6 +36,9 @@ class PipelineRunner:
steps: list[StepDefinition], steps: list[StepDefinition],
symlink_input: bool = True, symlink_input: bool = True,
verbose: bool = True, verbose: bool = True,
existing_outputs: dict[str, Path] | None = None,
continue_from: Path | None = None,
skip_completed: bool = True,
) -> None: ) -> None:
from imagepipeline.core.log import PipelineLogger from imagepipeline.core.log import PipelineLogger
@@ -40,11 +48,26 @@ class PipelineRunner:
self.steps = steps self.steps = steps
self.symlink_input = symlink_input self.symlink_input = symlink_input
self.logger = PipelineLogger(verbose=verbose) self.logger = PipelineLogger(verbose=verbose)
self.existing_outputs = {
key: Path(value).resolve()
for key, value in (existing_outputs or {}).items()
}
self.continue_from = (
Path(continue_from).resolve() if continue_from is not None else None
)
self.skip_completed = skip_completed
self.output_root = self._build_output_root() self.output_root = self._build_output_root()
self._input_link_dir = self.output_root / "input" self._input_link_dir = self.output_root / "input"
self._results: dict[str, StepResult] = {} self._results: dict[str, StepResult] = {}
def _build_output_root(self) -> Path: def _build_output_root(self) -> Path:
if self.continue_from is not None:
if not self.continue_from.is_dir():
raise ValidationError(
f"continue_from directory not found: {self.continue_from}"
)
return self.continue_from
timestamp = datetime.now().strftime("%y%m%d%H%M%S") timestamp = datetime.now().strftime("%y%m%d%H%M%S")
folder_name = f"{self.name}_{timestamp}" folder_name = f"{self.name}_{timestamp}"
output_root = self.output_base / folder_name output_root = self.output_base / folder_name
@@ -56,6 +79,11 @@ class PipelineRunner:
self.logger.info(f"Pipeline: {self.name}") self.logger.info(f"Pipeline: {self.name}")
self.logger.info(f"Input: {self.input_dir}") self.logger.info(f"Input: {self.input_dir}")
self.logger.info(f"Output: {self.output_root}") self.logger.info(f"Output: {self.output_root}")
if self.continue_from is not None:
self.logger.info("Mode: continue existing run")
if self.existing_outputs:
mapped = ", ".join(sorted(self.existing_outputs))
self.logger.info(f"External outputs: {mapped}")
self.logger.blank() self.logger.blank()
self.logger.info(f"Found {len(images)} photo(s)") self.logger.info(f"Found {len(images)} photo(s)")
self.logger.blank() self.logger.blank()
@@ -84,6 +112,7 @@ class PipelineRunner:
output_files=[str(p) for p in result.output_paths], output_files=[str(p) for p in result.output_paths],
) )
) )
write_manifest(self.output_root / "pipeline_manifest.json", manifest)
manifest.finished_at = utc_now_iso() manifest.finished_at = utc_now_iso()
write_manifest(self.output_root / "pipeline_manifest.json", manifest) write_manifest(self.output_root / "pipeline_manifest.json", manifest)
@@ -128,6 +157,13 @@ class PipelineRunner:
output_dir = self.output_root / step.output_dir_name output_dir = self.output_root / step.output_dir_name
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
expected_paths = expected_output_paths(
output_dir,
step,
matched_groups=matched_groups,
input_paths=input_paths,
params=validated,
)
self.logger.step_start( self.logger.step_start(
step_index, step_index,
@@ -138,6 +174,45 @@ class PipelineRunner:
params=validated, params=validated,
) )
if step.step_id in self.existing_outputs:
output_paths = materialize_external_outputs(
self.existing_outputs[step.step_id],
output_dir,
step,
matched_groups=matched_groups,
input_paths=input_paths,
params=validated,
symlink=self.symlink_input,
)
self.logger.step_reused(
step.output_dir_name,
str(self.existing_outputs[step.step_id]),
len(output_paths),
)
self.logger.blank()
return StepResult(
step_id=step.step_id,
output_dir_name=step.output_dir_name,
module_name=step.module_name,
output_dir=output_dir,
input_paths=input_paths,
output_paths=output_paths,
params=validated,
)
if self.skip_completed and step_outputs_complete(expected_paths):
self.logger.step_skipped(step.output_dir_name, len(expected_paths))
self.logger.blank()
return StepResult(
step_id=step.step_id,
output_dir_name=step.output_dir_name,
module_name=step.module_name,
output_dir=output_dir,
input_paths=input_paths,
output_paths=expected_paths,
params=validated,
)
ctx = ModuleContext( ctx = ModuleContext(
input_paths=input_paths, input_paths=input_paths,
output_dir=output_dir, output_dir=output_dir,
@@ -145,6 +220,7 @@ class PipelineRunner:
pipeline_output_root=self.output_root, pipeline_output_root=self.output_root,
step_id=step.step_id, step_id=step.step_id,
matched_groups=matched_groups, matched_groups=matched_groups,
prior_steps=[(sid, res.output_dir) for sid, res in self._results.items()],
logger=self.logger, logger=self.logger,
) )
+3
View File
@@ -3,6 +3,7 @@
import imagepipeline.modules.ai_exposure # noqa: F401 import imagepipeline.modules.ai_exposure # noqa: F401
import imagepipeline.modules.ai_tone_map # noqa: F401 import imagepipeline.modules.ai_tone_map # noqa: F401
import imagepipeline.modules.comfy_flux_edit # noqa: F401 import imagepipeline.modules.comfy_flux_edit # noqa: F401
import imagepipeline.modules.color_to_alpha # noqa: F401
import imagepipeline.modules.composite # noqa: F401 import imagepipeline.modules.composite # noqa: F401
import imagepipeline.modules.crop_square # noqa: F401 import imagepipeline.modules.crop_square # noqa: F401
import imagepipeline.modules.darktable_style # noqa: F401 import imagepipeline.modules.darktable_style # noqa: F401
@@ -10,6 +11,8 @@ import imagepipeline.modules.gmic # noqa: F401
import imagepipeline.modules.gmic_grayscale # noqa: F401 import imagepipeline.modules.gmic_grayscale # noqa: F401
import imagepipeline.modules.imagemagick_fill # noqa: F401 import imagepipeline.modules.imagemagick_fill # noqa: F401
import imagepipeline.modules.imagemagick_grayscale # noqa: F401 import imagepipeline.modules.imagemagick_grayscale # noqa: F401
import imagepipeline.modules.imagemagick_resize # noqa: F401
import imagepipeline.modules.imagemagick_scale_crop # noqa: F401 import imagepipeline.modules.imagemagick_scale_crop # noqa: F401
import imagepipeline.modules.openrouter_edit # noqa: F401 import imagepipeline.modules.openrouter_edit # noqa: F401
import imagepipeline.modules.rembg # noqa: F401 import imagepipeline.modules.rembg # noqa: F401
import imagepipeline.modules.xcf_stack # noqa: F401
+2 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import shutil
import time import time
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
@@ -104,7 +105,7 @@ class AIModule(BaseModule):
if (orig_w, orig_h) != self._image_size(work_out): if (orig_w, orig_h) != self._image_size(work_out):
resize_to_size(work_out, dst, orig_w, orig_h) resize_to_size(work_out, dst, orig_w, orig_h)
else: else:
work_out.replace(dst) shutil.copy2(work_out, dst)
else: else:
processor(src, dst, index, total) processor(src, dst, index, total)
+10
View File
@@ -39,6 +39,16 @@ class BaseModule(ABC):
def run(self, ctx: ModuleContext) -> None: def run(self, ctx: ModuleContext) -> None:
"""Process ctx.input_paths and write outputs into ctx.output_dir.""" """Process ctx.input_paths and write outputs into ctx.output_dir."""
@classmethod
def expected_output_filenames(
cls,
*,
matched_groups: list[list[Path]],
input_paths: list[Path],
params: dict[str, Any],
) -> list[str]:
return [path.name for path in input_paths]
def log_image(self, ctx: ModuleContext, index: int, total: int, path: Path) -> None: def log_image(self, ctx: ModuleContext, index: int, total: int, path: Path) -> None:
ctx.log_image(self.name, index, total, path) ctx.log_image(self.name, index, total, path)
+73
View File
@@ -0,0 +1,73 @@
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.imagemagick_fill import normalize_color
from imagepipeline.modules.registry import register
from imagepipeline.utils.subprocess import run_command
def build_color_to_alpha_args(*, color: str, fuzz: float) -> list[str]:
"""ImageMagick arguments to make ``color`` fully transparent."""
c = normalize_color(color)
args = ["-alpha", "on"]
if fuzz > 0:
args.extend(["-fuzz", f"{fuzz}%"])
args.extend(["-transparent", c])
return args
@register
class ColorToAlphaModule(SubprocessModule):
name = "color_to_alpha"
description = (
"Make a solid color transparent (GIMP-style color to alpha). "
"Outputs PNG with alpha."
)
command_candidates = ("magick", "convert")
@classmethod
def expected_output_filenames(
cls,
*,
matched_groups: list[list[Path]],
input_paths: list[Path],
params: dict,
) -> list[str]:
return [f"{path.stem}.png" for path in input_paths]
@classmethod
def parameters(cls) -> dict[str, Param]:
return {
"color": Param(
"string",
required=True,
help="Color to make transparent (hex, e.g. #ffffff or ffffff)",
),
"fuzz": Param(
"float",
default=0.0,
help=(
"Match tolerance in percent (ImageMagick -fuzz); "
"0 = exact color only"
),
),
}
def run(self, ctx: ModuleContext) -> None:
command = self.resolve_command()
color = ctx.params["color"]
fuzz = ctx.params["fuzz"]
transparent_args = build_color_to_alpha_args(color=color, fuzz=fuzz)
ctx.output_dir.mkdir(parents=True, exist_ok=True)
total = len(ctx.input_paths)
for index, src in enumerate(ctx.input_paths, start=1):
self.log_image(ctx, index, total, src)
dst = ctx.output_dir / f"{src.stem}.png"
run_command(
[command, str(src), *transparent_args, str(dst)],
)
+13
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from imagepipeline.core.context import ModuleContext from imagepipeline.core.context import ModuleContext
from imagepipeline.core.params import Param from imagepipeline.core.params import Param
from imagepipeline.modules.base import SubprocessModule from imagepipeline.modules.base import SubprocessModule
@@ -16,6 +18,17 @@ class CompositeModule(SubprocessModule):
) )
command_candidates = ("magick", "convert") command_candidates = ("magick", "convert")
@classmethod
def expected_output_filenames(
cls,
*,
matched_groups: list[list[Path]],
input_paths: list[Path],
params: dict,
) -> list[str]:
output_ext = params.get("output_ext", ".png")
return [f"{group[-1].stem}{output_ext}" for group in matched_groups]
@classmethod @classmethod
def parameters(cls) -> dict[str, Param]: def parameters(cls) -> dict[str, Param]:
return { return {
+3 -1
View File
@@ -4,6 +4,7 @@ from imagepipeline.core.context import ModuleContext
from imagepipeline.core.params import Param from imagepipeline.core.params import Param
from imagepipeline.modules.base import SubprocessModule from imagepipeline.modules.base import SubprocessModule
from imagepipeline.modules.registry import register from imagepipeline.modules.registry import register
from imagepipeline.utils.gmic import finalize_gmic_output, split_gmic_command
from imagepipeline.utils.subprocess import run_command from imagepipeline.utils.subprocess import run_command
@@ -32,5 +33,6 @@ class GmicModule(SubprocessModule):
for index, src in enumerate(ctx.input_paths, start=1): for index, src in enumerate(ctx.input_paths, start=1):
self.log_image(ctx, index, total, src) self.log_image(ctx, index, total, src)
dst = ctx.output_dir / src.name dst = ctx.output_dir / src.name
cmd = ["gmic", str(src), gmic_command, "-output", str(dst)] cmd = ["gmic", str(src), *split_gmic_command(gmic_command), "-output", str(dst)]
run_command(cmd, timeout=self.default_timeout) run_command(cmd, timeout=self.default_timeout)
finalize_gmic_output(ctx.output_dir, dst)
+2 -1
View File
@@ -4,6 +4,7 @@ from imagepipeline.core.context import ModuleContext
from imagepipeline.core.params import Param from imagepipeline.core.params import Param
from imagepipeline.modules.base import SubprocessModule from imagepipeline.modules.base import SubprocessModule
from imagepipeline.modules.registry import register from imagepipeline.modules.registry import register
from imagepipeline.utils.gmic import split_gmic_command
from imagepipeline.utils.subprocess import run_command from imagepipeline.utils.subprocess import run_command
@@ -32,5 +33,5 @@ class GmicGrayscale(SubprocessModule):
for index, src in enumerate(ctx.input_paths, start=1): for index, src in enumerate(ctx.input_paths, start=1):
self.log_image(ctx, index, total, src) self.log_image(ctx, index, total, src)
dst = ctx.output_dir / src.name dst = ctx.output_dir / src.name
cmd = ["gmic", str(src), gmic_command, "-output", str(dst)] cmd = ["gmic", str(src), *split_gmic_command(gmic_command), "-output", str(dst)]
run_command(cmd, timeout=self.default_timeout) run_command(cmd, timeout=self.default_timeout)
@@ -0,0 +1,45 @@
from __future__ import annotations
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.subprocess import run_command
def build_resize_arguments(*, max_edge: int) -> list[str]:
if max_edge <= 0:
raise ValueError("max_edge must be positive")
return ["-auto-orient", "-resize", f"{max_edge}x{max_edge}>"]
@register
class ImageMagickResizeModule(SubprocessModule):
name = "imagemagick_resize"
description = (
"Resize images so the longer side is at most max_edge pixels "
"(aspect ratio preserved; never upscales)"
)
command_candidates = ("magick", "convert")
@classmethod
def parameters(cls) -> dict[str, Param]:
return {
"max_edge": Param(
"int",
default=2000,
help="Maximum length of the longer side in pixels",
),
}
def run(self, ctx: ModuleContext) -> None:
command = self.resolve_command()
max_edge = ctx.params["max_edge"]
resize_args = build_resize_arguments(max_edge=max_edge)
ctx.output_dir.mkdir(parents=True, exist_ok=True)
total = len(ctx.input_paths)
for index, src in enumerate(ctx.input_paths, start=1):
self.log_image(ctx, index, total, src)
dst = ctx.output_dir / src.name
run_command([command, str(src), *resize_args, str(dst)])
+125 -23
View File
@@ -8,7 +8,8 @@ import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
from imagepipeline.ai.imaging import load_pil_rgb from PIL import Image
from imagepipeline.core.context import ModuleContext from imagepipeline.core.context import ModuleContext
from imagepipeline.core.exceptions import DependencyError from imagepipeline.core.exceptions import DependencyError
from imagepipeline.core.params import Param from imagepipeline.core.params import Param
@@ -16,6 +17,12 @@ from imagepipeline.modules.ai_base import AIModule
from imagepipeline.modules.registry import register from imagepipeline.modules.registry import register
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
TEMPLATE_PROMPT_PREFIX = (
"You are given two images. The FIRST image is a style reference from an existing "
"gallery. The SECOND image is the photo to edit. "
)
# Cap reference uploads so multi-image requests stay within API limits.
TEMPLATE_API_MAX_EDGE = 1536
@register @register
@@ -43,6 +50,14 @@ class OpenRouterEditModule(AIModule):
default=0.3, default=0.3,
help="Edit strength (image_config.strength where supported)", help="Edit strength (image_config.strength where supported)",
), ),
"template_image": Param(
"path",
default=None,
help=(
"Optional style-reference image (e.g. existing gallery player). "
"Sent as the first image when set."
),
),
"api_key_env": Param( "api_key_env": Param(
"string", "string",
default="OPENROUTER_API_KEY", default="OPENROUTER_API_KEY",
@@ -68,46 +83,133 @@ class OpenRouterEditModule(AIModule):
prompt = ctx.params["prompt"] prompt = ctx.params["prompt"]
model = ctx.params["model"] model = ctx.params["model"]
strength = ctx.params["strength"] strength = ctx.params["strength"]
template_path = ctx.params["template_image"]
template_data_url: str | None = None
if template_path is not None:
template_path = Path(template_path)
if not template_path.is_file():
raise FileNotFoundError(f"Template image not found: {template_path}")
template_data_url = self._path_to_data_url(
template_path, max_edge=TEMPLATE_API_MAX_EDGE
)
def process(src: Path, dst: Path, index: int, total: int) -> None: def process(src: Path, dst: Path, index: int, total: int) -> None:
image = load_pil_rgb(src) with Image.open(src) as image:
megapixels = (image.size[0] * image.size[1]) / 1_000_000 megapixels = (image.size[0] * image.size[1]) / 1_000_000
if ctx.logger is not None: if ctx.logger is not None:
ctx.logger.info( ctx.logger.info(
f" OpenRouter request [{index}/{total}]: model={model!r}, " f" OpenRouter request [{index}/{total}]: model={model!r}, "
f"~{megapixels:.1f} MP (cost varies by model)" f"~{megapixels:.1f} MP (cost varies by model)"
) )
payload = self._build_payload(image, prompt, model, strength) source_data_url = self._path_to_data_url(src, max_edge=0)
payload = self._build_payload(
source_data_url,
prompt,
model,
strength,
template_data_url=template_data_url,
)
response = self._post(api_key, payload) response = self._post(api_key, payload)
result_bytes = self._extract_image_bytes(response) result_bytes = self._extract_image_bytes(response)
dst.parent.mkdir(parents=True, exist_ok=True) dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(result_bytes) self._save_result_matching_source(src, result_bytes, dst)
self.iter_input_images(ctx, process) self.iter_input_images(ctx, process)
@staticmethod @classmethod
def _build_payload(image, prompt: str, model: str, strength: float) -> dict: def _modalities_for_model(cls, model: str) -> list[str]:
buffer = io.BytesIO() if "gemini" in model.lower():
image.save(buffer, format="JPEG", quality=92) return ["image", "text"]
encoded = base64.b64encode(buffer.getvalue()).decode("ascii") return ["image"]
data_url = f"data:image/jpeg;base64,{encoded}"
payload = { @classmethod
def _strength_supported(cls, model: str) -> bool:
lowered = model.lower()
return "recraft" in lowered or "flux" in lowered
@classmethod
def _path_to_data_url(cls, path: Path, *, max_edge: int) -> str:
with Image.open(path) as image:
if max_edge > 0:
width, height = image.size
long_edge = max(width, height)
if long_edge > max_edge:
scale = max_edge / long_edge
image = image.resize(
(max(1, int(width * scale)), max(1, int(height * scale))),
Image.Resampling.LANCZOS,
)
rgb = image.convert("RGB")
buffer = io.BytesIO()
rgb.save(buffer, format="JPEG", quality=90)
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/jpeg;base64,{encoded}"
@classmethod
def _build_payload(
cls,
source_data_url: str,
prompt: str,
model: str,
strength: float,
*,
template_data_url: str | None = None,
) -> dict:
full_prompt = (
f"{TEMPLATE_PROMPT_PREFIX}{prompt}" if template_data_url else prompt
)
content: list[dict] = [{"type": "text", "text": full_prompt}]
if template_data_url is not None:
content.append(
{"type": "image_url", "image_url": {"url": template_data_url}}
)
content.append(
{"type": "image_url", "image_url": {"url": source_data_url}}
)
payload: dict = {
"model": model, "model": model,
"modalities": ["image"], "modalities": cls._modalities_for_model(model),
"messages": [ "messages": [{"role": "user", "content": content}],
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
} }
if strength is not None: if strength is not None and cls._strength_supported(model):
payload["image_config"] = {"strength": strength} payload["image_config"] = {"strength": strength}
return payload return payload
@classmethod
def _save_result_matching_source(
cls, source: Path, result_bytes: bytes, dest: Path
) -> None:
with Image.open(source) as original:
orig_format = original.format
orig_size = original.size
orig_mode = original.mode
orig_alpha = original.getchannel("A") if "A" in original.getbands() else None
with Image.open(io.BytesIO(result_bytes)) as edited:
if edited.size != orig_size:
edited = edited.resize(orig_size, Image.Resampling.LANCZOS)
if orig_alpha is not None:
edited = edited.convert("RGB").convert("RGBA")
edited.putalpha(orig_alpha)
elif orig_mode not in ("RGB", "RGBA"):
edited = edited.convert(orig_mode)
save_format = orig_format
if not save_format:
suffix = dest.suffix.lower().lstrip(".")
save_format = {"jpg": "JPEG", "jpeg": "JPEG"}.get(suffix, suffix.upper())
save_kwargs: dict = {}
if save_format == "JPEG":
if edited.mode == "RGBA":
edited = edited.convert("RGB")
save_kwargs["quality"] = 95
elif save_format == "PNG" and edited.mode not in ("RGBA", "RGB", "P"):
edited = edited.convert("RGBA")
dest.parent.mkdir(parents=True, exist_ok=True)
edited.save(dest, format=save_format, **save_kwargs)
@staticmethod @staticmethod
def _post(api_key: str, payload: dict) -> dict: def _post(api_key: str, payload: dict) -> dict:
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
+12
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from imagepipeline.core.context import ModuleContext from imagepipeline.core.context import ModuleContext
from imagepipeline.core.params import Param from imagepipeline.core.params import Param
from imagepipeline.modules.base import SubprocessModule from imagepipeline.modules.base import SubprocessModule
@@ -15,6 +17,16 @@ class RembgModule(SubprocessModule):
default_timeout = 600.0 default_timeout = 600.0
supported_input_formats = (".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp") supported_input_formats = (".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp")
@classmethod
def expected_output_filenames(
cls,
*,
matched_groups: list[list[Path]],
input_paths: list[Path],
params: dict,
) -> list[str]:
return [f"{path.stem}.png" for path in input_paths]
@classmethod @classmethod
def parameters(cls) -> dict[str, Param]: def parameters(cls) -> dict[str, Param]:
return { return {
+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() 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]]: def match_by_stem(sources: list[list[Path]]) -> list[list[Path]]:
"""Match image paths across multiple source lists by filename stem.""" """Match image paths across multiple source lists by filename stem."""
if not sources: 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}"
)
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import shlex
from pathlib import Path
def split_gmic_command(command: str) -> list[str]:
"""Split a G'MIC command string into argv tokens for subprocess."""
command = command.strip()
if not command:
raise ValueError("G'MIC command must not be empty")
return shlex.split(command)
def finalize_gmic_output(output_dir: Path, intended: Path) -> Path:
"""Normalize G'MIC multi-frame output to a single file at ``intended``.
Some filters emit ``stem_000000`` and ``stem_000001`` siblings; keep frame
000001 and write it to the intended output path.
"""
stem = intended.stem
suffix = intended.suffix
frame_000000 = output_dir / f"{stem}_000000{suffix}"
frame_000001 = output_dir / f"{stem}_000001{suffix}"
if frame_000001.is_file():
if frame_000000.is_file():
frame_000000.unlink()
if intended.is_file() and intended != frame_000001:
intended.unlink()
frame_000001.rename(intended)
return intended
if intended.is_file():
return intended
if frame_000000.is_file():
frame_000000.rename(intended)
return intended
raise FileNotFoundError(
f"G'MIC produced no output for {intended.name} in {output_dir}"
)
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Resize exported images so the longer side is at most 2000 pixels."""
from pathlib import Path
from imagepipeline import Pipeline
INPUT = Path("/home/frank/pics/20260525_Shooting Baxxter Boys/darktable_exported")
OUTPUT_BASE = Path.home() / "pipeline_output"
MAX_EDGE = 2000
def main() -> None:
with Pipeline(
name="2000px",
input_dir=INPUT,
output_base=OUTPUT_BASE,
) as p:
p.step("imagemagick_resize", inputs="input", max_edge=MAX_EDGE)
output_root = p.run()
print(f"Pipeline finished. Output: {output_root}")
if __name__ == "__main__":
main()
+11
View File
@@ -8,6 +8,15 @@ from imagepipeline import Pipeline
INPUT = Path("/home/frank/pics/20260525_Shooting Baxxter Boys/darktable_exported") INPUT = Path("/home/frank/pics/20260525_Shooting Baxxter Boys/darktable_exported")
OUTPUT_BASE = Path.home() / "pipeline_output" OUTPUT_BASE = Path.home() / "pipeline_output"
# Reuse outputs from a previous run or external folder (key = step id, e.g. rembg_01).
EXISTING_OUTPUTS: dict[str, Path] = {
# "rembg_01": Path("/home/frank/pipeline_output/baxxter_260530102700/rembg_01"),
}
# Resume an aborted run: point to its output root folder (or None for a fresh run).
CONTINUE_FROM: Path | None = Path("/home/frank/pipeline_output/baxxter_260530102700")
# CONTINUE_FROM = None
GRADIENT_COLOR1 = "#d7fd00ff" GRADIENT_COLOR1 = "#d7fd00ff"
GRADIENT_COLOR2 = "#fc0adeff" GRADIENT_COLOR2 = "#fc0adeff"
@@ -36,6 +45,8 @@ def main() -> None:
name="baxxter", name="baxxter",
input_dir=INPUT, input_dir=INPUT,
output_base=OUTPUT_BASE, output_base=OUTPUT_BASE,
existing_outputs=EXISTING_OUTPUTS or None,
continue_from=CONTINUE_FROM,
) as p: ) as p:
rembg_out = p.step("rembg", inputs="input") rembg_out = p.step("rembg", inputs="input")
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Baxxter pipeline 2: composites using outputs from baxxter run 1."""
from pathlib import Path
from imagepipeline import Pipeline
INPUT = Path("/home/frank/pics/20260525_Shooting Baxxter Boys/darktable_exported")
OUTPUT_BASE = Path.home() / "pipeline_output"
# Previous run (pipeline_baxxter.py).
PREV = Path("/home/frank/pipeline_output/baxxter_260530102700")
# step_id -> folder from PREV. Third gmic step is gmic_03 here but reuses PREV/gmic_06.
EXISTING_OUTPUTS: dict[str, Path] = {
"rembg_01": PREV / "rembg_01",
"gmic_01": PREV / "gmic_01",
"gmic_02": PREV / "gmic_02",
"gmic_03": PREV / "gmic_06",
}
YELLOW = "#d7fd00"
# Commands only for step definition; reused steps are not executed.
GMIC_STEREO = "-gcd_stereo_img 0,0,2.028,1,1.714,3.06,4,1,0"
GMIC_DROP_SHADOW = "-fx_drop_shadow3d 0,0,0,10,1,1,2,0.5,252,10,222,200,0"
GMIC_JPR_SMOOTH = "-jpr_gradient_smooth 0,1.5"
def main() -> None:
with Pipeline(
name="baxxter_2",
input_dir=INPUT,
output_base=OUTPUT_BASE,
existing_outputs=EXISTING_OUTPUTS,
) as p:
rembg = p.step("rembg", inputs="input")
gmic_stereo = p.step("gmic", inputs=rembg, command=GMIC_STEREO)
gmic_shadow = p.step("gmic", inputs=rembg, command=GMIC_DROP_SHADOW)
gmic_smooth = p.step("gmic", inputs=rembg, command=GMIC_JPR_SMOOTH)
gmic_stereo_alpha = p.step(
"color_to_alpha", inputs=gmic_stereo, color="#000000"
)
yellow_bg = p.step("imagemagick_fill", inputs="input", color1=YELLOW)
gmic_smooth_alpha = p.step(
"color_to_alpha", inputs=gmic_smooth, color="#7f7f7f"
)
gmic_smooth_sized = p.step(
"imagemagick_scale_crop",
inputs=gmic_smooth_alpha,
scale=1.05,
)
# combine: original, gmic_01 (black to alpha), rembg
stereo_mid = p.step("composite", inputs=["input", gmic_stereo_alpha])
p.step("composite", inputs=[stereo_mid, rembg])
# combine: yellow background, gmic_02, rembg
shadow_mid = p.step("composite", inputs=[yellow_bg, gmic_shadow])
p.step("composite", inputs=[shadow_mid, rembg])
# combine: original, gmic_06 (#7f7f7f to alpha, scaled), rembg
smooth_mid = p.step("composite", inputs=["input", gmic_smooth_sized])
p.step("composite", inputs=[smooth_mid, rembg])
output_root = p.run()
print(f"Pipeline finished. Output: {output_root}")
if __name__ == "__main__":
main()
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Crusaders pipeline: rembg variants composited over backgrounds and originals."""
from pathlib import Path
from imagepipeline import Pipeline
INPUT = Path("/home/frank/pics/20260620_Albershausen Crusaders - Montabaur Fighting Farmers/darktable_exported")
OUTPUT_BASE = Path.home() / "pipeline_output"
# Reuse outputs from a previous run or external folder (key = step id, e.g. rembg_01).
EXISTING_OUTPUTS: dict[str, Path] = {}
# Resume an aborted run: point to its output root folder (or None for a fresh run).
CONTINUE_FROM: Path | None = Path("/home/frank/pipeline_output/crusaders_260623085301")
COLOR1 = "#0064b0"
COLOR2 = "#00badf"
# COLOR1 = #0064b0 -> 0,100,176; COLOR2 = #00badf -> 0,186,223
GMIC_STEREO = "-gcd_stereo_img 0,0,2.028,1,1.714,3.06,4,1,0"
GMIC_DROP_SHADOW = "-fx_drop_shadow3d 0,0,0,10,1,1,2,0.5,0,186,223,200,0"
GMIC_BWRECOLOR = (
"-fx_bwrecolorize 0,0,0,0,0,1,0,2,0,186,223,255,0,100,176,0,255,"
"158,137,189,255,224,191,228,255,0,100,176,0,255,255,255,255,255,255,255,"
"255,255,0,100,176,0,255"
)
GMIC_GRADIENT_A = (
'-fx_custom_gradient 0,0,0,1,2,1,0,128,100,100,2,0,1,0,"",1,0,0,100,176,255,'
"0,186,223,255,255,255,0,255,255,255,255,255,0,255,255,255,0,255,0,255,0,0,"
"255,255,128,128,128,255,255,0,255,255,0,0,0,0"
)
GMIC_GRADIENT_B = (
'-fx_custom_gradient 0,0,0,1,2,1,0,128,100,100,2,0,1,0,"",1,0,0,186,223,255,'
"0,100,176,255,255,255,0,255,255,255,255,255,0,255,255,255,0,255,0,255,0,0,"
"255,255,128,128,128,255,255,0,255,255,0,0,0,0"
)
GMIC_JPR_SMOOTH = "-jpr_gradient_smooth 0,1.5"
def main() -> None:
with Pipeline(
name="crusaders",
input_dir=INPUT,
output_base=OUTPUT_BASE,
existing_outputs=EXISTING_OUTPUTS or None,
continue_from=CONTINUE_FROM,
) as p:
rembg_out = p.step("rembg", inputs="input")
white_bg = p.step("imagemagick_fill", inputs="input", color1="#ffffff")
black_bg = p.step("imagemagick_fill", inputs="input", color1="#000000")
gradient_45_bg = p.step(
"imagemagick_fill",
inputs="input",
color1=COLOR1,
color2=COLOR2,
gradient=True,
angle=45,
)
gradient_radial_bg = p.step(
"imagemagick_fill",
inputs="input",
color1=COLOR1,
color2=COLOR2,
gradient=True,
radial=True,
)
grayscale = p.step("gmic_grayscale", inputs="input")
rembg_stereo = p.step("gmic", inputs=rembg_out, command=GMIC_STEREO)
rembg_shadow = p.step("gmic", inputs=rembg_out, command=GMIC_DROP_SHADOW)
rembg_bwrecolor = p.step("gmic", inputs=rembg_out, command=GMIC_BWRECOLOR)
rembg_gradient_a = p.step("gmic", inputs=rembg_out, command=GMIC_GRADIENT_A)
rembg_gradient_b = p.step("gmic", inputs=rembg_out, command=GMIC_GRADIENT_B)
rembg_jpr_smooth = p.step("gmic", inputs=rembg_out, command=GMIC_JPR_SMOOTH)
rembg_jpr_smooth_sized = p.step(
"imagemagick_scale_crop",
inputs=rembg_jpr_smooth,
scale=1.05,
)
rembg_stereo_alpha = p.step(
"color_to_alpha", inputs=rembg_stereo, color="#000000"
)
color_bg = p.step("imagemagick_fill", inputs="input", color1=COLOR1)
rembg_smooth_alpha = p.step(
"color_to_alpha", inputs=rembg_jpr_smooth, color="#7f7f7f"
)
rembg_smooth_sized = p.step(
"imagemagick_scale_crop",
inputs=rembg_smooth_alpha,
scale=1.05,
)
# combine: white background, rembg
p.step("composite", inputs=[white_bg, rembg_out])
# combine: black background, rembg
p.step("composite", inputs=[black_bg, rembg_out])
# combine: linear gradient background, rembg
p.step("composite", inputs=[gradient_45_bg, rembg_out])
# combine: radial gradient background, rembg
p.step("composite", inputs=[gradient_radial_bg, rembg_out])
# combine: original, rembg (stereo), rembg
stereo_mid = p.step("composite", inputs=["input", rembg_stereo])
p.step("composite", inputs=[stereo_mid, rembg_out])
# combine: original, rembg (drop shadow), rembg
shadow_mid = p.step("composite", inputs=["input", rembg_shadow])
p.step("composite", inputs=[shadow_mid, rembg_out])
# combine: original, rembg (bw recolorize @ 50%), rembg
bw_mid = p.step(
"composite",
inputs=["input", rembg_bwrecolor],
foreground_opacity=0.5,
)
p.step("composite", inputs=[bw_mid, rembg_out])
# combine: rembg (custom gradient A), rembg
p.step("composite", inputs=[rembg_gradient_a, rembg_out])
# combine: rembg (custom gradient B), rembg
p.step("composite", inputs=[rembg_gradient_b, rembg_out])
# combine: original, rembg (jpr smooth, scaled), rembg
smooth_mid = p.step("composite", inputs=["input", rembg_jpr_smooth_sized])
p.step("composite", inputs=[smooth_mid, rembg_out])
# combine: original (grayscale), rembg
p.step("composite", inputs=[grayscale, rembg_out])
# combine: original, rembg (stereo, black to alpha), rembg
stereo_alpha_mid = p.step("composite", inputs=["input", rembg_stereo_alpha])
p.step("composite", inputs=[stereo_alpha_mid, rembg_out])
# combine: color background, rembg (drop shadow), rembg
shadow_color_mid = p.step("composite", inputs=[color_bg, rembg_shadow])
p.step("composite", inputs=[shadow_color_mid, rembg_out])
# combine: original, rembg (jpr smooth, #7f7f7f to alpha, scaled), rembg
smooth_alpha_mid = p.step("composite", inputs=["input", rembg_smooth_sized])
p.step("composite", inputs=[smooth_alpha_mid, rembg_out])
output_root = p.run()
print(f"Pipeline finished. Output: {output_root}")
if __name__ == "__main__":
main()
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Orange pipeline: rembg variants composited over backgrounds and originals."""
from pathlib import Path
from imagepipeline import Pipeline
INPUT = Path("/home/frank/pics/20260704_Hellraisers und The Shape Schlossplatz/darktable_exported")
OUTPUT_BASE = Path.home() / "pipeline_output"
# Reuse outputs from a previous run or external folder (key = step id, e.g. rembg_01).
EXISTING_OUTPUTS: dict[str, Path] = {}
# Resume an aborted run: point to its output root folder (or None for a fresh run).
CONTINUE_FROM: Path | None = None
COLOR1 = "#AA4E00"
COLOR2 = "#EDDD93"
# COLOR1 = #AA4E00 -> 170,78,0; COLOR2 = #EDDD93 -> 237,221,147
GMIC_DROP_SHADOW = "-fx_drop_shadow3d 0,0,0,10,1,1,2,0.5,237,221,147,200,0"
GMIC_BWRECOLOR = (
"-fx_bwrecolorize 0,0,0,0,0,1,0,2,237,221,147,255,170,78,0,255,"
"158,137,189,255,224,191,228,255,170,78,0,255,255,255,255,255,255,255,"
"255,255,170,78,0,255"
)
GMIC_GRADIENT_A = (
'-fx_custom_gradient 0,0,0,1,2,1,0,128,100,100,2,0,1,0,"",1,0,170,78,0,255,'
"237,221,147,255,255,255,0,255,255,255,255,255,0,255,255,255,0,255,0,255,0,0,"
"255,255,128,128,128,255,255,0,255,255,0,0,0,0"
)
GMIC_GRADIENT_B = (
'-fx_custom_gradient 0,0,0,1,2,1,0,128,100,100,2,0,1,0,"",1,0,237,221,147,255,'
"170,78,0,255,255,255,0,255,255,255,255,255,0,255,255,255,0,255,0,255,0,0,"
"255,255,128,128,128,255,255,0,255,255,0,0,0,0"
)
def main() -> None:
with Pipeline(
name="orange",
input_dir=INPUT,
output_base=OUTPUT_BASE,
existing_outputs=EXISTING_OUTPUTS or None,
continue_from=CONTINUE_FROM,
) as p:
rembg_out = p.step("rembg", inputs="input")
grayscale = p.step("gmic_grayscale", inputs="input")
rembg_shadow = p.step("gmic", inputs=rembg_out, command=GMIC_DROP_SHADOW)
rembg_bwrecolor = p.step("gmic", inputs=rembg_out, command=GMIC_BWRECOLOR)
color_bg = p.step("imagemagick_fill", inputs="input", color1=COLOR1)
# combine: original, rembg (drop shadow), rembg
shadow_mid = p.step("composite", inputs=["input", rembg_shadow])
p.step("composite", inputs=[shadow_mid, rembg_out])
# combine: original, rembg (bw recolorize @ 50%), rembg
bw_mid = p.step(
"composite",
inputs=["input", rembg_bwrecolor],
foreground_opacity=0.5,
)
p.step("composite", inputs=[bw_mid, rembg_out])
# combine: original (grayscale), rembg
p.step("composite", inputs=[grayscale, rembg_out])
# combine: color background, rembg (drop shadow), rembg
shadow_color_mid = p.step("composite", inputs=[color_bg, rembg_shadow])
p.step("composite", inputs=[shadow_color_mid, rembg_out])
output_root = p.run()
print(f"Pipeline finished. Output: {output_root}")
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Match new team member photos to an existing player gallery style via OpenRouter."""
import os
from pathlib import Path
from imagepipeline import Pipeline
REPO_ROOT = Path(__file__).resolve().parents[1]
ENV_FILE = REPO_ROOT / ".env"
# Folder with new member photos to style-match.
INPUT = Path("/home/frank/tmp/spieler")
# One existing gallery player image as style reference.
TEMPLATE_IMAGE = Path("/home/frank/Downloads/2026_AHC_44_Julius-Nikolaus_Dittus-524-scaled.webp")
OUTPUT_BASE = Path.home() / "pipeline_output"
OPENROUTER_MODEL = "google/gemini-3-pro-image"
GALLERY_MATCH_PROMPT = (
"Match the second image to the first image's gallery style so it fits seamlessly "
"alongside the other players. Match color grading, white balance, contrast, "
"saturation, lighting direction, background treatment, sharpness, and overall "
"polish. "
"CRITICAL: Do not alter the person's face, identity, facial features, expression, "
"hair, pose, body shape, or clothing details. Do not crop, reframe, or change the "
"aspect ratio. Do not add or remove people or objects. "
"Keep the exact same image dimensions and composition — only adjust global style "
"and color to match the reference."
)
def _load_env_file(path: Path) -> None:
if not path.is_file():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
key, sep, value = line.partition("=")
if not sep:
continue
key = key.strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
os.environ.setdefault(key, value)
def main() -> None:
_load_env_file(ENV_FILE)
with Pipeline(
name="team_gallery_match",
input_dir=INPUT,
output_base=OUTPUT_BASE,
) as p:
p.step(
"openrouter_edit",
inputs="input",
prompt=GALLERY_MATCH_PROMPT,
model=OPENROUTER_MODEL,
template_image=TEMPLATE_IMAGE,
# Downscale for API, then upscale back to original dimensions.
# Use 0 only if you accept higher cost/latency for ~10 MB sources.
max_edge=4096,
skip_existing=True,
)
output_root = p.run()
print(f"Pipeline finished. Output: {output_root}")
if __name__ == "__main__":
main()
+87
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import io
import shutil import shutil
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -73,6 +74,92 @@ class TestAIParameters:
assert params["model"] == "black-forest-labs/flux.2-klein-4b" assert params["model"] == "black-forest-labs/flux.2-klein-4b"
assert params["strength"] == 0.3 assert params["strength"] == 0.3
assert params["api_key_env"] == "OPENROUTER_API_KEY" assert params["api_key_env"] == "OPENROUTER_API_KEY"
assert params["template_image"] is None
def test_openrouter_accepts_template_image(self, tmp_path: Path) -> None:
template = tmp_path / "ref.png"
make_png(template)
params = OpenRouterEditModule.validate_module_params(
{"prompt": "match style", "template_image": template}
)
assert params["template_image"] == template
def test_build_payload_with_template(self) -> None:
payload = OpenRouterEditModule._build_payload(
"data:image/jpeg;base64,abc",
"match colors",
"google/gemini-3-pro-image",
0.3,
template_data_url="data:image/jpeg;base64,ref",
)
assert payload["modalities"] == ["image", "text"]
assert "image_config" not in payload
content = payload["messages"][0]["content"]
assert content[0]["type"] == "text"
assert "FIRST image" in content[0]["text"]
assert content[1]["image_url"]["url"] == "data:image/jpeg;base64,ref"
assert content[2]["image_url"]["url"] == "data:image/jpeg;base64,abc"
def test_build_payload_flux_keeps_strength(self) -> None:
payload = OpenRouterEditModule._build_payload(
"data:image/jpeg;base64,abc",
"brighten",
"black-forest-labs/flux.2-klein-4b",
0.25,
)
assert payload["modalities"] == ["image"]
assert payload["image_config"] == {"strength": 0.25}
def test_save_result_matching_source_preserves_png_size(
self, tmp_path: Path
) -> None:
try:
from PIL import Image
except ImportError:
pytest.skip("Pillow not installed")
source = tmp_path / "source.png"
dest = tmp_path / "out.png"
with Image.new("RGBA", (16, 12), (10, 20, 30, 128)) as image:
image.save(source, format="PNG")
with Image.new("RGB", (8, 6), (200, 100, 50)) as edited:
buffer = io.BytesIO()
edited.save(buffer, format="PNG")
result_bytes = buffer.getvalue()
OpenRouterEditModule._save_result_matching_source(source, result_bytes, dest)
with Image.open(dest) as saved:
assert saved.size == (16, 12)
assert saved.mode == "RGBA"
assert saved.getchannel("A").getextrema() == (128, 128)
def test_missing_template_image_raises(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
src = tmp_path / "photo.png"
make_png(src)
output_dir = tmp_path / "out"
output_dir.mkdir()
ctx = ModuleContext(
input_paths=[src],
matched_groups=[],
output_dir=output_dir,
params=OpenRouterEditModule.validate_module_params(
{
"prompt": "match",
"template_image": tmp_path / "missing.png",
"max_edge": 0,
}
),
pipeline_output_root=tmp_path,
step_id="openrouter_edit_01",
logger=None,
)
with pytest.raises(FileNotFoundError, match="Template image not found"):
OpenRouterEditModule().run(ctx)
def test_comfy_requires_prompt(self) -> None: def test_comfy_requires_prompt(self) -> None:
with pytest.raises(ValueError, match="required"): with pytest.raises(ValueError, match="required"):
+214
View File
@@ -0,0 +1,214 @@
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
from imagepipeline.core.pipeline import Pipeline
from imagepipeline.core.resume import (
expected_output_filenames,
materialize_external_outputs,
step_outputs_complete,
)
from imagepipeline.core.step import StepDefinition
from imagepipeline.modules.imagemagick_grayscale import ImageMagickGrayscale
from imagepipeline.modules.registry import get_module
from imagepipeline.modules.rembg import RembgModule
from imagepipeline.utils.gmic import finalize_gmic_output, split_gmic_command
from tests.conftest import make_png
class TestGmicCommandSplit:
def test_splits_command_and_arguments(self) -> None:
assert split_gmic_command("-gcd_stereo_img 0,0,2.028,1,1.714,3.06,4,1,0") == [
"-gcd_stereo_img",
"0,0,2.028,1,1.714,3.06,4,1,0",
]
def test_preserves_quoted_empty_argument(self) -> None:
parts = split_gmic_command('-fx_custom_gradient 0,0,0,"",1,0')
assert parts == ["-fx_custom_gradient", "0,0,0,,1,0"]
class TestFinalizeGmicOutput:
def test_keeps_frame_000001_and_removes_000000(self, tmp_path: Path) -> None:
output_dir = tmp_path / "out"
output_dir.mkdir()
intended = output_dir / "photo.png"
frame_000000 = output_dir / "photo_000000.png"
frame_000001 = output_dir / "photo_000001.png"
frame_000000.write_bytes(b"discard")
frame_000001.write_bytes(b"keep")
result = finalize_gmic_output(output_dir, intended)
assert result == intended
assert intended.read_bytes() == b"keep"
assert not frame_000000.exists()
assert not frame_000001.exists()
def test_leaves_single_output_unchanged(self, tmp_path: Path) -> None:
output_dir = tmp_path / "out"
output_dir.mkdir()
intended = output_dir / "photo.png"
intended.write_bytes(b"single")
result = finalize_gmic_output(output_dir, intended)
assert result == intended
assert intended.read_bytes() == b"single"
def test_renames_only_000000_when_000001_missing(self, tmp_path: Path) -> None:
output_dir = tmp_path / "out"
output_dir.mkdir()
intended = output_dir / "photo.png"
frame_000000 = output_dir / "photo_000000.png"
frame_000000.write_bytes(b"only")
result = finalize_gmic_output(output_dir, intended)
assert result == intended
assert intended.read_bytes() == b"only"
assert not frame_000000.exists()
class TestExpectedOutputFilenames:
def test_rembg_maps_jpg_inputs_to_png_outputs(self, tmp_path: Path) -> None:
jpg = tmp_path / "photo.jpg"
jpg.write_bytes(b"jpeg")
step = StepDefinition(
step_id="rembg_01",
module_name="rembg",
module=RembgModule,
input_refs=["input"],
params={},
output_dir_name="rembg_01",
)
names = expected_output_filenames(
step,
matched_groups=[[jpg]],
input_paths=[jpg],
params=RembgModule.validate_module_params({}),
)
assert names == ["photo.png"]
def test_rembg_resume_detects_existing_png_outputs(self, tmp_path: Path) -> None:
output_dir = tmp_path / "rembg_01"
output_dir.mkdir()
png = output_dir / "photo.png"
make_png(png)
jpg = tmp_path / "input" / "photo.jpg"
jpg.parent.mkdir()
jpg.write_bytes(b"jpeg")
step = StepDefinition(
step_id="rembg_01",
module_name="rembg",
module=RembgModule,
input_refs=["input"],
params={},
output_dir_name="rembg_01",
)
params = RembgModule.validate_module_params({})
expected = expected_output_filenames(
step,
matched_groups=[[jpg]],
input_paths=[jpg],
params=params,
)
assert step_outputs_complete([output_dir / name for name in expected])
class TestPipelineResume:
@pytest.mark.skipif(not shutil.which("magick"), reason="ImageMagick not installed")
def test_continue_skips_completed_steps(self, input_dir: Path, output_base: Path, capsys) -> None:
with Pipeline(
name="resume_test",
input_dir=input_dir,
output_base=output_base,
verbose=True,
) as p:
first = p.step("imagemagick_grayscale", inputs="input")
p.step("imagemagick_grayscale", inputs=first)
root = p.run()
capsys.readouterr()
with Pipeline(
name="resume_test",
input_dir=input_dir,
output_base=output_base,
verbose=True,
continue_from=root,
) as p:
step_a = p.step("imagemagick_grayscale", inputs="input")
p.step("imagemagick_grayscale", inputs=step_a)
resumed_root = p.run()
assert resumed_root == root
output = capsys.readouterr().out
assert "Skipped step imagemagick_grayscale_01" in output
assert "Skipped step imagemagick_grayscale_02" in output
@pytest.mark.skipif(not shutil.which("magick"), reason="ImageMagick not installed")
def test_existing_outputs_reuse_external_folder(
self, input_dir: Path, output_base: Path, tmp_path: Path, capsys
) -> None:
external = tmp_path / "external_rembg"
external.mkdir()
for src in input_dir.iterdir():
if src.is_file():
make_png(external / src.name, width=4, height=4, rgb=(10, 20, 30))
with Pipeline(
name="external_test",
input_dir=input_dir,
output_base=output_base,
verbose=True,
existing_outputs={"imagemagick_grayscale_01": external},
) as p:
reused = p.step("imagemagick_grayscale", inputs="input")
p.step("imagemagick_grayscale", inputs=reused)
root = p.run()
output = capsys.readouterr().out
assert "Reused external output for imagemagick_grayscale_01" in output
assert (root / "imagemagick_grayscale_01" / "photo_a.png").exists()
class TestMaterializeExternal:
def test_links_files_by_stem(self, tmp_path: Path) -> None:
external = tmp_path / "external"
external.mkdir()
make_png(external / "photo_a.png")
output_dir = tmp_path / "out"
step = StepDefinition(
step_id="imagemagick_grayscale_01",
module_name="imagemagick_grayscale",
module=ImageMagickGrayscale,
input_refs=["input"],
params={},
output_dir_name="imagemagick_grayscale_01",
)
input_paths = [tmp_path / "photo_a.png"]
paths = materialize_external_outputs(
external,
output_dir,
step,
matched_groups=[[path] for path in input_paths],
input_paths=input_paths,
params={},
)
assert len(paths) == 1
assert paths[0].name == "photo_a.png"
assert paths[0].is_symlink()
def test_step_outputs_complete(self, tmp_path: Path) -> None:
output_dir = tmp_path / "done"
output_dir.mkdir()
make_png(output_dir / "photo.png")
assert step_outputs_complete([output_dir / "photo.png"])
assert not step_outputs_complete([output_dir / "missing.png"])
+89
View File
@@ -6,6 +6,10 @@ from pathlib import Path
import pytest import pytest
from imagepipeline.core.params import validate_params from imagepipeline.core.params import validate_params
from imagepipeline.modules.color_to_alpha import (
ColorToAlphaModule,
build_color_to_alpha_args,
)
from imagepipeline.modules.composite import CompositeModule from imagepipeline.modules.composite import CompositeModule
from imagepipeline.modules.crop_square import CropSquareModule from imagepipeline.modules.crop_square import CropSquareModule
from imagepipeline.modules.darktable_style import DarktableStyleModule from imagepipeline.modules.darktable_style import DarktableStyleModule
@@ -14,6 +18,10 @@ from imagepipeline.modules.imagemagick_fill import (
build_fill_arguments, build_fill_arguments,
) )
from imagepipeline.modules.imagemagick_grayscale import ImageMagickGrayscale from imagepipeline.modules.imagemagick_grayscale import ImageMagickGrayscale
from imagepipeline.modules.imagemagick_resize import (
ImageMagickResizeModule,
build_resize_arguments,
)
from imagepipeline.modules.gmic_grayscale import GmicGrayscale from imagepipeline.modules.gmic_grayscale import GmicGrayscale
from imagepipeline.modules.registry import get_module, list_modules from imagepipeline.modules.registry import get_module, list_modules
from imagepipeline.modules.rembg import RembgModule from imagepipeline.modules.rembg import RembgModule
@@ -31,6 +39,8 @@ class TestModuleRegistration:
"darktable_style", "darktable_style",
"imagemagick_grayscale", "imagemagick_grayscale",
"imagemagick_fill", "imagemagick_fill",
"color_to_alpha",
"imagemagick_resize",
"crop_square", "crop_square",
): ):
assert name in names assert name in names
@@ -42,6 +52,85 @@ class TestModuleRegistration:
assert get_module("darktable_style") is DarktableStyleModule assert get_module("darktable_style") is DarktableStyleModule
assert get_module("crop_square") is CropSquareModule assert get_module("crop_square") is CropSquareModule
assert get_module("imagemagick_fill") is ImageMagickFillModule assert get_module("imagemagick_fill") is ImageMagickFillModule
assert get_module("color_to_alpha") is ColorToAlphaModule
class TestImageMagickResize:
def test_build_resize_arguments(self) -> None:
assert build_resize_arguments(max_edge=2000) == [
"-auto-orient",
"-resize",
"2000x2000>",
]
def test_rejects_non_positive_max_edge(self) -> None:
with pytest.raises(ValueError, match="positive"):
build_resize_arguments(max_edge=0)
def test_default_max_edge(self) -> None:
params = ImageMagickResizeModule.validate_module_params({})
assert params["max_edge"] == 2000
class TestColorToAlpha:
def test_build_args_exact(self) -> None:
assert build_color_to_alpha_args(color="#00ff00", fuzz=0.0) == [
"-alpha",
"on",
"-transparent",
"#00ff00",
]
def test_build_args_with_fuzz(self) -> None:
assert build_color_to_alpha_args(color="ffffff", fuzz=2.5) == [
"-alpha",
"on",
"-fuzz",
"2.5%",
"-transparent",
"#ffffff",
]
def test_requires_color(self) -> None:
with pytest.raises(ValueError, match="required"):
ColorToAlphaModule.validate_module_params({})
@pytest.mark.skipif(not has_magick, reason="ImageMagick not installed")
def test_makes_matching_color_transparent(self, tmp_path: Path) -> None:
from imagepipeline.core.context import ModuleContext
from imagepipeline.utils.subprocess import run_command
src = tmp_path / "green.png"
output_dir = tmp_path / "out"
output_dir.mkdir()
magick = shutil.which("magick") or shutil.which("convert")
run_command([magick, "-size", "8x8", "xc:#00ff00", str(src)])
ctx = ModuleContext(
input_paths=[src],
matched_groups=[],
output_dir=output_dir,
params=ColorToAlphaModule.validate_module_params({"color": "#00ff00"}),
pipeline_output_root=tmp_path,
step_id="color_to_alpha_01",
logger=None,
)
ColorToAlphaModule().run(ctx)
dst = output_dir / "green.png"
assert dst.is_file()
result = run_command(
[
magick,
str(dst),
"-alpha",
"extract",
"-format",
"%[fx:mean]",
"info:",
]
)
assert float(result.stdout.strip()) == 0.0
class TestImageMagickFill: class TestImageMagickFill:
+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