From ce431d7eecb23cd7d0e7bca78050389544a12212 Mon Sep 17 00:00:00 2001 From: Frank Schwenk Date: Sun, 12 Jul 2026 10:45:00 +0200 Subject: [PATCH] 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 --- AGENTS.md | 275 +++++++++++++++++++++++ BOUNDARIES.md | 77 +++++++ INFRASTRUCTURE.md | 120 ++++++++++ MOOD.md | 107 +++++++++ NOTES.md | 5 + SOUL.md | 93 ++++++++ STANDARDS.md | 131 +++++++++++ USER.md | 100 +++++++++ imagepipeline/core/resume.py | 9 +- imagepipeline/modules/ai_base.py | 3 +- imagepipeline/modules/base.py | 10 + imagepipeline/modules/color_to_alpha.py | 10 + imagepipeline/modules/composite.py | 13 ++ imagepipeline/modules/gmic.py | 3 +- imagepipeline/modules/openrouter_edit.py | 148 ++++++++++-- imagepipeline/modules/rembg.py | 12 + imagepipeline/utils/gmic.py | 32 +++ pipelines/pipeline_crusaders.py | 155 +++++++++++++ pipelines/pipeline_orange.py | 80 +++++++ pipelines/pipeline_team_gallery_match.py | 77 +++++++ tests/test_ai_modules.py | 87 +++++++ tests/test_resume.py | 100 ++++++++- 22 files changed, 1616 insertions(+), 31 deletions(-) create mode 100644 AGENTS.md create mode 100644 BOUNDARIES.md create mode 100644 INFRASTRUCTURE.md create mode 100644 MOOD.md create mode 100644 NOTES.md create mode 100644 SOUL.md create mode 100644 STANDARDS.md create mode 100644 USER.md create mode 100644 pipelines/pipeline_crusaders.py create mode 100644 pipelines/pipeline_orange.py create mode 100644 pipelines/pipeline_team_gallery_match.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9aa1865 --- /dev/null +++ b/AGENTS.md @@ -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 : " +# or on failure: +ntfyschwenkonline "FAIL : , see " +``` + +- **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`**. diff --git a/BOUNDARIES.md b/BOUNDARIES.md new file mode 100644 index 0000000..a030c97 --- /dev/null +++ b/BOUNDARIES.md @@ -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. diff --git a/INFRASTRUCTURE.md b/INFRASTRUCTURE.md new file mode 100644 index 0000000..f2c9ae8 --- /dev/null +++ b/INFRASTRUCTURE.md @@ -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/.git` | +| Server app paths | `/home/frank//` (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/ +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..rule=Host(`example.schwenk.online`) +traefik.http.routers..entrypoints=websecure +traefik.http.routers..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. diff --git a/MOOD.md b/MOOD.md new file mode 100644 index 0000000..40693c9 --- /dev/null +++ b/MOOD.md @@ -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 (1–2 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 +``` diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..b41622b --- /dev/null +++ b/NOTES.md @@ -0,0 +1,5 @@ +# NOTES.md + +Scratchpad for this repo. Copy as empty file into projects — see `AGENTS.md`. + +--- diff --git a/SOUL.md b/SOUL.md new file mode 100644 index 0000000..2cf829f --- /dev/null +++ b/SOUL.md @@ -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/.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) diff --git a/STANDARDS.md b/STANDARDS.md new file mode 100644 index 0000000..4ed3c05 --- /dev/null +++ b/STANDARDS.md @@ -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..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 diff --git a/USER.md b/USER.md new file mode 100644 index 0000000..79b5895 --- /dev/null +++ b/USER.md @@ -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. diff --git a/imagepipeline/core/resume.py b/imagepipeline/core/resume.py index 36f3187..d5b476e 100644 --- a/imagepipeline/core/resume.py +++ b/imagepipeline/core/resume.py @@ -17,10 +17,11 @@ def expected_output_filenames( input_paths: list[Path], params: dict, ) -> list[str]: - if step.module_name == "composite": - output_ext = params.get("output_ext", ".png") - return [f"{group[-1].stem}{output_ext}" for group in matched_groups] - return [path.name for path in input_paths] + return step.module.expected_output_filenames( + matched_groups=matched_groups, + input_paths=input_paths, + params=params, + ) def expected_output_paths( diff --git a/imagepipeline/modules/ai_base.py b/imagepipeline/modules/ai_base.py index b60b721..885027f 100644 --- a/imagepipeline/modules/ai_base.py +++ b/imagepipeline/modules/ai_base.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shutil import time from collections.abc import Callable from pathlib import Path @@ -104,7 +105,7 @@ class AIModule(BaseModule): if (orig_w, orig_h) != self._image_size(work_out): resize_to_size(work_out, dst, orig_w, orig_h) else: - work_out.replace(dst) + shutil.copy2(work_out, dst) else: processor(src, dst, index, total) diff --git a/imagepipeline/modules/base.py b/imagepipeline/modules/base.py index cfdfe9b..c4527e8 100644 --- a/imagepipeline/modules/base.py +++ b/imagepipeline/modules/base.py @@ -39,6 +39,16 @@ class BaseModule(ABC): def run(self, ctx: ModuleContext) -> None: """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: ctx.log_image(self.name, index, total, path) diff --git a/imagepipeline/modules/color_to_alpha.py b/imagepipeline/modules/color_to_alpha.py index d037d2e..e696a43 100644 --- a/imagepipeline/modules/color_to_alpha.py +++ b/imagepipeline/modules/color_to_alpha.py @@ -29,6 +29,16 @@ class ColorToAlphaModule(SubprocessModule): ) 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 { diff --git a/imagepipeline/modules/composite.py b/imagepipeline/modules/composite.py index 74efd79..9a72b67 100644 --- a/imagepipeline/modules/composite.py +++ b/imagepipeline/modules/composite.py @@ -1,5 +1,7 @@ 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 @@ -16,6 +18,17 @@ class CompositeModule(SubprocessModule): ) 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 def parameters(cls) -> dict[str, Param]: return { diff --git a/imagepipeline/modules/gmic.py b/imagepipeline/modules/gmic.py index e32b973..945aa37 100644 --- a/imagepipeline/modules/gmic.py +++ b/imagepipeline/modules/gmic.py @@ -4,7 +4,7 @@ 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.gmic import split_gmic_command +from imagepipeline.utils.gmic import finalize_gmic_output, split_gmic_command from imagepipeline.utils.subprocess import run_command @@ -35,3 +35,4 @@ class GmicModule(SubprocessModule): dst = ctx.output_dir / src.name cmd = ["gmic", str(src), *split_gmic_command(gmic_command), "-output", str(dst)] run_command(cmd, timeout=self.default_timeout) + finalize_gmic_output(ctx.output_dir, dst) diff --git a/imagepipeline/modules/openrouter_edit.py b/imagepipeline/modules/openrouter_edit.py index 5196286..22c9046 100644 --- a/imagepipeline/modules/openrouter_edit.py +++ b/imagepipeline/modules/openrouter_edit.py @@ -8,7 +8,8 @@ import urllib.error import urllib.request 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.exceptions import DependencyError from imagepipeline.core.params import Param @@ -16,6 +17,12 @@ from imagepipeline.modules.ai_base import AIModule from imagepipeline.modules.registry import register 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 @@ -43,6 +50,14 @@ class OpenRouterEditModule(AIModule): default=0.3, 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( "string", default="OPENROUTER_API_KEY", @@ -68,46 +83,133 @@ class OpenRouterEditModule(AIModule): prompt = ctx.params["prompt"] model = ctx.params["model"] 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: - image = load_pil_rgb(src) - megapixels = (image.size[0] * image.size[1]) / 1_000_000 + with Image.open(src) as image: + megapixels = (image.size[0] * image.size[1]) / 1_000_000 if ctx.logger is not None: ctx.logger.info( f" OpenRouter request [{index}/{total}]: model={model!r}, " 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) result_bytes = self._extract_image_bytes(response) 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) - @staticmethod - def _build_payload(image, prompt: str, model: str, strength: float) -> dict: - buffer = io.BytesIO() - image.save(buffer, format="JPEG", quality=92) - encoded = base64.b64encode(buffer.getvalue()).decode("ascii") - data_url = f"data:image/jpeg;base64,{encoded}" - payload = { + @classmethod + def _modalities_for_model(cls, model: str) -> list[str]: + if "gemini" in model.lower(): + return ["image", "text"] + return ["image"] + + @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, - "modalities": ["image"], - "messages": [ - { - "role": "user", - "content": [ - {"type": "text", "text": prompt}, - {"type": "image_url", "image_url": {"url": data_url}}, - ], - } - ], + "modalities": cls._modalities_for_model(model), + "messages": [{"role": "user", "content": content}], } - if strength is not None: + if strength is not None and cls._strength_supported(model): payload["image_config"] = {"strength": strength} 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 def _post(api_key: str, payload: dict) -> dict: body = json.dumps(payload).encode("utf-8") diff --git a/imagepipeline/modules/rembg.py b/imagepipeline/modules/rembg.py index 1e543d4..ea77a83 100644 --- a/imagepipeline/modules/rembg.py +++ b/imagepipeline/modules/rembg.py @@ -1,5 +1,7 @@ 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 @@ -15,6 +17,16 @@ class RembgModule(SubprocessModule): default_timeout = 600.0 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 def parameters(cls) -> dict[str, Param]: return { diff --git a/imagepipeline/utils/gmic.py b/imagepipeline/utils/gmic.py index 7dd6ed0..9e7946f 100644 --- a/imagepipeline/utils/gmic.py +++ b/imagepipeline/utils/gmic.py @@ -1,6 +1,7 @@ from __future__ import annotations import shlex +from pathlib import Path def split_gmic_command(command: str) -> list[str]: @@ -9,3 +10,34 @@ def split_gmic_command(command: str) -> list[str]: 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}" + ) diff --git a/pipelines/pipeline_crusaders.py b/pipelines/pipeline_crusaders.py new file mode 100644 index 0000000..cd99773 --- /dev/null +++ b/pipelines/pipeline_crusaders.py @@ -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() diff --git a/pipelines/pipeline_orange.py b/pipelines/pipeline_orange.py new file mode 100644 index 0000000..44687c7 --- /dev/null +++ b/pipelines/pipeline_orange.py @@ -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() diff --git a/pipelines/pipeline_team_gallery_match.py b/pipelines/pipeline_team_gallery_match.py new file mode 100644 index 0000000..5a9ce81 --- /dev/null +++ b/pipelines/pipeline_team_gallery_match.py @@ -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() diff --git a/tests/test_ai_modules.py b/tests/test_ai_modules.py index da1c9ec..4c3d522 100644 --- a/tests/test_ai_modules.py +++ b/tests/test_ai_modules.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import shutil from pathlib import Path from unittest.mock import MagicMock, patch @@ -73,6 +74,92 @@ class TestAIParameters: assert params["model"] == "black-forest-labs/flux.2-klein-4b" assert params["strength"] == 0.3 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: with pytest.raises(ValueError, match="required"): diff --git a/tests/test_resume.py b/tests/test_resume.py index 11077bb..74ea0c4 100644 --- a/tests/test_resume.py +++ b/tests/test_resume.py @@ -6,11 +6,16 @@ from pathlib import Path import pytest from imagepipeline.core.pipeline import Pipeline -from imagepipeline.core.resume import materialize_external_outputs, step_outputs_complete +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.utils.gmic import split_gmic_command +from imagepipeline.modules.rembg import RembgModule +from imagepipeline.utils.gmic import finalize_gmic_output, split_gmic_command from tests.conftest import make_png @@ -26,6 +31,97 @@ class TestGmicCommandSplit: 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: