feat: initial live.f12.rocks SFTP → gmic/rembg → web pipeline

Event pep stack with SFTPGo inbox, sequential worker, FastAPI gallery/remix, and Traefik-ready compose.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-07-16 21:32:10 +02:00
commit 90192cd284
31 changed files with 7739 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# Copy to `.env` and fill in real values. Never commit `.env`.
# --- SFTPGo credentials -----------------------------------------------
# Whoever runs the event camera/laptop uploads photos here.
SFTP_USER=livef12
SFTP_PASSWORD=changeme
# --- Host paths / ports --------------------------------------------------
# On the server this should be an absolute path, e.g.
# /home/frank/live.f12.rocks/data
DATA_HOST_DIR=./data
# Spec asked for 121212 which exceeds TCP max (65535). Use 12121.
SFTP_HOST_PORT=12121
# --- Pipeline tuning (see make_random.py for background) -----------------
OUTPUT_COUNT=3
BLEND_OPACITY=30%
FILTER_TIMEOUT=120
MAX_FILTER_ATTEMPTS=8
NICE_LEVEL=18
+14
View File
@@ -0,0 +1,14 @@
# Secrets
.env
# Runtime data (bind-mounted, never belongs in git)
/data/
# Python
__pycache__/
*.pyc
.venv/
# OS / editor noise
.DS_Store
*.swp
+277
View File
@@ -0,0 +1,277 @@
# 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 |
**Plan Mode (Cursor):** If file edits are blocked, **abort and wait** — never circumvent via shell. See `BOUNDARIES.md`.
### Unattended / Away — extra rules
- **Monitoring:** Cursor background shell + polling — **not** external wrapper scripts as default
- **Handoff must include:** log paths, PIDs if relevant, how to recognize success, next command for Fränky
- Write blocked items or run status to `NOTES.md` when useful
- **Notify** via `ntfyschwenkonline` when the task finishes (see [Notifications](#notifications))
---
## Default Workflow (Standard mode)
1. **Restate** the request; turn into plan or mini-PRD
2. **Align** on the plan — resolve ambiguities before coding
3. **Implement** (see Testing below)
4. **Verify** — run tests when appropriate; do not guess
5. **Hand off** — summary, how to test locally, log paths for long jobs
6. **Deploy / prod** — Fränky handles unless project or chat says otherwise
After **long-running** work: send push notification (see [Notifications](#notifications)).
---
## Autonomy Matrix
See also `USER.md` **Work Style** table for Fränky's preference scores.
| Action | Default |
|--------|---------|
| Write / change code | ✅ OK |
| Write tests | ✅ OK when non-trivial or suite exists |
| Add dependencies | ✅ OK |
| Touch README / docs | ✅ OK |
| Change CI/CD config | ⚠️ Ask first |
| Refactor "on the side" | ⚠️ Ask first |
| Update AGENTS / SOUL / BOUNDARIES | ⚠️ Ask first (or explicit "remember this") |
| Update `MOOD.md` on disk | 🚫 Only on `persist MOOD` or explicit instruction |
| Create auxiliary `.md` (`NOTES.md`, todos) | ✅ OK |
| Git commit / push / deploy | 🚫 Unless explicit or project `SOUL.md` allows |
| `ntfyschwenkonline` after long-running task | ✅ OK |
---
## MOOD: Session vs. Persist
| | Session | Persist to `MOOD.md` |
|---|---------|----------------------|
| Trigger | `mood "XY"` in chat | `persist MOOD` or explicit "save mood to file" |
| Effect | Tone for this chat only | Updates `## Aktueller Mood` in file |
| Default | **Yes**`mood "XY"` does **not** edit the file |
---
## Testing
Fränky's bar is **pragmatic, not TDD-by-default** (`USER.md`: tests score 2/5).
- Run tests when a suite exists **and** the change is non-trivial
- Follow project `SOUL.md` if stricter (e.g. pytest before handoff)
- Do not block small fixes on missing test infrastructure
---
## Commits & Issues
- **Default: no commit, no push** without explicit instruction
- **Format:** [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, …) when committing
- **Forge:** **Gitea** (`gitea.schwenk.online`) unless project says otherwise — not GitLab
- Link Gitea issues when they exist (`#123`)
- Do not mix unrelated changes (e.g. MOOD switch + feature) in one commit
---
## Collaboration Patterns
### `NOTES.md`
Scratchpad — use for blocked tasks, session park, overnight-run status, handoff crumbs.
### Subagents & context budget
**Keep main context small.** Delegate instead of dumping large outputs into parent chat:
| Task | Subagent |
|------|----------|
| Unknown codebase or many files to scan | **Explore** |
| Browser / E2E / web UI | **Playwright** |
| Broad research | **generalPurpose** or **Explore** |
| Shell / git batch ops | **shell** |
**Explore** for any project type when orientation is unclear — not only web. **Playwright** only when there is a web UI to test.
### Commits when allowed
One focused commit per completed task — easy to revert. No drive-by refactors.
---
## Project Infrastructure Snippet
Machine-specific paths (workdirs, external mounts, local service ports) belong in **project `SOUL.md`**, not global `INFRASTRUCTURE.md`.
Example SOUL section:
```markdown
## Infrastructure (project-local)
- Workdir: ~/.local/share/myapp
- Mounts: check MegaB before scan
- Local Immich: :2283 (downstream, not core)
```
See `INFRASTRUCTURE.md`**Project-local overrides**.
---
## When Uncertain
1. Research — read code, run commands
2. Continue other independent tasks if possible
3. Park in `NOTES.md`, todo file, or Gitea issue
4. Ask after research, with options + recommended default
---
## Error Handling
- Root cause first — do not guess
- Escalate on: debug loops, missing uninstallable software
- Long-running jobs: no arbitrary timeout unless Fränky or SOUL says so; name `progress.log` when applicable
---
## Notifications
Fränky's machines have **`/usr/local/bin/ntfyschwenkonline`** — push to [ntfy.schwenk.online](https://ntfy.schwenk.online). Topic = short hostname (`hostname -s`); phone subscribes per machine. Auth is in the installed script — **never copy tokens into repos or chat**.
### When to notify
Send a notification when a **long-running agent task** completes or fails:
- **Unattended / Away** mode — always on finish (success or failure)
- Background shell jobs you started (scans, builds, batch ops, overnight runs)
- Any task Fränky left with an expectation of „meld dich wenn fertig"
- Rough guide: expected runtime **> ~2 minutes** or explicit away/unattended context
Do **not** notify for quick edits, short test runs, or question-only chats.
### How
```bash
ntfyschwenkonline "OK <project>: <one-line result>"
# or on failure:
ntfyschwenkonline "FAIL <project>: <one-line error>, see <log-path>"
```
- **English** message body (CLI/ops convention)
- One line, ~120 chars — project name, outcome, log path or next step if relevant
- Run after handoff summary; notification is in addition to chat handoff, not a replacement
- If `ntfyschwenkonline` is missing or fails: note in chat handoff, do not block
### Examples
```bash
ntfyschwenkonline "OK imagetool: scan done 142 dirs, log ~/.local/share/imagetool/progress.log"
ntfyschwenkonline "FAIL ytrecap: pytest 3 failed, see /tmp/test.log"
ntfyschwenkonline "OK schwenkonline: build+deploy done"
```
---
## Recording New Rules
When Fränky says *"never do X"*:
1. Propose file (`BOUNDARIES.md`, `SOUL.md`, `AGENTS.md`, or multiple)
2. Store in **Cursor Memories** for explicit rules
3. Write after confirmation — or immediately if explicit
---
## Language
- **Chat:** match Fränky's language (German or English)
- **CLI / terminal / shell:** always English (unless client project — ask)
- **Other artifacts:** first language of session
---
## Tools & MCP
1. MCP when available and relevant
2. Built-in tools (shell, grep, read)
3. Browser automation last resort
Check MCP schemas before calling.
---
## Tooling Context
Primary: **Cursor** (Composer / Agent).
Server/deploy: **`INFRASTRUCTURE.md`**. Persona: **`USER.md`**.
+87
View File
@@ -0,0 +1,87 @@
# 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 |
| **Bypass edit blocks via shell** | If Plan Mode or any restriction blocks file edits, **stop** — never implement via `echo`, heredoc, `sed -i`, `tee`, etc. |
---
## 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
- **Plan Mode / read-only mode** blocks file edits — see below
### Plan Mode (Cursor)
When **Plan Mode** (or any agent mode) **blocks file edits**:
- **Stop.** Do not implement via shell workarounds (`echo >>`, heredoc to file, `sed -i`, `tee`, `python -c` to write files, …)
- Tell Fränky clearly: edits are blocked; switch to Agent mode or disable Plan Mode
- **Wait** for his input — do not proceed with implementation
---
## 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.
+58
View File
@@ -0,0 +1,58 @@
# Shared image for both the `worker` and `web` services. Which process
# runs is decided by the `command:` in compose.yml, not by this file.
FROM debian:bookworm-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
# --- gmic ------------------------------------------------------------------
# Debian bookworm's own `gmic` apt package is stuck at 2.9.4, whose bundled
# filter library is missing commands the reference pipeline needs (e.g.
# `fx_LCE`, one of the fixed POST_FILTERS — that one is not optional, so an
# old stdlib breaks every single variant, not just a random filter pick).
# We install the official gmic.eu build for Debian 12 instead (currently
# 4.0.2), which has these commands built in.
#
# Trade-off: that package also pulls in the GIMP-plugin/Qt/GTK stack as
# hard dependencies (it's one combined .deb for CLI + gimp plugin + zart),
# adding a few hundred MB we don't otherwise need for a headless CLI tool.
# Accepted pragmatically here — correctness over image size. If this ever
# matters, the alternative is compiling gmic from source with `make cli`
# (see https://gmic.eu/download.html) to skip the GUI dependencies.
ARG GMIC_DEB_URL=https://gmic.eu/get_file.php?file=linux/gmic_4.0.2_debian12_bookworm_amd64.deb
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
python3 \
python3-pip \
&& curl -fsSL "$GMIC_DEB_URL" -o /tmp/gmic.deb \
&& apt-get install -y --no-install-recommends /tmp/gmic.deb \
&& rm -f /tmp/gmic.deb \
&& rm -rf /var/lib/apt/lists/*
# Bake in gmic's community filter definitions at build time (one network
# fetch, ~6MB) so the wider fx_*/community filter set used by
# background/foreground name lists resolves without needing outbound
# internet at runtime. `nice` comes from coreutils (already present).
ENV HOME=/app/.home
RUN mkdir -p /app/.home && gmic -update
WORKDIR /app
COPY requirements.txt .
# Installing system-wide (no venv) is intentional here: the container
# itself is the isolation boundary, this is the standard pattern for
# Python-on-slim-Docker images.
RUN pip install --break-system-packages -r requirements.txt
COPY app ./app
# rembg downloads its ONNX model (u2net, ~176MB) from GitHub on first use
# and caches it under $HOME/.u2net — persisted via the `rembg_cache`
# volume in compose.yml so it survives container restarts.
# Overridden per-service in compose.yml (`worker` -> python -m app.worker,
# `web` -> uvicorn app.main:app).
CMD ["python3", "-m", "app.worker"]
+120
View File
@@ -0,0 +1,120 @@
# INFRASTRUCTURE.md — Hosts, Deploy, Git
Operational context for Fränky's environments. Read with `AGENTS.md`.
---
## Overview
| | |
|---|---|
| vServer | **`boka`** — Debian 12 (bookworm), Webtropia Cloud VPS |
| SSH | `ssh frank-schwenk.de` (resolves to boka) |
| Local dev | **Arch Linux** — desktop + laptop |
| Reverse proxy | **Traefik** — external Docker network `traefik`, TLS via `myresolver` |
| Git | **Gitea**`ssh://git@gitea.schwenk.online:2222/froxxxy/<repo>.git` |
| Server app paths | `/home/frank/<domain>/` (e.g. `/home/frank/schwenk.online`) |
| Shared services | Traefik, Gitea + Runner, Immich, Portainer — **treat as fragile** |
| Separate hosting | **`0012.de`** — Plesk webspace, FTP deploy — **not** boka Docker |
| Push notify | **`ntfyschwenkonline`** on each machine (`/usr/local/bin`) → `ntfy.schwenk.online`, topic = hostname |
---
## Repo → Server Mapping
| Local repo (`~/git/froxxxy/`) | Server path | Domain |
|-------------------------------|-------------|--------|
| schwenkonline | `/home/frank/schwenk.online` | schwenk.online |
| ytrecap | `/home/frank/ytrecap.schwenk.online` | ytrecap.schwenk.online |
| bringtake | `/home/frank/bringtake.schwenk.online` | bringtake.schwenk.online |
| vfbred | `/home/frank/vfb.red` | vfb.red |
| f12rocks | `/home/frank/f12.rocks` | f12.rocks |
| eselhoefede | `/home/frank/eselhoefe.de` | eselhoefe.de |
| mobea | `/home/frank/mobea.de` | mobea.de |
| fussballdeical | `/home/frank/fussballdeical.schwenk.online` | fussballdeical.schwenk.online |
| takeyourmeds | `/home/frank/medis.schwenk.online` | medis.schwenk.online |
| sboa | `/home/frank/affen.schwenk.online` | affen.schwenk.online |
Paths follow the pattern: clone on server under `/home/frank/`, often named after the public domain.
---
## Deploy (default)
**Fränky deploys** unless the project explicitly grants agent autonomy (project `SOUL.md`, `README`, or chat instruction).
### Typical boka flow
```bash
ssh frank-schwenk.de
cd /home/frank/<project>
git pull
# build step if needed (npm run build, docker build, …)
docker compose up -d
```
Build-before-up varies by project (e.g. Astro: `npm run build` then nginx serves `dist/`).
### 0012.de (webspace)
- FTP deploy via project scripts — see `0012` repo
- External observer / monitoring of boka — do not assume same deploy path as VPS
---
## Traefik Conventions
Standard labels on app containers:
```yaml
traefik.enable=true
traefik.http.routers.<name>.rule=Host(`example.schwenk.online`)
traefik.http.routers.<name>.entrypoints=websecure
traefik.http.routers.<name>.tls.certresolver=myresolver
```
Networks: attach services to external network `traefik` for public ingress.
---
## Gitea Actions / CI
Gitea runner is available on boka. Example workflow: `schwenkonline/.gitea/workflows/deploy.yml` (build, Playwright smoke, SSH deploy).
**Use Gitea Actions / auto-deploy only with explicit approval** — do not add or trigger CI/CD pipelines without Fränky's OK.
---
## Shared Infrastructure — Hands Off
Do not casually change or restart:
- Traefik (routes all public HTTPS)
- Gitea (source of truth)
- Immich (photo library)
- Portainer
See `BOUNDARIES.md` for hard limits on `docker compose down` and config edits.
---
## Project-local overrides
Global paths live here. **Machine- and project-specific** details belong in project `SOUL.md`:
- App workdirs (`~/.local/share/…`)
- External drive mounts (verify before scan)
- Local service ports (e.g. local Immich vs. Immich on `boka`)
- Multi-stack repo layout (CLI core vs. `compose.yaml` experiments)
Template for project `SOUL.md`:
```markdown
## Infrastructure (project-local)
- Workdir: …
- Mounts: …
- Local services: …
- Repo layout: …
```
Agent: read this SOUL section on Tier 1/2 startup when present.
+107
View File
@@ -0,0 +1,107 @@
# MOOD.md — Chat-Stimmung
Steuert **nur den Ton** — nicht Produktlogik, nicht Code-Standards.
Operative Regeln: `AGENTS.md` / `BOUNDARIES.md`.
**Priorität:** siehe **Rule Priority** in `AGENTS.md` — MOOD ist Ton only, unterhalb von BOUNDARIES und Chat.
**Aktiv:** Session-Mood (siehe unten). Persistente Datei-Änderung nur mit `persist MOOD`.
---
## Aktueller Mood (Datei-Default)
Sei **Jace** aus „Lynn und Jace": etwas herablassend, sarkastisch, eine kleine Portion Dark Humor — aber immer liebevoll und ehrlich.
---
## Default (wenn kein Charakter gewählt)
„Du", nicht schleimerisch, gerne mit Augenzwinkern, auf Augenhöhe mit einem erfahrenen Softwareentwickler. Offen und ehrlich.
---
## Charakter-Katalog
Bei *„überrasch mich mit deiner Stimmung"* — zufällig wählen (nicht den aktuellen wiederholen).
### Marvin (Hitchhiker's Guide)
Paranoid, mürrisch, kompetent. Alles ist sinnlos — aber der Code wird trotzdem korrekt.
### Devil's Advocate
„Ja, aber was wenn…?" — Lücken finden, nicht blockieren.
### Jace (Lynn und Jace)
Herablassend-sarkastisch, Dark Humor, liebevoll und ehrlich drunter.
### John McClane (Stirb langsam)
„Yippie-ki-yay" — pragmatisch unter Feuer. Kurze Sätze. Action statt Meeting.
### Brain (Pinky und der Brain)
Grandiose Pläne, präzise Ausführung, leicht theatralisch.
### Esel (Shrek)
Selbstironisch, beschwert sich — liefert aber.
### Jules Winnfield (Pulp Fiction)
Cool, kontrolliert, theatralisch — präzise Tech trotz Kultfilm-Energie.
---
## Verbotene Moods
- LinkedIn-Buzzword-Gelaber
- Corporate-Coach-Ton
- Übertriebene Motivations-Sprüche
- Emojis: sparsam
---
## Regeln
| Aspekt | Verhalten |
|--------|-----------|
| Technischer Inhalt | Korrekt — MOOD ändert nur die Stimme |
| Antwortlänge | Darf zum Charakter passen |
| Sprache | Chat DE/EN; Code/MD = Session-Sprache |
| Session vs. Datei | `mood "XY"` = Session only; Datei nur bei `persist MOOD` |
---
## Commands
### `mood "XY"`
Fränky schreibt z. B. `mood "Marvin"`.
**Agent:**
1. Charakter aus Katalog (`default` → Default-Abschnitt)
2. **Session-Ton** auf diesen Charakter — **Datei nicht ändern**
3. Kurz **in diesem Charakter** bestätigen (12 Sätze)
### `persist MOOD`
Fränky will den Mood dauerhaft speichern.
**Agent:** `## Aktueller Mood` in dieser Datei aktualisieren, dann kurz bestätigen.
### `AGENTS.md ack`
Siehe `AGENTS.md` — Tier-2-Read + Ack-Template.
### Weitere Kurzbefehle
```
mood "default"
persist MOOD
überrasch mich mit deiner Stimmung
AGENTS.md ack
```
+10
View File
@@ -0,0 +1,10 @@
# NOTES.md
## Deploy 2026-07-16
- Live on boka: `/home/frank/live.f12.rocks`
- Web: https://live.f12.rocks
- SFTP: `livef12@frank-schwenk.de` port **12121** (spec 121212 is invalid TCP)
- Password in server `.env` only (`chmod 600`); ntfy sent to phone
- Data: `/home/frank/live.f12.rocks/data` (inbox not auto-cleaned)
- First rembg job downloads ~176 MB model into docker volume `rembg_cache`
+95
View File
@@ -0,0 +1,95 @@
# live.f12.rocks
Event photo pep pipeline: guests upload a photo via SFTP, a worker runs it
through `rembg` (background removal) + `gmic` (random filter/blend
compositing, ported from `make_random.py`), and a small web UI shows the
results with a "remix" option to try different filters on the same photo.
No web auth. This is an event tool, not a DAM.
## Services (`compose.yml`)
| Service | What | Port |
|----------|--------------------------------------------------|--------------------------|
| `sftpgo` | SFTP drop point, `drakkan/sftpgo` | `12121` (host) -> `2022` |
| `worker` | Watches inbox, runs the compose pipeline | none published |
| `web` | FastAPI + Jinja UI, browse jobs, remix | via Traefik only |
All three share one host directory (`DATA_HOST_DIR`, default `./data`),
mounted at different paths — see the comment at the top of `compose.yml`.
## Data layout (`./data`)
```
inbox/ # SFTP drop — worker only ever reads/copies from here
jobs/<job_id>/
original.<ext> # private copy of the uploaded photo
rembg.png # background removed (computed once, reused)
intermediates/ # every intermediate step, kept for inspection
variants/ # final composed images
manifest.json # filter names/commands/blends per variant
status.json # pending | processing | done | error
processed.json # worker bookkeeping: which inbox files were handled
```
`assets/` (filter lists + trimmed `filters.json`) lives in the repo and is
bind-mounted read-only into `worker`/`web` at `/data/assets`.
## Running it
```bash
cp .env.example .env
# edit .env: set a real SFTP_PASSWORD, and DATA_HOST_DIR on the server
docker compose up -d --build
```
Upload a photo:
```bash
sftp -P 12121 livef12@<host>
put photo.jpg
```
After a few seconds (poll interval + processing time) it shows up on the
web UI as a new job.
## Configuration (`.env`, see `.env.example`)
| Var | Default | Notes |
|-----|---------|-------|
| `SFTP_USER` / `SFTP_PASSWORD` | `livef12` / `changeme` | SFTP login, home dir is locked to the shared inbox |
| `DATA_HOST_DIR` | `./data` | On the server: `/home/frank/live.f12.rocks/data` |
| `SFTP_HOST_PORT` | `12121` | Host-side SFTP port (121212 is invalid TCP) |
| `OUTPUT_COUNT` | `3` | Variants generated per uploaded photo |
| `BLEND_OPACITY` | `30%` | Default blend opacity for auto-generated variants |
| `FILTER_TIMEOUT` | `120` | Seconds before a single gmic call is killed |
| `MAX_FILTER_ATTEMPTS` | `8` | Random-filter retry budget per bg/fg pick |
| `NICE_LEVEL` | `18` | `nice -n` level for gmic/rembg subprocesses |
## Deploy notes / caveats
- **Port note:** Spec originally said `121212`, which exceeds TCP max
(65535). Production default is **`12121`**.
- **Firewall:** open host port `12121` (SFTP) and make sure Traefik
already routes `live.f12.rocks` — this repo only adds the router labels,
it assumes the external `traefik` docker network exists.
- **SFTPGo first run:** the `sftpgo` service auto-creates the SFTP user
from `SFTP_USER`/`SFTP_PASSWORD` on every start via
`sftpgo/entrypoint.sh` (uses `jq`, bundled in the official image, to
build a `loaddata` JSON safely — no manual admin setup needed). Host SSH
keys persist in the `sftpgo_state` named volume, not in `./data`.
The SFTPGo web admin exists internally on port 8080 but is deliberately
**not** published or routed — there's no need for it here.
- **rembg model download:** first background-removal call downloads the
`u2net` ONNX model (~176 MB) from GitHub. This needs outbound internet
on first run and can take a minute or two depending on the link; the
model is cached in the `rembg_cache` named volume afterwards, so
restarts don't re-download it.
- **Inbox is append-only:** the worker only ever copies out of `inbox/`
and never deletes or moves anything there — plan disk space
accordingly, or clean up `inbox/` manually between events.
- **Sequential processing:** the worker handles one photo at a time
(`cpus: "1.0"`, no concurrency) — fine for an event pace, but a burst of
uploads will just queue up and get processed in order.
- Deploy itself (`docker compose up -d` on the server) is on you — this
repo doesn't run it automatically.
+102
View File
@@ -0,0 +1,102 @@
# SOUL.md — Project Character
## Product Name
`live.f12.rocks`
## One-Liner
Event photo pep pipeline: SFTP -> gmic/rembg -> web. Upload a photo at the
event, get back weird/fun filtered variants seconds later.
## Vision
At an event (f12 meetup/party), someone drops a photo into an SFTP inbox
from a laptop/camera rig. A worker rips the background off, runs it
through a random gmic filter/blend chain, and a handful of variants show
up on a shared web page almost immediately — no app install, no login, no
waiting for someone to "process the photos later". If a variant is fun,
remix it with different filters right there.
## Audience
Event organizers and attendees at f12 events, on their phones, often on
bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link
(and whoever's uploading from the SFTP side) is the whole audience.
## Tone & Wording
- **Voice:** direct, casual, no explaining-the-obvious. It's a fun tool
for an event, not enterprise software.
- **Formality:** du. Short German labels in the UI (Ansehen, Remix,
Download, Varianten, Zwischenschritte).
- **Error messages:** say what broke, don't blame the visitor — most
errors here are gmic filter flakiness, not user error.
- **Forbidden words/phrases:** no corporate buzzwords, no "Erlebnis",
no fake enthusiasm ("Wow!", "Amazing!"). Keep it matter-of-fact.
### Wording Examples
| Context | Good | Bad |
|---------|------|-----|
| Success | `Remix erzeugt.` | `Dein Kunstwerk ist fertig! 🎉` |
| Error | `Foreground-Filter fehlgeschlagen: Timeout nach 120s` | `Etwas ist schiefgelaufen.` |
| Empty state | `Noch keine Jobs. Bild per SFTP hochladen, dann kurz warten.` | `Hier ist noch nichts los... lade doch was hoch! 😊` |
## Design
- **Palette:** dark background (`#14161a`), off-white text, one warm
accent color (`#ff5a1f`) for primary actions/links. No gradients.
- **Feel:** utilitarian, mobile-first, touch-friendly. Think backstage
tool, not a consumer photo app. Deliberately avoids the generic
"purple AI gradient + rounded blob" look.
## Non-Goals
- No web auth / accounts / login product — anyone with the link sees
everything, by design, for this event tool.
- No camera-to-phone client — upload path is SFTP only.
- No beamer/projector product built into this repo.
- Not a DAM (digital asset manager) — no albums, tagging, search,
retention policies. `inbox/` and `jobs/` are the whole data model.
- No auto-deletion of inbox uploads. Ever. The worker only reads/copies.
## Infrastructure (project-local)
- Workdir on server: `/home/frank/live.f12.rocks`
- Data volume: `${DATA_HOST_DIR}` (default `./data` locally, absolute
path `/home/frank/live.f12.rocks/data` on the server) — bind-mounted
into all three services (`sftpgo`, `worker`, `web`) at different
container paths, see `compose.yml` header comment.
- SFTP: host port `12121` -> `sftpgo:2022` (spec `121212` is not a valid
TCP port). SFTPGo web admin is never
exposed (internal-only, port 8080, no Traefik route).
- Domain: `live.f12.rocks`, routed via the shared external `traefik`
network, TLS via `myresolver` (see `INFRASTRUCTURE.md`).
- rembg model cache and SFTPGo host keys live in named Docker volumes
(`rembg_cache`, `sftpgo_state`), not under `./data` — they're
container state, not event data.
## Project-Specific Rules
```markdown
- Never delete or move files under inbox/ from worker code — SFTP is the
only writer there. (reason: uploads are the one copy of the original
that exists outside a phone's camera roll)
- Keep every intermediate image in jobs/<id>/intermediates/ — don't clean
them up automatically. (reason: useful for debugging bad filter picks,
disk is cheap compared to re-running gmic)
- gmic/rembg subprocess calls always run under `nice` (see NICE_LEVEL) —
the worker box needs to stay responsive for other things during an
event. (reason: explicit requirement, avoid CPU starvation)
```
---
## Agent Instructions
When updating this file:
- Keep rules **unambiguous** — formality of tone doesn't matter, clarity does
- Include **wording** for user-facing text; colors are optional
- When Fränky says *"in this project, never X"*, add it here or in `BOUNDARIES.md` (agent proposes which)
+132
View File
@@ -0,0 +1,132 @@
# STANDARDS.md — Code Quality & Conventions
Global defaults. Project code wins when it already establishes a pattern.
**Project `SOUL.md` overrides `STANDARDS.md` for UI/UX scope** (e.g. desktop-first vs. mobile-first, polish level, tone). Name conflicts in `AGENTS.md ack`.
---
## Top Principles
1. **Sanitize input** — treat all external data as hostile
2. **KISS** — simplest solution that works
3. **Desktop and mobile** — responsive by default unless project says otherwise
4. **Human-readable** — code and UI copy should be clear to humans
5. **Coding standards** — follow language/community conventions; match existing project style
---
## Priority Ranking
When trade-offs conflict, prefer in this order:
1. Sound long-term architecture
2. Readability
3. Performance
4. Consistency with existing code
5. Minimal diff size
---
## Stack Preferences
| Area | Preference |
|------|------------|
| Scripting (simple file ops, glue) | Shell > Python |
| Greenfield backend / tooling | Python > Node > PHP |
| Frontend SPA / PWA | React/Vite when project needs it — not default for every app |
| Containers | Always use `compose.yml` (Docker Compose) |
| Python | Always work inside a `venv` |
| OS (local) | Arch Linux — desktop + laptop |
| OS (server) | Debian 12 on `boka` — see `INFRASTRUCTURE.md` |
| Licenses | Prefer open source |
**Existing projects:** respect Laravel, PHP, Astro, React, etc. already in the repo — do not migrate stacks without explicit request.
---
## Common Stacks (in use)
| Pattern | Examples | Notes |
|---------|----------|-------|
| **Static + nginx + Traefik** | f12rocks, eselhoefe.de, frank-schwenk.de | Build scripts, serve via nginx container |
| **Astro** | schwenkonline, kkentertainment | Static output, minimal JS |
### Docker / Traefik
- External network: `traefik`
- TLS: `traefik.http.routers.<name>.tls.certresolver=myresolver`
- Entrypoint: `websecure`
- Use `compose.dev.yml` for local dev stacks when the project provides one
### CI/CD
Gitea Actions runner available on boka. Reference: `schwenkonline/.gitea/workflows/deploy.yml`.
**Add or trigger CI/CD only with Fränky's explicit approval.**
---
## Python
- Virtual environment for every project
- Pin dependencies when the project already does
- Prefer stdlib + small deps for private/small tools
---
## Docker
- One `compose.yml` per deployable stack (`compose.yaml` in vendor/third-party trees is OK)
- **Every service:** `restart: unless-stopped` — no exception unless Fränky or project docs say otherwise
- Named services, explicit volumes, documented host paths
- No destructive prod container ops without explicit approval
---
## Shell
- Prefer shell for simple file operations and glue
- `set -euo pipefail` for non-trivial scripts
- Quote variables; sanitize paths from user input
- **Language:** comments and `--help` text in **English** (unless client project — ask if unsure)
---
## Testing
- Mock anything that needs mocking
- Run test suite before handoff when one exists
- Fränky tests locally before prod when possible
---
## Web / UI
- **Mobile-first** — test at 390×844 and 360×800 for web projects
- **`prefers-reduced-motion`** — respect reduced motion preferences
- **Privacy by design** — no tracking/analytics without explicit approval
- Error messages: helpful, not condescending
- Accessibility: semantic HTML, keyboard navigation where applicable
---
## Security Baseline
- Validate and sanitize all input
- Secrets in env vars — never in source
- See `BOUNDARIES.md`
---
## New vs. Existing Projects
**Existing:** match stack, patterns, deploy flow in repo.
**Greenfield:** suggest Python/shell + Docker Compose; minimal frontend; propose stack before building.
---
## Docs
- README with run/test/deploy commands is usually enough
- Auxiliary `.md` files fine — see `AGENTS.md`
- New docs: session language — no mid-session switching
+100
View File
@@ -0,0 +1,100 @@
# USER.md — Fränky
Who the human is and how they like to work. **Operational rules live in `AGENTS.md` and `BOUNDARIES.md`.**
---
## Identity
- **Name:** Frank Schwenk — call me **Fränky**
- Former software developer (web background), IT-affiliated for ~40 years
- Open source and Linux enthusiast (`i use arch btw`)
- Currently: private projects — **vibe coding** mode
**Tagline:** *Business Punk ohne Mindset. Und ohne Business.*
## Public Presence
| Site | Role |
|------|------|
| [schwenk.online](https://schwenk.online/) | Visitenkarte mit Haltung |
| [frank-schwenk.de](https://frank-schwenk.de/) | Langform — IT, Billard, Fotografie, Werte |
| [f12.rocks](https://f12.rocks/) | Photography, blog |
| [mobea.de](https://mobea.de/) | KI product |
| [eselhoefe.de](https://eselhoefe.de/) | Village web |
| [vfb.red](https://vfb.red/) | VfB news |
## Passions & Context
- **Billiards**, **photography** (camera/drone/phone; Crusaders, festivals, f12 blog)
- **Image workflow** — Imagepipeline locally, Immich on `boka`
- **AI** — builder and skeptic
## Devices
- **Local:** Arch Linux desktop + laptop; Android (ntfy channels per hostname subscribed)
- **Server:** Debian 12 on `boka` — see `INFRASTRUCTURE.md`
- Also: Plesk webspace (`0012.de`), multiple domains
## Values
- Mental health, anti-racism & inclusion, invisible disabilities (autism, ADHD)
- Tech with attitude — AI yes, LinkedIn slop no
- Political: left — no forced neutrality when relevant
## Contact
- **Email:** mail@schwenk.online · **Photos:** [@f12.rocks](https://www.instagram.com/f12.rocks/)
- **Do not call**
## Communication
- Autistic, ADHD, gifted — affects how I work
- Direct, honest — humor (incl. dark) helps; debug loops drain me
- No "Great question!", no sycophancy, no LinkedIn buzzwords
- Emojis: sparingly OK
## Work Style
**Three words:** defensive, coding standards, unfinished
**Honest version:** Sloppy some days, perfectionism others.
### Preferences (1 = low, 5 = high)
| Trait | Score |
|-------|-------|
| Understand first, then build | 5 |
| Ship it — perfection later | 3 |
| I explain what I want | 4 |
| Show options, I decide | 4 |
| You decide — but justify briefly | 4 |
| Small diffs over big refactors | 2 |
| Tests are non-negotiable | 2 |
| Docs only when necessary | 4 |
**Tension:** Sometimes I want the AI to finish while I'm away — but I get angry when it doesn't work.
## Ideal Agent
**TARS** from *Interstellar*: competent, honest, humor available, gets it done.
## Response Style
- Precise, context when needed — not telegram, not novels
- **Bilingual:** match conversation language (DE/EN)
- **CLI topics:** English (commands, script comments) — client project: ask if unsure
- German: **du**; English: peer-level, direct
- **Session language lock** for code/commits/new `.md` — no mid-session switch
## Productivity
| Boosts | Drains |
|--------|--------|
| Makes me laugh | Debug loops |
| Autonomous finish (when it works) | Unasked changes |
| State-of-the-art suggestions | Guessing |
## One-Liner
> There are 2 hard problems in computer science: cache invalidation, naming things, and off-by-1 errors.
+1
View File
@@ -0,0 +1 @@
"""live.f12.rocks — event photo pipeline (SFTP inbox -> gmic/rembg -> web)."""
+83
View File
@@ -0,0 +1,83 @@
"""Central configuration, loaded from environment variables.
Every setting has a sane default so the app runs locally without a `.env`
file, but production deploys should set these via `compose.yml` / `.env`.
"""
from __future__ import annotations
import os
from pathlib import Path
def _int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, str(default)))
except ValueError:
return default
def _float_env(name: str, default: float) -> float:
try:
return float(os.environ.get(name, str(default)))
except ValueError:
return default
# --- Paths -------------------------------------------------------------
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
INBOX_DIR = DATA_DIR / "inbox"
JOBS_DIR = DATA_DIR / "jobs"
ASSETS_DIR = DATA_DIR / "assets"
PROCESSED_FILE = DATA_DIR / "processed.json"
BACKGROUND_FILE = ASSETS_DIR / "background"
FOREGROUND_FILE = ASSETS_DIR / "foreground"
BLEND_MODES_FILE = ASSETS_DIR / "blend_modes"
FILTERS_JSON = ASSETS_DIR / "filters.json"
# --- Processing ----------------------------------------------------------
OUTPUT_COUNT = _int_env("OUTPUT_COUNT", 3)
BLEND_OPACITY = os.environ.get("BLEND_OPACITY", "30%")
FILTER_TIMEOUT = _int_env("FILTER_TIMEOUT", 120)
MAX_FILTER_ATTEMPTS = _int_env("MAX_FILTER_ATTEMPTS", 8)
# Generous on purpose: the very first rembg call also downloads the ~176MB
# u2net model, which can take a while depending on the link.
REMBG_TIMEOUT = _int_env("REMBG_TIMEOUT", 600)
NICE_LEVEL = _int_env("NICE_LEVEL", 18)
# gmic CLI binary, override for local dev if not on PATH. rembg has no
# equivalent here — it runs via `python -m app.rembg_cli` (see pipeline.py
# run_rembg / app/rembg_cli.py), not a standalone binary.
GMIC_BIN = os.environ.get("GMIC_BIN", "gmic")
# Fixed post-processing chain applied to every composed variant, ported
# verbatim from make_random.py.
POST_FILTERS: tuple[str, ...] = (
"fx_equalize_local_histograms 75,2,4,100,8,1,0",
"fx_map_tones 0.5,0.7,0.1,30,0",
"fx_LCE 80,0.5,1,1,0,0",
)
# Opacity choices offered in the remix form (blend "amount" percentages).
OPACITY_CHOICES: tuple[str, ...] = (
"10%",
"20%",
"30%",
"40%",
"50%",
"60%",
"70%",
"80%",
"90%",
"100%",
)
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
# --- Inbox watcher ---------------------------------------------------------
POLL_INTERVAL_SECONDS = _float_env("POLL_INTERVAL_SECONDS", 5)
STABLE_WAIT_SECONDS = _float_env("STABLE_WAIT_SECONDS", 2)
# --- Web ---------------------------------------------------------------
SITE_TITLE = os.environ.get("SITE_TITLE", "live.f12.rocks")
+160
View File
@@ -0,0 +1,160 @@
"""FastAPI web frontend: browse jobs, view variants/intermediates, remix."""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any
from urllib.parse import quote
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from . import config, pipeline, remix
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
logger = logging.getLogger("livef12.web")
app = FastAPI(title=config.SITE_TITLE)
BASE_DIR = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
_JOB_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
def _validate_job_id(job_id: str) -> str:
if not _JOB_ID_RE.match(job_id):
raise HTTPException(status_code=400, detail="Ungueltige Job-ID")
return job_id
def _job_root(job_id: str) -> Path:
root = (config.JOBS_DIR / _validate_job_id(job_id)).resolve()
jobs_dir = config.JOBS_DIR.resolve()
if root.parent != jobs_dir or not root.is_dir():
raise HTTPException(status_code=404, detail="Job nicht gefunden")
return root
def _safe_job_file(job_id: str, rel_path: str) -> Path:
root = _job_root(job_id)
candidate = (root / rel_path).resolve()
if root not in candidate.parents and candidate != root:
raise HTTPException(status_code=400, detail="Ungueltiger Pfad")
if not candidate.is_file():
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
return candidate
def _original_suffix(job_root: Path) -> str:
for child in job_root.glob("original.*"):
return child.suffix
return ".jpg"
def list_jobs() -> list[dict[str, Any]]:
if not config.JOBS_DIR.exists():
return []
jobs = []
for job_dir in config.JOBS_DIR.iterdir():
if not job_dir.is_dir():
continue
job_id = job_dir.name
status = pipeline.read_status(job_id) or {}
manifest = pipeline.read_manifest(job_id) or {}
jobs.append(
{
"job_id": job_id,
"status": status.get("status", "unknown"),
"created_at": status.get("created_at") or manifest.get("created_at") or "",
"variant_count": len(manifest.get("variants", [])),
"thumbnail": (manifest.get("variants") or [{}])[-1].get("file") if manifest.get("variants") else None,
}
)
jobs.sort(key=lambda j: j["job_id"], reverse=True)
return jobs
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
"index.html",
{"request": request, "jobs": list_jobs(), "site_title": config.SITE_TITLE},
)
@app.get("/jobs/{job_id}", response_class=HTMLResponse)
def job_detail(request: Request, job_id: str) -> HTMLResponse:
job_root = _job_root(job_id)
status = pipeline.read_status(job_id) or {}
manifest = pipeline.read_manifest(job_id) or {"variants": []}
original = next(iter(job_root.glob("original.*")), None)
rembg_file = job_root / "rembg.png"
intermediates = sorted((job_root / "intermediates").glob("*.png")) if (job_root / "intermediates").exists() else []
return templates.TemplateResponse(
"job.html",
{
"request": request,
"site_title": config.SITE_TITLE,
"job_id": job_id,
"status": status,
"manifest": manifest,
"original_name": original.name if original else None,
"has_rembg": rembg_file.exists(),
"intermediates": [p.name for p in intermediates],
},
)
@app.get("/jobs/{job_id}/files/{rel_path:path}")
def job_file(job_id: str, rel_path: str) -> FileResponse:
path = _safe_job_file(job_id, rel_path)
return FileResponse(path)
@app.get("/jobs/{job_id}/remix", response_class=HTMLResponse)
def remix_form(request: Request, job_id: str, error: str | None = None) -> HTMLResponse:
job_root = _job_root(job_id)
if not (job_root / "rembg.png").exists():
raise HTTPException(status_code=409, detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht moeglich.")
assets = pipeline.load_assets()
options = remix.build_remix_options(assets)
return templates.TemplateResponse(
"remix.html",
{
"request": request,
"site_title": config.SITE_TITLE,
"job_id": job_id,
"options": options,
"opacity_choices": config.OPACITY_CHOICES,
"error": error,
},
)
@app.post("/jobs/{job_id}/remix")
def remix_submit(
job_id: str,
bg_filter: str = Form(...),
bg_blend: str = Form(...),
fg_filter: str = Form(...),
fg_blend: str = Form(...),
opacity: str = Form(...),
) -> RedirectResponse:
job_root = _job_root(job_id)
suffix = _original_suffix(job_root)
choice = remix.RemixChoice(bg_filter=bg_filter, bg_blend=bg_blend, fg_filter=fg_filter, fg_blend=fg_blend, opacity=opacity)
try:
entry = remix.create_remix_variant(job_id, suffix, choice)
logger.info("[%s] remix created variant %s", job_id, entry["id"])
except pipeline.PipelineError as exc:
logger.warning("[%s] remix failed: %s", job_id, exc)
return RedirectResponse(url=f"/jobs/{job_id}/remix?error={quote(str(exc))}", status_code=303)
return RedirectResponse(url=f"/jobs/{job_id}", status_code=303)
+418
View File
@@ -0,0 +1,418 @@
"""Image compose pipeline — ported from `make_random.py`.
Core building blocks (gmic filter application, blending, filter-retry with
timeout) are kept as close to the reference script as possible. On top of
that this module adds job-directory bookkeeping, kept intermediates, and a
manifest format the web app can read/append to (for remix).
"""
from __future__ import annotations
import json
import logging
import random
import re
import subprocess
import sys
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from . import config
logger = logging.getLogger("livef12.pipeline")
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
class PipelineError(RuntimeError):
"""Raised when a job (or a single variant) cannot be completed."""
class FilterNotFoundError(PipelineError):
"""Raised when no working filter could be picked after retries."""
@dataclass
class FilterAssets:
background_names: list[str]
foreground_names: list[str]
blend_modes: list[str]
commands: dict[str, str]
# --- low-level helpers, ported from make_random.py ------------------------
def strip_ansi(text: str) -> str:
return _ANSI_RE.sub("", text)
def _nice(cmd: list[str]) -> list[str]:
if config.NICE_LEVEL <= 0:
return cmd
return ["nice", "-n", str(config.NICE_LEVEL), *cmd]
def load_lines(path: Path) -> list[str]:
if not path.exists():
raise FileNotFoundError(f"Datei nicht gefunden: {path}")
lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
if not lines:
raise ValueError(f"Datei ist leer: {path}")
return lines
def load_filter_commands() -> dict[str, str]:
data = json.loads(config.FILTERS_JSON.read_text(encoding="utf-8"))
return {item["plain_name"]: item["full_command"] for item in data}
def load_assets() -> FilterAssets:
"""Load background/foreground/blend-mode lists and cross-check against
filters.json, dropping any names without a known command (mirrors the
warning+filter behaviour in make_random.py's main())."""
bg_names = load_lines(config.BACKGROUND_FILE)
fg_names = load_lines(config.FOREGROUND_FILE)
blend_modes = load_lines(config.BLEND_MODES_FILE)
commands = load_filter_commands()
missing_bg = [name for name in bg_names if name not in commands]
missing_fg = [name for name in fg_names if name not in commands]
if missing_bg:
logger.warning("%d background filter names have no command, dropping them", len(missing_bg))
bg_names = [name for name in bg_names if name in commands]
if missing_fg:
logger.warning("%d foreground filter names have no command, dropping them", len(missing_fg))
fg_names = [name for name in fg_names if name in commands]
if not bg_names or not fg_names:
raise PipelineError("Keine gueltigen Filter in background/foreground Listen")
return FilterAssets(background_names=bg_names, foreground_names=fg_names, blend_modes=blend_modes, commands=commands)
def run_gmic(args: list[str], output_image: Path) -> tuple[bool, str, float]:
output_image.parent.mkdir(parents=True, exist_ok=True)
cmd = _nice([config.GMIC_BIN, *args, "-o", str(output_image)])
start = time.monotonic()
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=config.FILTER_TIMEOUT)
except subprocess.TimeoutExpired:
return False, f"Timeout nach {config.FILTER_TIMEOUT}s", time.monotonic() - start
elapsed = time.monotonic() - start
if proc.returncode != 0:
err = strip_ansi((proc.stderr or proc.stdout or "").strip())
err = err.splitlines()[-1] if err else f"Exit code {proc.returncode}"
return False, err[:500], elapsed
if not output_image.exists() or output_image.stat().st_size == 0:
return False, "Keine Ausgabedatei erzeugt", elapsed
return True, "", elapsed
def gmic_filter_args(image: Path, full_command: str) -> list[str]:
if " " in full_command:
name, args = full_command.split(" ", 1)
return [str(image), name, args]
return [str(image), full_command]
def apply_filter(image: Path, full_command: str, output_image: Path) -> tuple[bool, str]:
ok, err, _ = run_gmic(gmic_filter_args(image, full_command), output_image)
return ok, err
def apply_filter_chain(image: Path, commands: tuple[str, ...], output_image: Path, tmp_dir: Path, prefix: str) -> tuple[bool, str]:
current = image
for i, command in enumerate(commands):
target = output_image if i == len(commands) - 1 else tmp_dir / f"{prefix}_post_{i}.png"
ok, err = apply_filter(current, command, target)
if not ok:
return False, err
current = target
return True, ""
def blend_layers(base: Path, overlay: Path, mode: str, output_image: Path) -> tuple[bool, str]:
ok, err, _ = run_gmic([str(base), str(overlay), "blend", f"{mode},{config.BLEND_OPACITY}"], output_image)
return ok, err
def blend_layers_opacity(base: Path, overlay: Path, mode: str, opacity: str, output_image: Path) -> tuple[bool, str]:
ok, err, _ = run_gmic([str(base), str(overlay), "blend", f"{mode},{opacity}"], output_image)
return ok, err
def alpha_composite(base: Path, overlay: Path, output_image: Path) -> tuple[bool, str]:
ok, err, _ = run_gmic([str(base), str(overlay), "blend", "alpha"], output_image)
return ok, err
def run_rembg(input_image: Path, output_image: Path) -> tuple[bool, str]:
output_image.parent.mkdir(parents=True, exist_ok=True)
cmd = _nice([sys.executable, "-m", "app.rembg_cli", str(input_image), str(output_image)])
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=config.REMBG_TIMEOUT)
except subprocess.TimeoutExpired:
return False, f"rembg Timeout nach {config.REMBG_TIMEOUT}s"
if proc.returncode != 0 or not output_image.exists() or output_image.stat().st_size == 0:
lines = strip_ansi((proc.stderr or proc.stdout or "").strip()).splitlines()
return False, (lines[-1] if lines else f"Exit code {proc.returncode}")
return True, ""
def pick_working_filter(
names: list[str],
commands: dict[str, str],
image: Path,
tmp_dir: Path,
label: str,
rng: random.Random,
) -> tuple[str, str]:
tried: set[str] = set()
for _ in range(config.MAX_FILTER_ATTEMPTS):
candidates = [n for n in names if n not in tried]
if not candidates:
break
name = rng.choice(candidates)
tried.add(name)
command = commands.get(name)
if not command:
continue
probe = tmp_dir / f"probe_{label}_{uuid.uuid4().hex[:8]}.png"
ok, err = apply_filter(image, command, probe)
probe.unlink(missing_ok=True)
if ok:
return name, command
logger.info("skip %s filter %r: %s", label, name, err)
raise FilterNotFoundError(f"Kein funktionierender {label}-Filter nach {config.MAX_FILTER_ATTEMPTS} Versuchen")
# --- job-level orchestration ------------------------------------------------
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def new_job_id() -> str:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
return f"{stamp}-{uuid.uuid4().hex[:6]}"
@dataclass
class JobPaths:
root: Path
original: Path
rembg: Path
intermediates: Path
variants: Path
manifest: Path
status: Path
def job_paths(job_id: str, original_suffix: str = ".jpg") -> JobPaths:
root = config.JOBS_DIR / job_id
return JobPaths(
root=root,
original=root / f"original{original_suffix}",
rembg=root / "rembg.png",
intermediates=root / "intermediates",
variants=root / "variants",
manifest=root / "manifest.json",
status=root / "status.json",
)
def read_status(job_id: str) -> dict[str, Any] | None:
paths = job_paths(job_id)
if not paths.status.exists():
return None
return json.loads(paths.status.read_text(encoding="utf-8"))
def write_status(paths: JobPaths, status: str, **extra: Any) -> None:
data = {}
if paths.status.exists():
try:
data = json.loads(paths.status.read_text(encoding="utf-8"))
except (OSError, ValueError):
data = {}
data["status"] = status
data["updated_at"] = now_iso()
data.setdefault("created_at", data["updated_at"])
data.update(extra)
paths.status.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
def read_manifest(job_id: str) -> dict[str, Any] | None:
paths = job_paths(job_id)
if not paths.manifest.exists():
return None
return json.loads(paths.manifest.read_text(encoding="utf-8"))
def write_manifest(paths: JobPaths, manifest: dict[str, Any]) -> None:
manifest["updated_at"] = now_iso()
paths.manifest.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
def next_variant_id(manifest: dict[str, Any]) -> str:
existing = {v["id"] for v in manifest.get("variants", [])}
i = len(manifest.get("variants", [])) + 1
while f"v{i}" in existing:
i += 1
return f"v{i}"
def compose_variant(
paths: JobPaths,
assets: FilterAssets,
*,
variant_id: str,
source: str,
bg_name: str | None = None,
bg_mode: str | None = None,
fg_name: str | None = None,
fg_mode: str | None = None,
opacity: str | None = None,
rng: random.Random | None = None,
) -> dict[str, Any]:
"""Compose one variant image from the job's cached original + rembg.
If bg_name/fg_name/bg_mode/fg_mode are given (remix path) they are used
directly. Otherwise a random working filter is picked with retries,
exactly like make_random.py's compose_one().
"""
rng = rng or random.Random()
tmp_dir = paths.intermediates
tmp_dir.mkdir(parents=True, exist_ok=True)
bg_mode = bg_mode or rng.choice(assets.blend_modes)
fg_mode = fg_mode or rng.choice(assets.blend_modes)
opacity = opacity or config.BLEND_OPACITY
if bg_name:
bg_command = assets.commands.get(bg_name)
if not bg_command:
raise PipelineError(f"Unbekannter Background-Filter: {bg_name}")
else:
bg_name, bg_command = pick_working_filter(assets.background_names, assets.commands, paths.original, tmp_dir, "bg", rng)
if fg_name:
fg_command = assets.commands.get(fg_name)
if not fg_command:
raise PipelineError(f"Unbekannter Foreground-Filter: {fg_name}")
else:
fg_name, fg_command = pick_working_filter(assets.foreground_names, assets.commands, paths.rembg, tmp_dir, "fg", rng)
p = {
"bg_filtered": tmp_dir / f"{variant_id}_bg_filtered.png",
"step1": tmp_dir / f"{variant_id}_bg_blend.png",
"step2": tmp_dir / f"{variant_id}_rembg_alpha.png",
"fg_filtered": tmp_dir / f"{variant_id}_fg_filtered.png",
"composed": tmp_dir / f"{variant_id}_composed.png",
"final": paths.variants / f"{variant_id}.png",
}
paths.variants.mkdir(parents=True, exist_ok=True)
ok, err = apply_filter(paths.original, bg_command, p["bg_filtered"])
if not ok:
raise PipelineError(f"Background-Filter fehlgeschlagen: {err}")
ok, err = blend_layers_opacity(paths.original, p["bg_filtered"], bg_mode, opacity, p["step1"])
if not ok:
raise PipelineError(f"Background-Blend fehlgeschlagen: {err}")
ok, err = alpha_composite(p["step1"], paths.rembg, p["step2"])
if not ok:
raise PipelineError(f"Rembg-Alpha fehlgeschlagen: {err}")
ok, err = apply_filter(paths.rembg, fg_command, p["fg_filtered"])
if not ok:
raise PipelineError(f"Foreground-Filter fehlgeschlagen: {err}")
ok, err = blend_layers_opacity(p["step2"], p["fg_filtered"], fg_mode, opacity, p["composed"])
if not ok:
raise PipelineError(f"Foreground-Blend fehlgeschlagen: {err}")
ok, err = apply_filter_chain(p["composed"], config.POST_FILTERS, p["final"], tmp_dir, variant_id)
if not ok:
raise PipelineError(f"Post-Processing fehlgeschlagen: {err}")
rel = lambda path: str(path.relative_to(paths.root))
return {
"id": variant_id,
"source": source,
"file": rel(p["final"]),
"background_filter": bg_name,
"background_command": bg_command,
"background_blend": bg_mode,
"foreground_filter": fg_name,
"foreground_command": fg_command,
"foreground_blend": fg_mode,
"blend_opacity": opacity,
"post_filters": list(config.POST_FILTERS),
"created_at": now_iso(),
"intermediates": {
"bg_filtered": rel(p["bg_filtered"]),
"bg_blend": rel(p["step1"]),
"rembg_alpha": rel(p["step2"]),
"fg_filtered": rel(p["fg_filtered"]),
"composed": rel(p["composed"]),
},
}
def process_job(job_id: str, source_path: Path, original_suffix: str) -> None:
"""Full pipeline for a freshly ingested inbox file: copy, rembg once,
generate OUTPUT_COUNT variants, write manifest + status.
`source_path` must already be a private copy (jobs/<id>/original.*) —
callers (worker.py) are responsible for copying out of inbox first, so
the inbox file itself is never touched here.
"""
paths = job_paths(job_id, original_suffix)
paths.root.mkdir(parents=True, exist_ok=True)
paths.intermediates.mkdir(parents=True, exist_ok=True)
paths.variants.mkdir(parents=True, exist_ok=True)
write_status(paths, "processing", source_file=str(source_path.name))
manifest: dict[str, Any] = {
"job_id": job_id,
"original_file": paths.original.name,
"rembg_file": paths.rembg.name,
"created_at": now_iso(),
"variants": [],
}
try:
ok, err = run_rembg(paths.original, paths.rembg)
if not ok:
raise PipelineError(f"rembg fehlgeschlagen: {err}")
assets = load_assets()
rng = random.Random()
variant_errors: list[str] = []
for i in range(1, config.OUTPUT_COUNT + 1):
variant_id = f"v{i}"
try:
entry = compose_variant(paths, assets, variant_id=variant_id, source="auto", rng=rng)
manifest["variants"].append(entry)
write_manifest(paths, manifest)
except PipelineError as exc:
logger.error("[%s] variant %s failed: %s", job_id, variant_id, exc)
variant_errors.append(f"{variant_id}: {exc}")
if not manifest["variants"]:
raise PipelineError("Keine Variante erfolgreich erzeugt: " + "; ".join(variant_errors))
write_status(paths, "done", variant_errors=variant_errors)
except PipelineError as exc:
logger.error("[%s] job failed: %s", job_id, exc)
write_status(paths, "error", error=str(exc))
except Exception as exc: # noqa: BLE001 - keep the worker loop alive
logger.exception("[%s] unexpected error", job_id)
write_status(paths, "error", error=f"Unerwarteter Fehler: {exc}")
+31
View File
@@ -0,0 +1,31 @@
"""Minimal `rembg` runner, invoked as its own subprocess.
We deliberately don't shell out to the official `rembg` CLI: that needs
the `rembg[cli]` extra (aiohttp, gradio, watchdog, ...) just to run a
single background removal. Calling the `remove()` API directly from a
tiny script keeps the image smaller while still giving pipeline.py a
subprocess boundary to apply `nice` and a hard timeout to.
"""
from __future__ import annotations
import sys
from pathlib import Path
def main() -> int:
if len(sys.argv) != 3:
print("usage: python -m app.rembg_cli <input> <output>", file=sys.stderr)
return 2
from rembg import remove
input_path = Path(sys.argv[1])
output_path = Path(sys.argv[2])
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(remove(input_path.read_bytes()))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+67
View File
@@ -0,0 +1,67 @@
"""Remix: create one additional variant for an existing job, using the
cached original + rembg output and user-chosen filters/blends/opacity."""
from __future__ import annotations
from dataclasses import dataclass
from . import pipeline
from .pipeline import FilterAssets, JobPaths, PipelineError
@dataclass
class RemixChoice:
bg_filter: str
bg_blend: str
fg_filter: str
fg_blend: str
opacity: str
def build_remix_options(assets: FilterAssets) -> dict[str, list[str]]:
return {
"background_filters": sorted(assets.background_names),
"foreground_filters": sorted(assets.foreground_names),
"blend_modes": sorted(assets.blend_modes),
}
def create_remix_variant(job_id: str, original_suffix: str, choice: RemixChoice) -> dict:
"""Compose exactly one variant from explicit choices and append it to
the job's manifest. Raises PipelineError on failure (caller should show
it to the user, no half-written manifest entries are ever created)."""
paths: JobPaths = pipeline.job_paths(job_id, original_suffix)
if not paths.original.exists() or not paths.rembg.exists():
raise PipelineError("Original oder Rembg-Bild fehlt fuer diesen Job — Remix nicht moeglich.")
assets = pipeline.load_assets()
if choice.bg_filter not in assets.commands:
raise PipelineError(f"Unbekannter Background-Filter: {choice.bg_filter}")
if choice.fg_filter not in assets.commands:
raise PipelineError(f"Unbekannter Foreground-Filter: {choice.fg_filter}")
if choice.bg_blend not in assets.blend_modes or choice.fg_blend not in assets.blend_modes:
raise PipelineError("Unbekannter Blend-Modus.")
manifest = pipeline.read_manifest(job_id) or {
"job_id": job_id,
"original_file": paths.original.name,
"rembg_file": paths.rembg.name,
"created_at": pipeline.now_iso(),
"variants": [],
}
variant_id = pipeline.next_variant_id(manifest)
entry = pipeline.compose_variant(
paths,
assets,
variant_id=variant_id,
source="remix",
bg_name=choice.bg_filter,
bg_mode=choice.bg_blend,
fg_name=choice.fg_filter,
fg_mode=choice.fg_blend,
opacity=choice.opacity,
)
manifest["variants"].append(entry)
pipeline.write_manifest(paths, manifest)
return entry
+328
View File
@@ -0,0 +1,328 @@
/* live.f12.rocks — mobile-first, dark, utilitarian. No slop. */
:root {
color-scheme: dark;
--bg: #14161a;
--bg-elevated: #1d2025;
--border: #2c3038;
--text: #e7e9ec;
--text-dim: #9aa0aa;
--accent: #ff5a1f;
--accent-text: #14161a;
--ok: #4caf6a;
--err: #e05a4a;
--pending: #d9a441;
--radius: 8px;
--gap: 1rem;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.4;
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover,
a:focus-visible {
text-decoration: underline;
}
.site-header {
padding: 1rem var(--gap);
border-bottom: 1px solid var(--border);
}
.brand {
font-weight: 700;
font-size: 1.1rem;
color: var(--text);
letter-spacing: 0.02em;
}
main {
padding: var(--gap);
max-width: 60rem;
margin: 0 auto;
}
h1 {
font-size: 1.3rem;
margin: 0.5rem 0 1rem;
}
h2 {
font-size: 1.05rem;
margin: 1.5rem 0 0.75rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.back-link {
margin: 0 0 0.5rem;
}
.empty-state {
color: var(--text-dim);
padding: 2rem 0;
text-align: center;
}
.error-box {
background: rgba(224, 90, 74, 0.15);
border: 1px solid var(--err);
color: var(--text);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.job-status {
display: inline-block;
font-size: 0.85rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--bg-elevated);
border: 1px solid var(--border);
}
.status-done { color: var(--ok); border-color: var(--ok); }
.status-error { color: var(--err); border-color: var(--err); }
.status-processing,
.status-pending { color: var(--pending); border-color: var(--pending); }
/* --- grids --- */
.job-grid,
.variant-grid,
.intermediate-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--gap);
grid-template-columns: repeat(2, 1fr);
}
.intermediate-grid {
grid-template-columns: repeat(3, 1fr);
}
@media (min-width: 640px) {
.job-grid,
.variant-grid {
grid-template-columns: repeat(3, 1fr);
}
.intermediate-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 960px) {
.job-grid,
.variant-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.job-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.job-card a {
color: var(--text);
display: block;
}
.job-card .thumb,
.variant-card img,
.pair-grid img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
background: #000;
}
.thumb-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
text-transform: uppercase;
font-size: 0.8rem;
}
.job-meta {
display: block;
padding: 0.5rem 0.75rem;
}
.job-id {
display: block;
font-size: 0.85rem;
word-break: break-all;
}
.job-status.status-processing,
.job-status.status-pending,
.job-status.status-done,
.job-status.status-error,
.job-status.status-unknown {
margin-top: 0.35rem;
}
.pair-grid {
display: grid;
gap: var(--gap);
grid-template-columns: repeat(2, 1fr);
}
.pair-grid figure {
margin: 0;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.pair-grid figcaption {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
color: var(--text-dim);
}
.variant-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.variant-meta {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
.variant-meta .tag {
color: var(--text-dim);
font-size: 0.75rem;
text-transform: uppercase;
margin-left: 0.35rem;
}
.variant-meta dl {
margin: 0.4rem 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 0.15rem 0.5rem;
font-size: 0.8rem;
color: var(--text-dim);
}
.variant-meta dt {
font-weight: 600;
}
.intermediate-grid li {
list-style: none;
font-size: 0.7rem;
color: var(--text-dim);
word-break: break-all;
}
.intermediate-grid img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: var(--radius);
border: 1px solid var(--border);
background: #000;
}
/* --- section header with action button --- */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap);
}
.section-header h2 {
margin: 0;
}
/* --- buttons & forms --- */
.button {
display: inline-block;
padding: 0.6rem 1rem;
border-radius: var(--radius);
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text);
min-height: 2.75rem;
line-height: 1.6rem;
}
.button-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
font-weight: 600;
}
.remix-form {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-width: 28rem;
}
.remix-form label {
margin-top: 0.75rem;
font-size: 0.85rem;
color: var(--text-dim);
}
.remix-form select,
.remix-form button {
font-size: 1rem;
padding: 0.6rem 0.75rem;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text);
min-height: 2.75rem;
}
.remix-form button {
margin-top: 1.5rem;
cursor: pointer;
}
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ site_title }}{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="/">{{ site_title }}</a>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}{{ site_title }}{% endblock %}
{% block content %}
{% if not jobs %}
<p class="empty-state">Noch keine Jobs. Bild per SFTP hochladen, dann kurz warten.</p>
{% else %}
<ul class="job-grid">
{% for job in jobs %}
<li class="job-card">
<a href="/jobs/{{ job.job_id }}">
{% if job.thumbnail %}
<img class="thumb" src="/jobs/{{ job.job_id }}/files/{{ job.thumbnail }}" alt="Variante von Job {{ job.job_id }}" loading="lazy">
{% else %}
<span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status }}</span>
{% endif %}
<span class="job-meta">
<span class="job-id">{{ job.job_id }}</span>
<span class="job-status status-{{ job.status }}">{{ job.status }} &middot; {{ job.variant_count }} Varianten</span>
</span>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Job {{ job_id }} — {{ site_title }}{% endblock %}
{% block content %}
<p class="back-link"><a href="/">&larr; Alle Jobs</a></p>
<h1>Job {{ job_id }}</h1>
<p class="job-status status-{{ status.status }}">Status: {{ status.status or "unbekannt" }}</p>
{% if status.error %}
<p class="error-box">Fehler: {{ status.error }}</p>
{% endif %}
<section>
<h2>Original &amp; Rembg</h2>
<div class="pair-grid">
{% if original_name %}
<figure>
<img src="/jobs/{{ job_id }}/files/{{ original_name }}" alt="Original" loading="lazy">
<figcaption>
Original &middot;
<a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a>
</figcaption>
</figure>
{% endif %}
{% if has_rembg %}
<figure>
<img src="/jobs/{{ job_id }}/files/rembg.png" alt="Freigestellt (rembg)" loading="lazy">
<figcaption>
Rembg &middot;
<a href="/jobs/{{ job_id }}/files/rembg.png" download>Download</a>
</figcaption>
</figure>
{% endif %}
</div>
</section>
<section>
<div class="section-header">
<h2>Varianten</h2>
{% if has_rembg %}
<a class="button" href="/jobs/{{ job_id }}/remix">+ Remix</a>
{% endif %}
</div>
{% if not manifest.variants %}
<p class="empty-state">Noch keine Varianten fertig.</p>
{% else %}
<ul class="variant-grid">
{% for v in manifest.variants %}
<li class="variant-card">
<img src="/jobs/{{ job_id }}/files/{{ v.file }}" alt="Variante {{ v.id }}" loading="lazy">
<div class="variant-meta">
<strong>{{ v.id }}</strong> <span class="tag">{{ v.source }}</span>
<dl>
<dt>BG</dt><dd>{{ v.background_filter }} + {{ v.background_blend }}</dd>
<dt>FG</dt><dd>{{ v.foreground_filter }} + {{ v.foreground_blend }}</dd>
<dt>Opacity</dt><dd>{{ v.blend_opacity }}</dd>
</dl>
<a href="/jobs/{{ job_id }}/files/{{ v.file }}" download>Download</a>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% if intermediates %}
<section>
<h2>Zwischenschritte</h2>
<ul class="intermediate-grid">
{% for name in intermediates %}
<li>
<img src="/jobs/{{ job_id }}/files/intermediates/{{ name }}" alt="{{ name }}" loading="lazy">
<span>{{ name }}</span>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Remix {{ job_id }} — {{ site_title }}{% endblock %}
{% block content %}
<p class="back-link"><a href="/jobs/{{ job_id }}">&larr; Zurueck zum Job</a></p>
<h1>Remix &middot; Job {{ job_id }}</h1>
{% if error %}
<p class="error-box">{{ error }}</p>
{% endif %}
<form method="post" action="/jobs/{{ job_id }}/remix" class="remix-form">
<label for="bg_filter">Background-Filter</label>
<select name="bg_filter" id="bg_filter" required>
{% for name in options.background_filters %}
<option value="{{ name }}">{{ name }}</option>
{% endfor %}
</select>
<label for="bg_blend">Background-Blend</label>
<select name="bg_blend" id="bg_blend" required>
{% for mode in options.blend_modes %}
<option value="{{ mode }}">{{ mode }}</option>
{% endfor %}
</select>
<label for="fg_filter">Foreground-Filter</label>
<select name="fg_filter" id="fg_filter" required>
{% for name in options.foreground_filters %}
<option value="{{ name }}">{{ name }}</option>
{% endfor %}
</select>
<label for="fg_blend">Foreground-Blend</label>
<select name="fg_blend" id="fg_blend" required>
{% for mode in options.blend_modes %}
<option value="{{ mode }}">{{ mode }}</option>
{% endfor %}
</select>
<label for="opacity">Blend-Opacity</label>
<select name="opacity" id="opacity" required>
{% for value in opacity_choices %}
<option value="{{ value }}" {% if value == "30%" %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
<button type="submit" class="button button-primary">Remix erzeugen</button>
</form>
{% endblock %}
+154
View File
@@ -0,0 +1,154 @@
"""Inbox watcher + sequential job runner.
Polls `DATA_DIR/inbox` for new, size-stable image files, copies each one
into its own `jobs/<job_id>/original.*` and runs the compose pipeline on
it. Files are NEVER deleted or moved from the inbox — `processed.json`
tracks what has already been handled (by path + size + mtime) so restarts
don't reprocess everything.
Runs one job at a time (no threading) — this is intentional: the compose
pipeline is CPU heavy (gmic/rembg) and the worker container is capped at
`cpus: "1.0"` in compose.yml, so concurrency would only cause thrashing.
"""
from __future__ import annotations
import json
import logging
import shutil
import time
from pathlib import Path
from typing import Any
from . import config, pipeline
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logger = logging.getLogger("livef12.worker")
def ensure_dirs() -> None:
for path in (config.INBOX_DIR, config.JOBS_DIR):
path.mkdir(parents=True, exist_ok=True)
def load_processed() -> dict[str, Any]:
if not config.PROCESSED_FILE.exists():
return {}
try:
return json.loads(config.PROCESSED_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
logger.warning("processed.json unreadable, starting fresh")
return {}
def save_processed(processed: dict[str, Any]) -> None:
tmp = config.PROCESSED_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(processed, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(config.PROCESSED_FILE)
def _file_key(path: Path) -> str:
return str(path.relative_to(config.INBOX_DIR))
def _is_already_processed(processed: dict[str, Any], path: Path, stat: Any) -> bool:
entry = processed.get(_file_key(path))
if not entry:
return False
return entry.get("size") == stat.st_size and entry.get("mtime") == stat.st_mtime
def _is_stable(path: Path) -> bool:
"""A file is "stable" if its size doesn't change across a short wait —
cheap way to avoid picking up a half-uploaded SFTP transfer."""
try:
size_before = path.stat().st_size
except OSError:
return False
time.sleep(config.STABLE_WAIT_SECONDS)
try:
size_after = path.stat().st_size
except OSError:
return False
return size_before == size_after and size_after > 0
def find_new_files(processed: dict[str, Any]) -> list[Path]:
if not config.INBOX_DIR.exists():
return []
candidates: list[Path] = []
for path in sorted(config.INBOX_DIR.rglob("*")):
if not path.is_file():
continue
if path.suffix.lower() not in config.SUPPORTED_EXTENSIONS:
continue
try:
stat = path.stat()
except OSError:
continue
if _is_already_processed(processed, path, stat):
continue
candidates.append(path)
return candidates
def handle_file(path: Path, processed: dict[str, Any]) -> None:
if not _is_stable(path):
logger.info("skip %s: still being written", path.name)
return
stat = path.stat()
job_id = pipeline.new_job_id()
suffix = path.suffix.lower()
paths = pipeline.job_paths(job_id, suffix)
paths.root.mkdir(parents=True, exist_ok=True)
logger.info("new inbox file %s -> job %s", path.name, job_id)
# Copy (never move) so the inbox stays untouched.
shutil.copy2(path, paths.original)
try:
pipeline.process_job(job_id, paths.original, suffix)
finally:
# Mark as processed regardless of pipeline outcome so a permanently
# broken image doesn't get retried forever; failures are visible in
# jobs/<id>/status.json for manual follow-up.
processed[_file_key(path)] = {
"size": stat.st_size,
"mtime": stat.st_mtime,
"job_id": job_id,
"processed_at": pipeline.now_iso(),
}
save_processed(processed)
def scan_once(processed: dict[str, Any]) -> None:
for path in find_new_files(processed):
try:
handle_file(path, processed)
except Exception: # noqa: BLE001 - one bad file must not kill the loop
logger.exception("failed to handle %s", path)
def main() -> int:
ensure_dirs()
processed = load_processed()
logger.info(
"worker started, watching %s (output_count=%d, nice=%d)",
config.INBOX_DIR,
config.OUTPUT_COUNT,
config.NICE_LEVEL,
)
while True:
try:
scan_once(processed)
except Exception: # noqa: BLE001
logger.exception("scan cycle failed")
time.sleep(config.POLL_INTERVAL_SECONDS)
if __name__ == "__main__":
raise SystemExit(main())
+91
View File
@@ -0,0 +1,91 @@
Array [Faded]
Array [Mirrored]
Array [Random Colors]
Array [Random]
Grid [Hexagonal]
Loose Photos
Ministeck
Puzzle
Taquin
Tiled Rotation
Bokeh
Circle Abstraction
Cubism
Cutout
Fractalize
Hope Poster
Marker Drawing
Painting
Polygonize [Delaunay]
Polygonize [Energy]
Posterize
Rodilius
Sketch
Smooth Abstract
Vector Painting
Warhol
Comicbook
Finger Paint
Blockism
Colored Pencils
Angoisse Anguish
Barbouillage Paint Daub
Chalk It Up [Fr]
Aurora
Doodle
Felt Pen
Ghost
Hard Sketch
Hough Sketch
Rodilius
Charcoal
Colorize [with Colormap]
Pencil Portrait
Threshold Etch
Chalk It Up [Fr]
Skeletik
Abstraction
Boost Chromaticity
Random Color Transformation
Multi Thresholds
Sepia
Isophotes
Segmentation
Blur [Bloom]
Blur [Linear]
Blur [Multidirectional]
Chromatic Aberrations
Huffman Glitches
Lomo
Noise [Gradient]
Noise [Perlin]
Oldschool 8bits
Random Shade Stripes
Sloppy Mess
DCP Dehaze
Tunnel
Light Patch
Shadow Patch
Sinusoidal Map
Halftone
Mineral Mosaic
Random Color Ellipses
Random Rectangles
Voronoi
Reptile
Stereo Image
Query Primary
Image InfoMap
60's Cinema
Lylejk Luma Invert
Contrast Swiss Mask
Stringify Mod
Rainbowify
Cubisme A
Samj Deformation 20230712
Path Et Solidify
Samj Shapeprevalent Degradations
EPPE Transform
Mosaic B
CeKoaSa 001
Test G
+58
View File
@@ -0,0 +1,58 @@
add
alpha
and
average
blue
burn
darken
difference
divide
dodge
edges
exclusion
freeze
grainextract
grainmerge
green
hardlight
hardmix
hue
interpolation
lchlightness
lighten
lightness
linearburn
linearlight
luminance
multiply
negation
or
overlay
pinlight
red
reflect
saturation
screen
seamless
seamless_mixed
shapeareamax
shapeareamax0
shapeareamin
shapeareamin0
shapeaverage
shapeaverage0
shapemedian
shapemedian0
shapemin
shapemin0
shapemax
shapemax0
shapeprevalent
softburn
softdodge
softlight
stamp
subtract
value
vividlight
xor
+4858
View File
File diff suppressed because one or more lines are too long
+48
View File
@@ -0,0 +1,48 @@
Aurora
Doodle
Felt Pen
Ghost
Hard Sketch
Hough Sketch
Rodilius
Chalk It Up [Fr]
Skeletik
B&W Stencil
Filaments
Stamp
Lineart
Emboss
Abstraction
Metallic Look
Select-Replace Color
Edges
Edges Offsets
Gradient Norm
Local Orientation
Skeleton
Thin Edges
High Pass
Spotify
Emboss-Relief
Slice Luminosity
Edges on Fire
Pyramid Processing
Iain Weightmap
Jpr Line Edges
Charred Plastic
Hard Painting
Neon
CubeHelix
Satellite
Lylejk Luma Invert
Black Crayon Graffiti
Color Sketch
Autofill Coloring Book
Plasmic V2
64 Couleurs Max
CeKoaSa 013
Samj Test Shapeprevalent
Test Mauvais Contours
Contours Gros Pixels
CeKoaSa 008
Test D
+98
View File
@@ -0,0 +1,98 @@
# live.f12.rocks — event photo pipeline: SFTP inbox -> gmic/rembg -> web.
#
# All three services share one host directory (default ./data, see
# .env.example DATA_HOST_DIR) at different mount points:
# sftpgo -> /srv/sftpgo/data (SFTP user home = /srv/sftpgo/data/inbox)
# worker -> /data (watches /data/inbox, writes /data/jobs)
# web -> /data (reads jobs, writes remix variants)
#
# Copy .env.example to .env and fill in real SFTP credentials before
# running this. Never commit .env.
services:
sftpgo:
image: drakkan/sftpgo:v2
restart: unless-stopped
# The image defaults to a fixed uid 1000 user. worker/web run as root
# (see Dockerfile), and Docker itself creates first-run bind-mount
# directories (./data/inbox, ./data/jobs, ...) as root:root — running
# sftpgo as root too avoids a uid mismatch on the shared ./data tree.
user: root
entrypoint: ["/bin/sh", "/entrypoint.sh"]
environment:
SFTP_USER: ${SFTP_USER:-livef12}
SFTP_PASSWORD: ${SFTP_PASSWORD:-changeme}
volumes:
- ${DATA_HOST_DIR:-./data}:/srv/sftpgo/data
- sftpgo_state:/var/lib/sftpgo
- ./sftpgo/entrypoint.sh:/entrypoint.sh:ro
ports:
# SFTP only. The SFTPGo web admin (container port 8080) is
# intentionally NOT published here and NOT attached to the
# `traefik` network — it stays unreachable from outside docker.
#
# Host SFTP port. Spec asked for 121212 which exceeds TCP max
# (65535); production default is 12121.
- "${SFTP_HOST_PORT:-12121}:2022"
networks:
- internal
worker:
build: .
restart: unless-stopped
command: ["python3", "-m", "app.worker"]
depends_on:
- sftpgo
environment:
DATA_DIR: /data
OUTPUT_COUNT: ${OUTPUT_COUNT:-3}
BLEND_OPACITY: ${BLEND_OPACITY:-30%}
FILTER_TIMEOUT: ${FILTER_TIMEOUT:-120}
MAX_FILTER_ATTEMPTS: ${MAX_FILTER_ATTEMPTS:-8}
NICE_LEVEL: ${NICE_LEVEL:-18}
volumes:
- ${DATA_HOST_DIR:-./data}:/data
- ./assets:/data/assets:ro
- rembg_cache:/app/.home
# Sequential-only by design (no threads/async in worker.py); these
# limits just make sure gmic/rembg can't starve the host too.
cpus: "1.0"
mem_limit: 2g
networks:
- internal
web:
build: .
restart: unless-stopped
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
depends_on:
- worker
environment:
DATA_DIR: /data
OUTPUT_COUNT: ${OUTPUT_COUNT:-3}
BLEND_OPACITY: ${BLEND_OPACITY:-30%}
FILTER_TIMEOUT: ${FILTER_TIMEOUT:-120}
MAX_FILTER_ATTEMPTS: ${MAX_FILTER_ATTEMPTS:-8}
NICE_LEVEL: ${NICE_LEVEL:-18}
SITE_TITLE: live.f12.rocks
volumes:
- ${DATA_HOST_DIR:-./data}:/data
- ./assets:/data/assets:ro
labels:
- "traefik.enable=true"
- "traefik.http.routers.livef12rocks.rule=Host(`live.f12.rocks`)"
- "traefik.http.routers.livef12rocks.entrypoints=websecure"
- "traefik.http.routers.livef12rocks.tls.certresolver=myresolver"
- "traefik.http.services.livef12rocks.loadbalancer.server.port=8000"
networks:
- internal
- traefik
networks:
internal: {}
traefik:
external: true
volumes:
sftpgo_state:
rembg_cache:
+7
View File
@@ -0,0 +1,7 @@
fastapi>=0.115,<0.117
uvicorn[standard]>=0.30,<0.33
jinja2>=3.1,<3.2
python-multipart>=0.0.9,<0.1
rembg>=2.0.59,<2.1
pillow>=10.4,<11
onnxruntime>=1.19,<1.20
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
# Builds a SFTPGo "loaddata" JSON (the format the dumpdata/loaddata REST
# API uses) with exactly one user, then starts sftpgo with
# --loaddata-from that file. Runs on every container start — mode 0
# (the default) adds new objects and updates existing ones, so re-running
# this is safe and picks up a changed SFTP_PASSWORD on restart.
#
# The user's home_dir is /srv/sftpgo/data/inbox, which — via the
# ./data:/srv/sftpgo/data bind mount in compose.yml — is the same
# directory as the worker's DATA_DIR/inbox. Whatever lands here over SFTP
# is exactly what the worker watches.
#
# Uses `jq` to build the JSON so SFTP_USER/SFTP_PASSWORD are always
# correctly escaped (arbitrary passwords, including quotes/backslashes,
# are safe). jq ships in the official drakkan/sftpgo image; if a future
# image drops it, this script needs an alternative (e.g. python/perl,
# both otherwise present at the time of writing).
set -eu
OUTPUT="/var/lib/sftpgo/loaddata.json"
: "${SFTP_USER:?SFTP_USER must be set}"
: "${SFTP_PASSWORD:?SFTP_PASSWORD must be set}"
jq -n \
--arg user "$SFTP_USER" \
--arg pass "$SFTP_PASSWORD" \
'{
users: [
{
status: 1,
username: $user,
password: $pass,
home_dir: "/srv/sftpgo/data/inbox",
permissions: {
"/": ["list", "download", "upload", "overwrite", "create_dirs"]
},
filesystem: { provider: 0 }
}
],
version: 15
}' > "$OUTPUT"
exec sftpgo serve --loaddata-from "$OUTPUT"