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: