Path(".._evil").stem returns "." and fell back to the default "photo".
Strip leading/trailing dots before Path.stem so "../evil" becomes "evil".
live.f12.rocks
Event photo pep pipeline: the organizer shares a photo into the Android
app, 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. Finished jobs
land on the phone (gallery + local notification) and on the public page.
No web login. Upload uses a shared token in the organizer app. This is an event tool, not a DAM.
Services (compose.yml)
| Service | What | Port |
|---|---|---|
worker |
Watches incoming/, runs the compose pipeline |
none published |
web |
FastAPI HTML + JSON API, remix | via Traefik only |
Both share one host directory (DATA_HOST_DIR) mounted at /data.
The organizer app lives in android/. Syncthing can still write
incoming/ as a fallback — it is not in this compose.
Data layout
Prod path: /home/frank/sync.schwenk.online/data/livef12
incoming/ # HTTP upload (and optional Syncthing drop)
variants/
{stem}_original.jpg # preprocessed private copy
{stem}_rembg.png
{stem}_v1.png # final composed images
intermediates/
{stem}_v1_bg_filtered.png
{stem}_v1_post_0.png # …
meta/
{stem}.json # status + stage + manifest
processed.json # worker bookkeeping
assets/ (filter lists + trimmed filters.json) lives in the repo and is
bind-mounted read-only into worker/web at /data/assets.
API
| Method | Path | Auth |
|---|---|---|
GET /api/health |
{ok, upload} |
no |
GET /api/jobs |
job list | no |
GET /api/jobs/{id} |
status, variants, intermediates | no |
POST /api/jobs |
multipart file |
Authorization: Bearer <UPLOAD_TOKEN> |
Empty UPLOAD_TOKEN disables uploads (fail closed). File download stays
on GET /jobs/{id}/files/{name}.
Running it
cp .env.example .env
# set UPLOAD_TOKEN (python3 -c "import secrets; print(secrets.token_urlsafe(32))")
# local: set DATA_HOST_DIR=./data
docker compose up -d --build
Share a photo into the Android app, or drop one into incoming/ locally.
After a few seconds it shows up on the web UI.
Android build: android/README.md.
Development
Python 3.11 (same as Dockerfile). On a host with newer Python, use
Docker or a 3.11 venv — pinned onnxruntime does not publish wheels for 3.14+.
python3.11 -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
ruff check app/ tests/ # lint
ruff format app/ tests/ # format
pytest -q # unit tests (no gmic/rembg)
CI (.gitea/workflows/ci.yml): ruff + pytest on push/PR.
Architecture notes: ARCHITECTURE.md.
Configuration (.env, see .env.example)
| Var | Default | Notes |
|---|---|---|
DATA_HOST_DIR |
/home/frank/sync.schwenk.online/data/livef12 |
data root; use ./data locally |
UPLOAD_TOKEN |
empty | required for POST /api/jobs; empty disables upload |
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 |
MAX_EDGE_PX |
2000 |
Longest edge after ImageMagick preprocess |
PREPROCESS_TIMEOUT |
120 |
Seconds for preprocess step |
NICE_LEVEL |
18 |
nice -n level for gmic/rembg subprocesses |
Deploy notes / caveats
- Firewall: Traefik must already route
live.f12.rocks— this repo only adds the router labels; it assumes the externaltraefikdocker network exists. - rembg model download: first background-removal call downloads the
u2netONNX model (~176 MB) from GitHub. Cached inrembg_cache. - Incoming is append-only: the worker only ever copies out of
incoming/and never deletes or moves anything there. - Sequential processing: the worker handles one photo at a time
(
cpus: "1.0", no concurrency). - Deploy itself (
docker compose up -don the server) is on you — this repo doesn't run it automatically. RememberUPLOAD_TOKENin the server.envor the app cannot upload.