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:
@@ -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
@@ -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
|
||||||
@@ -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`**.
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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`
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""live.f12.rocks — event photo pipeline (SFTP inbox -> gmic/rembg -> web)."""
|
||||||
@@ -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
@@ -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
@@ -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}")
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 }} · {{ job.variant_count }} Varianten</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Job {{ job_id }} — {{ site_title }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p class="back-link"><a href="/">← 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 & Rembg</h2>
|
||||||
|
<div class="pair-grid">
|
||||||
|
{% if original_name %}
|
||||||
|
<figure>
|
||||||
|
<img src="/jobs/{{ job_id }}/files/{{ original_name }}" alt="Original" loading="lazy">
|
||||||
|
<figcaption>
|
||||||
|
Original ·
|
||||||
|
<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 ·
|
||||||
|
<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 %}
|
||||||
@@ -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 }}">← Zurueck zum Job</a></p>
|
||||||
|
|
||||||
|
<h1>Remix · 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
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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:
|
||||||
@@ -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
|
||||||
Executable
+44
@@ -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"
|
||||||
Reference in New Issue
Block a user