Compare commits

..

14 Commits

Author SHA1 Message Date
Frank Schwenk c03f17488c feat: organizer Android app and token-gated upload API
CI / lint-and-test (push) Failing after 1m18s
Replace Syncthing as the product upload path with a share-target app
and POST /api/jobs. Web UI live-polls job progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 21:38:50 +02:00
Frank Schwenk 5dcc3717c7 Merge branch 'fix/concurrency-and-cleanup'
CI / lint-and-test (push) Failing after 11s
Meta lock hardening plus responsive WebP srcsets for the web UI.
2026-08-01 13:04:41 +02:00
Frank Schwenk dec74a46d7 feat: serve responsive WebP srcsets for faster mobile browsing
On-demand ?w= resize with a disk cache under webcache/; downloads stay full-resolution.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:04:33 +02:00
Frank Schwenk b09f90bfcc fix: lock meta RMW and harden concurrent remix paths
CI / lint-and-test (pull_request) Failing after 9s
Prevent worker/web clobbering of meta variants via flock and merge-by-id,
make filter timeouts thread-local, harden job-id/stem sanitization, migrate
TemplateResponse API, and remove the compare-bg experiment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:44:15 +02:00
froxxxy 9a9b143f08 Merge pull request 'Cleanup/code quality' (#1) from cleanup/code-quality into main
CI / lint-and-test (push) Successful in 7s
Reviewed-on: #1
2026-07-18 17:26:22 +02:00
Frank Schwenk fd024d31e9 docs: update README and add ARCHITECTURE
CI / lint-and-test (pull_request) Successful in 2m3s
Dev setup (venv, ruff, pytest), config table entries, architecture
overview, and cleanup completion report.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:12:26 +02:00
Frank Schwenk c29186a227 test: add characterization tests for core modules
33 tests covering env/io helpers, config parsers, pipeline meta I/O,
worker inbox logic, and FastAPI smoke routes. No gmic/rembg subprocesses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:12:09 +02:00
Frank Schwenk 1cf4ea0f07 refactor: named preprocess timeout and single meta read
Add PREPROCESS_TIMEOUT config (default 120s). Expose read_meta() and use
one read per job in list_jobs() instead of read_status + read_manifest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:11:29 +02:00
Frank Schwenk 86989c1a4c chore: deduplicate compose environment blocks
Use YAML anchor x-app-env for the shared worker/web environment vars.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:11:28 +02:00
Frank Schwenk 5a3118f590 refactor: share env-bool parsing and logging setup
env_utils.env_bool replaces duplicated truthy parsing in config and
rembg_cli. logging_config.configure_logging unifies entry-point setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:11:28 +02:00
Frank Schwenk b63a796907 refactor: extract atomic JSON helpers to io_utils
Centralize read_json/write_json_atomic used by pipeline meta I/O and
worker processed.json bookkeeping. Preserves corrupt-file warning in
load_processed().

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:11:26 +02:00
Frank Schwenk 0963297815 style: apply ruff format and extend gitignore
Ruff auto-fixes import order and line wrapping. Gitignore covers
build artifacts and coverage output from future test runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:10:33 +02:00
Frank Schwenk e9f494ed9d chore: add pyproject.toml with ruff and pytest config
Pin Python 3.11 for parity with the Docker image. Dev deps live in
requirements-dev.txt for local lint/test without rembg/onnxruntime.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:10:31 +02:00
Frank Schwenk 90094cf8f8 docs: add cleanup plan from phase-0 analysis
Phase 0 inventory: ~7 Python modules, no tests/CI/lint, pipeline.py
concentration. Roadmap for incremental cleanup without behavior changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:09:53 +02:00
78 changed files with 4253 additions and 451 deletions
+8 -1
View File
@@ -2,7 +2,8 @@
# --- Host data path (Syncthing share root) ------------------------------ # --- Host data path (Syncthing share root) ------------------------------
# Prod default (compose.yml): /home/frank/sync.schwenk.online/data/livef12 # Prod default (compose.yml): /home/frank/sync.schwenk.online/data/livef12
# Layout under that path: incoming/ variants/ intermediates/ meta/ # Layout under that path: incoming/ variants/ intermediates/ meta/ webcache/
# (webcache = resized WebP for srcset; safe to wipe / add to phone .stignore)
# Local override when the absolute path does not exist: # Local override when the absolute path does not exist:
# DATA_HOST_DIR=./data # DATA_HOST_DIR=./data
# DATA_HOST_DIR=/home/frank/sync.schwenk.online/data/livef12 # DATA_HOST_DIR=/home/frank/sync.schwenk.online/data/livef12
@@ -17,7 +18,13 @@ FILTER_TIMEOUT=120
FILTER_TIMEOUT_LONG=300 FILTER_TIMEOUT_LONG=300
MAX_FILTER_ATTEMPTS=8 MAX_FILTER_ATTEMPTS=8
MAX_EDGE_PX=2000 MAX_EDGE_PX=2000
PREPROCESS_TIMEOUT=120
# birefnet-general OOMs on the ~8GB box; u2net + alpha ≈ `rembg i -a` # birefnet-general OOMs on the ~8GB box; u2net + alpha ≈ `rembg i -a`
REMBG_MODEL=u2net REMBG_MODEL=u2net
REMBG_ALPHA=1 REMBG_ALPHA=1
NICE_LEVEL=18 NICE_LEVEL=18
# --- Upload API (organizer Android app) ----------------------------------
# Shared secret for POST /api/jobs. Leave empty to disable uploads.
# Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
UPLOAD_TOKEN=
+34
View File
@@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Ruff lint
run: ruff check app/ tests/
- name: Ruff format check
run: ruff format --check app/ tests/
- name: Pytest
run: pytest -q
+11 -7
View File
@@ -4,21 +4,20 @@
# Runtime data (bind-mounted / Syncthing share — never in git) # Runtime data (bind-mounted / Syncthing share — never in git)
/data/ /data/
# Local rembg/withoutbg comparison scratch
/compare-bg/inputs/
/compare-bg/outputs/
/compare-bg/model-cache/
/compare-bg/compare.log
.venv-compare/
# Python # Python
__pycache__/ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
.venv/ .venv/
.venv-*/
*.egg-info/
dist/
build/
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
.ruff_cache/ .ruff_cache/
htmlcov/
.coverage
# OS / editor noise # OS / editor noise
.DS_Store .DS_Store
@@ -26,3 +25,8 @@ __pycache__/
*~ *~
.idea/ .idea/
.vscode/ .vscode/
# Android
android/local.properties
android/.gradle/
android/**/build/
+1
View File
@@ -0,0 +1 @@
3.11
+102
View File
@@ -0,0 +1,102 @@
# Architecture — live.f12.rocks
## Overview
```
Organizer phone Docker host
────────────── ───────────
Share / pick
→ JPEG 2000px
→ POST /api/jobs ──► incoming/ ──watch──► worker
token │
└── rembg + gmic pipeline
│
meta/{stem}.json ◄───────────────────────────────────┘
│
├── web (HTML) ──► guest browsers + remix
└── GET /api/jobs/{id} ──► app polls, local notification,
save variants + intermediates
```
Two containers share one image (`Dockerfile`) and one bind-mounted data
directory (`DATA_HOST_DIR` → `/data`). The organizer app is in `android/`.
Syncthing is an optional fallback writer for `incoming/`, not required.
## Python modules
| Module | Role |
|--------|------|
| `app/worker.py` | Poll `incoming/`, copy to `variants/`, run `process_job()` |
| `app/pipeline.py` | gmic/rembg orchestration, meta/manifest I/O, variant compose |
| `app/remix.py` | User-selected remix variant (one-shot compose + manifest append) |
| `app/main.py` | FastAPI HTML routes, Jinja templates, static files |
| `app/api.py` | JSON API: health, list, detail, authenticated upload |
| `app/jobs.py` | Shared job payloads, labels, inbox filenames |
| `app/config.py` | Environment → constants (paths, timeouts, `UPLOAD_TOKEN`) |
| `app/rembg_cli.py` | Subprocess entry for rembg (avoids heavy `rembg[cli]` extra) |
| `app/io_utils.py` | Atomic JSON read/write |
| `app/env_utils.py` | Shared bool env parsing |
| `app/logging_config.py` | Entry-point logging setup |
| `app/images.py` | On-demand WebP srcset cache |
## Data model
One **job** = one sanitized stem (unique upload name). Flat files:
- `variants/{stem}_original.jpg` — preprocessed copy
- `variants/{stem}_rembg.png` — background removal
- `variants/{stem}_vN.png` — final composed images
- `intermediates/{stem}_vN_*.png` — step images (kept for debugging)
- `meta/{stem}.json` — status + stage + progress + manifest
- `processed.json` — worker bookkeeping (incoming path → handled)
Status values: `queued` → `processing` (stages `preprocess` / `rembg` /
`variant`) → `done` | `error`.
## API
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| GET | `/api/health` | no | `{ok, upload}` |
| GET | `/api/jobs` | no | list + `active` flag |
| GET | `/api/jobs/{id}` | no | full payload + file URLs |
| POST | `/api/jobs` | Bearer / `X-Upload-Token` | multipart `file`, 202 + job summary |
| GET | `/jobs/{id}/files/{name}` | no | original file or `?w=` WebP |
Empty `UPLOAD_TOKEN` → POST returns 503. Wrong token → 401.
## Pipeline origin
The gmic filter/blend chain is ported from an external reference script
`make_random.py` (not in this repo). `config.POST_FILTERS` and asset lists
under `assets/` mirror that script's behaviour.
## Background removal
Production uses `u2net` + alpha matting via `app/rembg_cli.py`
(see `config.REMBG_MODEL`).
## Known limitations (intentional)
- **No web login** — anyone with the link sees all jobs (event tool).
- **Upload token** — organizer app only; not an account system.
- **Sequential worker** — one photo at a time; burst uploads queue.
- **Meta JSON** — worker and web share `meta/*.json` via locked
read-modify-write (`fcntl.flock`); variants are merged by id so
concurrent remix during processing does not clobber entries.
- **Incoming is append-only** — worker never deletes from `incoming/`.
## Dev / test
```bash
python3.11 -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
ruff check app/ tests/
pytest -q
```
Android: see `android/README.md`. Full stack (gmic/rembg):
`docker compose up --build` with `DATA_HOST_DIR=./data`.
Python **3.11** matches the Docker image; host 3.14+ cannot install pinned
`onnxruntime` — use Docker or a 3.11 venv for parity.
+68
View File
@@ -0,0 +1,68 @@
# Cleanup Plan — live.f12.rocks
Phase 0 analysis (2026-07-18). Working branch: `cleanup/code-quality`.
## Project snapshot
| Area | Detail |
|------|--------|
| Stack | Python 3.11 (Docker), FastAPI, gmic, rembg, Docker Compose |
| Size | ~7 Python modules under `app/`, ~1.3k LOC |
| Tests | None (pre-cleanup) |
| Lint/format | None (pre-cleanup) |
| CI | None |
| Lockfile | Soft pins in `requirements.txt` only |
## Findings (prioritized)
1. **No test/CI/lint baseline** — high risk for refactors
2. **`pipeline.py` god module** (642 LOC) — medium risk; defer split until tests exist
3. **Meta JSON race** (worker + web concurrent writes) — documented, not fixed (behavior change)
4. **Dead code**: `blend_layers()` unused
5. **Duplication**: atomic JSON write ×3, `basicConfig` ×2, bool-env parsing ×2, compose env block ×2
6. **Double meta read** on index page (`read_status` + `read_manifest`)
7. ~~`compare-bg/`~~ — removed (was a standalone experiment)
8. **`make_random.py`** — external reference only, document in ARCHITECTURE
## Risk matrix
| Area | Risk | Action |
|------|------|--------|
| Formatter/linter setup | Low | Phase 1 |
| Dead code removal | Low | Phase 1 |
| `.gitignore` / `.python-version` | Low | Phase 1 |
| Extract `io_utils`, `env_utils` | Low | Phase 2 |
| Compose env DRY (YAML anchor) | Low | Phase 2 |
| Meta read dedup in `main.py` | Low | Phase 3 |
| Magic numbers → config constants | Low | Phase 3 |
| Characterization tests | Low | Phase 5 (before deeper refactors) |
| `pipeline.py` split | Medium | **Deferred** — tests first, split later |
| Meta file locking | Medium–High | **Deferred** — behavior change |
| Dependency major bumps | Medium | List only, no auto-bump |
## Roadmap & commits
| Phase | Step | Commit theme |
|-------|------|--------------|
| 0 | This file | `docs: add cleanup plan from phase-0 analysis` |
| 1 | pyproject.toml + ruff | `chore: add pyproject.toml with ruff and pytest config` |
| 1 | ruff format | `style: apply ruff format to Python sources` |
| 1 | dead code + gitignore | `chore: remove dead code and extend gitignore` |
| 2 | io_utils extraction | `refactor: extract atomic JSON helpers to io_utils` |
| 2 | env_utils + logging | `refactor: share env-bool parsing and logging setup` |
| 2 | compose DRY | `chore: deduplicate compose environment blocks` |
| 3 | config constants | `refactor: replace magic numbers with named constants` |
| 3 | meta read dedup | `refactor: read job meta once in list_jobs` |
| 4 | dev requirements | `chore: add dev requirements and python version pin` |
| 4 | Gitea CI | `ci: add lint and test workflow` |
| 5 | tests | `test: add characterization tests for core modules` |
| 5 | docs | `docs: update README and add ARCHITECTURE` |
## Conscious non-goals
- No `pipeline.py` module split in this pass
- No meta file locking / concurrency fix
- No dependency major version bumps
- ~~No changes to `compare-bg/` experiment~~ (later removed entirely)
- No deploy CI (manual `docker compose` on boka stays)
- No behavior changes to gmic/rembg processing logic
+90
View File
@@ -0,0 +1,90 @@
# Cleanup Report — live.f12.rocks
Branch: `cleanup/code-quality` (local only, not pushed).
Completed: 2026-07-18.
## Summary
Incremental code-quality cleanup across 10 commits. **No intentional
behavior changes** to the photo pipeline, worker loop, or web UX.
## What changed
| Step | What | Why | Risk | Verification |
|------|------|-----|------|--------------|
| Phase 0 | `CLEANUP_PLAN.md` | Analysis + roadmap | Low | — |
| Tooling | `pyproject.toml`, `requirements-dev.txt`, `.python-version` | Ruff + pytest baseline, pin 3.11 | Low | `ruff check`, `pytest` |
| Style | Ruff format entire `app/` | Consistent style | Low | `ruff format --check` |
| Dead code | Removed unused `blend_layers()` | Less noise | Low | grep + pytest |
| `.gitignore` | Build/coverage/venv patterns | Keep repo clean | Low | — |
| `io_utils` | Atomic JSON helpers | DRY meta/processed I/O | Low | `test_io_utils`, pipeline/worker tests |
| `env_utils` / `logging_config` | Shared bool env + logging | DRY | Low | `test_env_utils`, pytest |
| `compose.yml` | YAML anchor for env | DRY, fewer drift bugs | Low | `docker compose config` (manual) |
| Config | `PREPROCESS_TIMEOUT` constant | Named magic number | Low | default still 120s |
| `read_meta()` | Public meta accessor; `list_jobs` reads once | Perf + clarity | Low | `test_pipeline`, `test_main` |
| Tests | 33 characterization tests | Safe refactor baseline | Low | `pytest -q` |
| CI | `.gitea/workflows/ci.yml` | Lint + test on push/PR | Low | workflow syntax |
| Docs | `README.md`, `ARCHITECTURE.md` | Onboarding | Low | — |
## What was NOT changed (and why)
| Item | Reason |
|------|--------|
| `pipeline.py` module split | Medium risk without broader integration tests; deferred |
| Dependency major bumps | Per plan — list only, no auto-bump |
| Deploy CI | Manual deploy on boka stays; STANDARDS says ask first |
## Follow-ups (later branch)
Meta locking, timeout thread-local, TemplateResponse migration, stem/validate
hardening, and removal of `compare-bg/` were done on a subsequent branch
(`fix/concurrency-and-cleanup`).
## Bugs noticed (historical — fixed later)
1. **Meta JSON race** — fixed with `fcntl.flock` + variant merge-by-id.
2. **`_filter_timeout_override` global** — fixed with `threading.local`.
3. **`sanitize_stem("a/b")` → `"b"`** — fixed (separators replaced before `Path.stem`).
4. **`_validate_job_id`** — rejection path restored for path-like raw IDs.
## Dependency notes (suggestions only)
| Package | Current pin | Note |
|---------|-------------|------|
| `fastapi` | `<0.117` | 0.117+ available; test before bump |
| `onnxruntime` | `<1.20` | Very old pin; rembg may allow newer in future |
| `pillow` | `<11` | Pillow 11 exists; verify rembg compatibility |
No lockfile added — soft pins kept to avoid changing Docker resolve behavior.
## How to verify locally
```bash
git checkout cleanup/code-quality
python3.11 -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
ruff check app/ tests/
pytest -q
# Full stack:
cp .env.example .env # set DATA_HOST_DIR=./data
docker compose up --build
```
## Commits on branch
```
docs: add cleanup plan from phase-0 analysis
chore: add pyproject.toml with ruff and pytest config
style: apply ruff format and extend gitignore
refactor: extract atomic JSON helpers to io_utils
refactor: share env-bool parsing and logging setup
chore: deduplicate compose environment blocks
refactor: named preprocess timeout and single meta read
test: add characterization tests for core modules (+ CI workflow)
docs: update README and add ARCHITECTURE
```
## External references
- `make_random.py` — external reference script, not vendored
- Submodule/symlink: none
+26
View File
@@ -16,3 +16,29 @@
- Old path `/home/frank/live.f12.rocks/data` no longer used by compose - Old path `/home/frank/live.f12.rocks/data` no longer used by compose
(migration/cleanup manual) (migration/cleanup manual)
- Host port 12121 can be closed after cutover - Host port 12121 can be closed after cutover
## Android upload (2026-08-27)
- Syncthing is fallback only. Product path: organizer Android app
(`android/`) → `POST /api/jobs` with `UPLOAD_TOKEN` → worker as before.
- App polls JSON, local notification, saves to `Pictures/livef12/{job_id}/`.
- After deploy: set `UPLOAD_TOKEN` in server `.env`, recreate `web`.
- Sideload debug APK; no Play Store / ntfy.
- Leftover branch `fix/concurrency-and-cleanup` deleted (already in main).
## Deploy 2026-07-16
- Live on boka: `/home/frank/live.f12.rocks`
- Web: https://live.f12.rocks
- Password / secrets in server `.env` only (`chmod 600`)
- First rembg job downloads ~176 MB model into docker volume `rembg_cache`
## Syncthing cutover (2026-07-18)
- SFTPGo removed; drop path is Syncthing
- Data share: `/home/frank/sync.schwenk.online/data/livef12`
(`incoming/` + `variants/` + `intermediates/` + `meta/` + `processed.json`)
- Flat phone-friendly names: `{stem}_original.jpg`, `{stem}_v1.png`, …
- Old path `/home/frank/live.f12.rocks/data` no longer used by compose
(migration/cleanup manual)
- Host port 12121 can be closed after cutover
+59 -30
View File
@@ -1,29 +1,31 @@
# live.f12.rocks # live.f12.rocks
Event photo pep pipeline: guests drop a photo into a Syncthing folder, a Event photo pep pipeline: the organizer shares a photo into the Android
worker runs it through `rembg` (background removal) + `gmic` (random app, a worker runs it through `rembg` (background removal) + `gmic`
filter/blend compositing, ported from `make_random.py`), and a small web (random filter/blend compositing, ported from `make_random.py`), and a
UI shows the results with a "remix" option to try different filters on small web UI shows the results with a "remix" option. Finished jobs
the same photo. Finished jobs sync back via the same share. land on the phone (gallery + local notification) and on the public page.
No web auth. This is an event tool, not a DAM. No web login. Upload uses a shared token in the organizer app. This is
an event tool, not a DAM.
## Services (`compose.yml`) ## Services (`compose.yml`)
| Service | What | Port | | Service | What | Port |
|----------|--------------------------------------------------|--------------------------| |----------|--------------------------------------------------|--------------------------|
| `worker` | Watches `incoming/`, runs the compose pipeline | none published | | `worker` | Watches `incoming/`, runs the compose pipeline | none published |
| `web` | FastAPI + Jinja UI, browse jobs, remix | via Traefik only | | `web` | FastAPI HTML + JSON API, remix | via Traefik only |
Both share one host directory (`DATA_HOST_DIR`) mounted at `/data`. Both share one host directory (`DATA_HOST_DIR`) mounted at `/data`.
Syncthing runs on the host — not in this compose. The organizer app lives in `android/`. Syncthing can still write
`incoming/` as a fallback — it is not in this compose.
## Data layout (Syncthing share) ## Data layout
Prod path: `/home/frank/sync.schwenk.online/data/livef12` Prod path: `/home/frank/sync.schwenk.online/data/livef12`
``` ```
incoming/ # phone drop — worker only ever reads/copies from here incoming/ # HTTP upload (and optional Syncthing drop)
variants/ variants/
{stem}_original.jpg # preprocessed private copy {stem}_original.jpg # preprocessed private copy
{stem}_rembg.png {stem}_rembg.png
@@ -32,55 +34,82 @@ intermediates/
{stem}_v1_bg_filtered.png {stem}_v1_bg_filtered.png
{stem}_v1_post_0.png # … {stem}_v1_post_0.png # …
meta/ meta/
{stem}.json # status + manifest for the web UI {stem}.json # status + stage + manifest
processed.json # worker bookkeeping: which incoming files were handled processed.json # worker bookkeeping
``` ```
Phone tip: `.stignore` can drop `meta/` and `processed.json` if you only
want images on the device.
`assets/` (filter lists + trimmed `filters.json`) lives in the repo and is `assets/` (filter lists + trimmed `filters.json`) lives in the repo and is
bind-mounted read-only into `worker`/`web` at `/data/assets`. 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 ## Running it
```bash ```bash
cp .env.example .env cp .env.example .env
# set UPLOAD_TOKEN (python3 -c "import secrets; print(secrets.token_urlsafe(32))")
# local: set DATA_HOST_DIR=./data # local: set DATA_HOST_DIR=./data
# prod: leave unset (defaults to the Syncthing share path)
docker compose up -d --build docker compose up -d --build
``` ```
Drop a photo into `incoming/` (via Syncthing or locally). After a few Share a photo into the Android app, or drop one into `incoming/` locally.
seconds (poll interval + processing time) it shows up on the web UI; flat After a few seconds it shows up on the web UI.
files land in `variants/` and `intermediates/` for the phone.
Android build: [`android/README.md`](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+.
```bash
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`) ## Configuration (`.env`, see `.env.example`)
| Var | Default | Notes | | Var | Default | Notes |
|-----|---------|-------| |-----|---------|-------|
| `DATA_HOST_DIR` | `/home/frank/sync.schwenk.online/data/livef12` | Syncthing share root; use `./data` locally | | `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 | | `OUTPUT_COUNT` | `3` | Variants generated per uploaded photo |
| `BLEND_OPACITY` | `30%` | Default blend opacity for auto-generated variants | | `BLEND_OPACITY` | `30%` | Default blend opacity for auto-generated variants |
| `FILTER_TIMEOUT` | `120` | Seconds before a single gmic call is killed | | `FILTER_TIMEOUT` | `120` | Seconds before a single gmic call is killed |
| `MAX_FILTER_ATTEMPTS` | `8` | Random-filter retry budget per bg/fg pick | | `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 | | `NICE_LEVEL` | `18` | `nice -n` level for gmic/rembg subprocesses |
## Deploy notes / caveats ## Deploy notes / caveats
- **Firewall:** Traefik must already route `live.f12.rocks` — this repo - **Firewall:** Traefik must already route `live.f12.rocks` — this repo
only adds the router labels; it assumes the external `traefik` docker only adds the router labels; it assumes the external `traefik` docker
network exists. No SFTP port needed. network exists.
- **rembg model download:** first background-removal call downloads the - **rembg model download:** first background-removal call downloads the
`u2net` ONNX model (~176 MB) from GitHub. This needs outbound internet `u2net` ONNX model (~176 MB) from GitHub. Cached in `rembg_cache`.
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.
- **Incoming is append-only:** the worker only ever copies out of - **Incoming is append-only:** the worker only ever copies out of
`incoming/` and never deletes or moves anything there — plan disk space `incoming/` and never deletes or moves anything there.
accordingly, or clean up `incoming/` manually between events.
- **Sequential processing:** the worker handles one photo at a time - **Sequential processing:** the worker handles one photo at a time
(`cpus: "1.0"`, no concurrency) — fine for an event pace, but a burst of (`cpus: "1.0"`, no concurrency).
uploads will just queue up and get processed in order.
- Deploy itself (`docker compose up -d` on the server) is on you — this - Deploy itself (`docker compose up -d` on the server) is on you — this
repo doesn't run it automatically. repo doesn't run it automatically. Remember `UPLOAD_TOKEN` in the
server `.env` or the app cannot upload.
+35 -28
View File
@@ -6,31 +6,33 @@
## One-Liner ## One-Liner
Event photo pep pipeline: Syncthing drop -> gmic/rembg -> web. Upload a Event photo pep pipeline: organizer Android app -> gmic/rembg -> web.
photo at the event, get back weird/fun filtered variants seconds later. Upload a photo at the event, get back weird/fun filtered variants seconds
later.
## Vision ## Vision
At an event (f12 meetup/party), someone drops a photo into a Syncthing At an event (f12 meetup/party), the organizer shares a photo into the
folder from a phone/camera rig. A worker rips the background off, runs it Android app. The phone converts it to a 2000px JPEG, uploads it, a worker
through a random gmic filter/blend chain, and a handful of variants show rips the background off, runs it through a random gmic filter/blend chain,
up on a shared web page almost immediately — no app install, no login, no and a handful of variants show up on a shared web page almost immediately —
waiting for someone to "process the photos later". If a variant is fun, guests need no app, no login, no "process the photos later". If a variant
remix it with different filters right there. Finished jobs sync back to is fun, remix it with different filters right there. Finished jobs land on
the phone via the same Syncthing share. the organizer's phone (gallery + local notification) and stay on the web
for everyone with the link.
## Audience ## Audience
Event organizers and attendees at f12 events, on their phones, often on Event organizers (upload via the Android app) and attendees on their
bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link phones, often on bad venue wifi. Nobody signs up, nobody logs in. Guests
(and whoever's syncing photos in) is the whole audience. with the link see everything; only the organizer's app can upload.
## Tone & Wording ## Tone & Wording
- **Voice:** direct, casual, no explaining-the-obvious. It's a fun tool - **Voice:** direct, casual, no explaining-the-obvious. It's a fun tool
for an event, not enterprise software. for an event, not enterprise software.
- **Formality:** du. Short German labels in the UI (Ansehen, Remix, - **Formality:** du. Short German labels in the UI (Ansehen, Remix,
Download, Varianten, Zwischenschritte). Download, Varianten, Zwischenschritte, Warteschlange, Fertig).
- **Error messages:** say what broke, don't blame the visitor — most - **Error messages:** say what broke, don't blame the visitor — most
errors here are gmic filter flakiness, not user error. errors here are gmic filter flakiness, not user error.
- **Forbidden words/phrases:** no corporate buzzwords, no "Erlebnis", - **Forbidden words/phrases:** no corporate buzzwords, no "Erlebnis",
@@ -42,7 +44,7 @@ bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link
|---------|------|-----| |---------|------|-----|
| Success | `Remix erzeugt.` | `Dein Kunstwerk ist fertig! 🎉` | | Success | `Remix erzeugt.` | `Dein Kunstwerk ist fertig! 🎉` |
| Error | `Foreground-Filter fehlgeschlagen: Timeout nach 120s` | `Etwas ist schiefgelaufen.` | | Error | `Foreground-Filter fehlgeschlagen: Timeout nach 120s` | `Etwas ist schiefgelaufen.` |
| Empty state | `Noch keine Jobs. Bild in den Sync-Ordner legen, dann kurz warten.` | `Hier ist noch nichts los... lade doch was hoch! 😊` | | Empty state | `Noch keine Jobs. Foto in der App teilen, dann kurz warten.` | `Hier ist noch nichts los... lade doch was hoch! 😊` |
## Design ## Design
@@ -54,46 +56,51 @@ bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link
## Non-Goals ## Non-Goals
- No web auth / accounts / login product — anyone with the link sees - No web auth / accounts / login product — anyone with the link **sees**
everything, by design, for this event tool. everything, by design. Upload is a separate shared token in the
- No camera-to-phone client in this repo — upload path is Syncthing organizer app, not a user account.
(phone/folder setup is out of band). - No Play Store / FCM / ntfy dependency. Notifications are local Android.
- No beamer/projector product built into this repo. - No beamer/projector product built into this repo.
- Not a DAM (digital asset manager) — no albums, tagging, search, - Not a DAM (digital asset manager) — no albums, tagging, search,
retention policies. `incoming/`, `variants/`, and `intermediates/` retention policies. `incoming/`, `variants/`, and `intermediates/`
are the whole data model. are the whole data model.
- No auto-deletion of incoming uploads. Ever. The worker only reads/copies. - No auto-deletion of incoming uploads. Ever. The worker only reads/copies.
- No Syncthing container in this compose — Syncthing runs on the host. - No Syncthing container in this compose. A host Syncthing share can
still write `incoming/` as a fallback; it is not the product path.
## Infrastructure (project-local) ## Infrastructure (project-local)
- Workdir on server: `/home/frank/live.f12.rocks` - Workdir on server: `/home/frank/live.f12.rocks`
- Data volume (Syncthing share): `${DATA_HOST_DIR}` — prod default - Data volume: `${DATA_HOST_DIR}` — prod default
`/home/frank/sync.schwenk.online/data/livef12`, local override `./data`. `/home/frank/sync.schwenk.online/data/livef12`, local override `./data`.
Bind-mounted into `worker` and `web` at `/data` (see `compose.yml`). Bind-mounted into `worker` and `web` at `/data` (see `compose.yml`).
- Layout under the share: - Layout:
- `incoming/` — phone drop - `incoming/` — HTTP upload (and optional Syncthing drop)
- `variants/` — `{stem}_original.jpg`, `{stem}_rembg.png`, `{stem}_vN.png` - `variants/` — `{stem}_original.jpg`, `{stem}_rembg.png`, `{stem}_vN.png`
- `intermediates/` — `{stem}_vN_*.png` (incl. `_post_0` …) - `intermediates/` — `{stem}_vN_*.png` (incl. `_post_0` …)
- `meta/` — web bookkeeping JSON (optional `.stignore` on phone) - `meta/` — status + manifest JSON
- `processed.json` - `processed.json`
- Domain: `live.f12.rocks`, routed via the shared external `traefik` - Domain: `live.f12.rocks`, routed via the shared external `traefik`
network, TLS via `myresolver` (see `INFRASTRUCTURE.md`). network, TLS via `myresolver` (see `INFRASTRUCTURE.md`).
- rembg model cache lives in the named Docker volume `rembg_cache`, not - rembg model cache lives in the named Docker volume `rembg_cache`.
under the Syncthing share — container state, not event data. - Organizer Android app lives in `android/` (sideload APK).
- `UPLOAD_TOKEN` in server `.env` — required for `POST /api/jobs`.
## Project-Specific Rules ## Project-Specific Rules
```markdown ```markdown
- Never delete or move files under incoming/ from worker code — Syncthing - Never delete or move files under incoming/ from worker code — the
is the writer there. (reason: uploads are the one copy of the original upload API (and optional Syncthing) is the writer there. (reason:
that exists outside a phone's camera roll) uploads are the one copy of the original that exists outside a
phone's camera roll)
- Keep every intermediate image under intermediates/ — don't clean them - Keep every intermediate image under intermediates/ — don't clean them
up automatically. (reason: useful for debugging bad filter picks, up automatically. (reason: useful for debugging bad filter picks,
disk is cheap compared to re-running gmic) disk is cheap compared to re-running gmic)
- gmic/rembg subprocess calls always run under `nice` (see NICE_LEVEL) — - gmic/rembg subprocess calls always run under `nice` (see NICE_LEVEL) —
the worker box needs to stay responsive for other things during an the worker box needs to stay responsive for other things during an
event. (reason: explicit requirement, avoid CPU starvation) event. (reason: explicit requirement, avoid CPU starvation)
- POST /api/jobs is fail-closed: empty UPLOAD_TOKEN disables uploads.
The public web UI stays read-only.
``` ```
--- ---
+9
View File
@@ -0,0 +1,9 @@
.gradle/
**/build/
local.properties
*.apk
*.aab
*.iml
.idea/
captures/
.cxx/
+27
View File
@@ -0,0 +1,27 @@
# Android organizer app
Share a photo into **live.f12** (or pick from the gallery). The app resizes
to JPEG, longest edge 2000px, uploads to `POST /api/jobs`, then polls until
the worker is done. Variants and intermediates land in
`Pictures/livef12/{job_id}/`. A local notification fires when the job
finishes — no ntfy, no Play Services.
## Build
JDK 17, Android SDK 35. On this machine:
```bash
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk
export ANDROID_HOME=$HOME/Android/Sdk
cd android
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
First launch: Settings → paste the `UPLOAD_TOKEN` from the server `.env`.
Default server URL is `https://live.f12.rocks`.
## Share target
Android share sheet → live.f12. That's the replacement for dropping a
file into a Syncthing folder.
+65
View File
@@ -0,0 +1,65 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "rocks.f12.live"
compileSdk = 35
defaultConfig {
applicationId = "rocks.f12.live"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("androidx.exifinterface:exifinterface:1.3.7")
implementation("androidx.core:core-ktx:1.15.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("io.coil-kt:coil-compose:2.7.0")
}
+3
View File
@@ -0,0 +1,3 @@
# Keep rules unused while minify is off; ready if we ever shrink release.
-dontwarn okhttp3.**
-dontwarn okio.**
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".LiveF12App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.LiveF12"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.LiveF12"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>
@@ -0,0 +1,11 @@
package rocks.f12.live
import android.app.Application
import rocks.f12.live.notify.AppNotifications
class LiveF12App : Application() {
override fun onCreate() {
super.onCreate()
AppNotifications.ensureChannels(this)
}
}
@@ -0,0 +1,79 @@
package rocks.f12.live
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import rocks.f12.live.ui.LiveF12Root
class MainActivity : ComponentActivity() {
private var shareUri by mutableStateOf<Uri?>(null)
private var openJobId by mutableStateOf<String?>(null)
private val pickImage =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) shareUri = uri
}
private val askNotifications =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNotificationPermission()
consumeIntent(intent)
setContent {
LiveF12Root(
shareUri = shareUri,
openJobId = openJobId,
onPickPhoto = { pickImage.launch("image/*") },
onShareConsumed = { shareUri = null },
onOpenJobConsumed = { openJobId = null },
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
consumeIntent(intent)
}
private fun consumeIntent(intent: Intent?) {
if (intent == null) return
intent.getStringExtra(EXTRA_JOB_ID)?.let { openJobId = it }
if (intent.action == Intent.ACTION_SEND) {
val uri =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
if (uri != null) shareUri = uri
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val granted =
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
}
companion object {
const val EXTRA_JOB_ID = "job_id"
}
}
@@ -0,0 +1,105 @@
package rocks.f12.live.data
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
class ApiException(val code: Int, override val message: String) : IOException(message)
class LiveF12Api(
private val client: OkHttpClient = defaultClient(),
) {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}
fun health(baseUrl: String): HealthResponse {
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/api/health").get().build()
return parse(call(request), HealthResponse.serializer())
}
fun listJobs(baseUrl: String): JobListResponse {
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/api/jobs").get().build()
return parse(call(request), JobListResponse.serializer())
}
fun job(baseUrl: String, jobId: String): JobDetail {
val request =
Request.Builder()
.url("${baseUrl.trimEnd('/')}/api/jobs/${encode(jobId)}")
.get()
.build()
return parse(call(request), JobDetail.serializer())
}
fun upload(baseUrl: String, token: String, jpeg: File, filename: String = jpeg.name): JobSummary {
val body =
MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"file",
filename,
jpeg.asRequestBody("image/jpeg".toMediaType()),
)
.build()
val request =
Request.Builder()
.url("${baseUrl.trimEnd('/')}/api/jobs")
.header("Authorization", "Bearer $token")
.post(body)
.build()
return parse(call(request), JobSummary.serializer())
}
fun download(baseUrl: String, relativeOrAbsolute: String): ByteArray {
val url = resolve(baseUrl, relativeOrAbsolute)
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw ApiException(response.code, "Download fehlgeschlagen (${response.code})")
}
return response.body?.bytes() ?: throw ApiException(response.code, "Leere Antwort")
}
}
private fun <T> parse(raw: String, serializer: kotlinx.serialization.DeserializationStrategy<T>): T =
json.decodeFromString(serializer, raw)
private fun call(request: Request): String {
client.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
if (!response.isSuccessful) {
val detail = runCatching { json.decodeFromString<ErrorBody>(body).detail }.getOrNull()
throw ApiException(response.code, detail ?: "HTTP ${response.code}")
}
return body
}
}
companion object {
fun defaultClient(): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
fun resolve(baseUrl: String, path: String): String {
if (path.startsWith("http://") || path.startsWith("https://")) return path
return baseUrl.trimEnd('/') + if (path.startsWith("/")) path else "/$path"
}
private fun encode(jobId: String): String = java.net.URLEncoder.encode(jobId, Charsets.UTF_8.name())
}
}
@kotlinx.serialization.Serializable
private data class ErrorBody(val detail: String = "")
@@ -0,0 +1,80 @@
package rocks.f12.live.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class JobListResponse(
val jobs: List<JobSummary> = emptyList(),
val active: Boolean = false,
)
@Serializable
data class JobSummary(
@SerialName("job_id") val jobId: String,
val status: String,
@SerialName("status_label") val statusLabel: String = status,
val stage: String? = null,
@SerialName("stage_label") val stageLabel: String? = null,
val progress: JobProgress? = null,
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
@SerialName("variant_count") val variantCount: Int = 0,
val thumbnail: String? = null,
@SerialName("thumbnail_url") val thumbnailUrl: String? = null,
val error: String? = null,
)
@Serializable
data class JobProgress(
val current: Int = 0,
val total: Int = 0,
)
@Serializable
data class FileRef(
val file: String,
val url: String,
)
@Serializable
data class JobDetail(
@SerialName("job_id") val jobId: String,
val status: String,
@SerialName("status_label") val statusLabel: String = status,
val stage: String? = null,
@SerialName("stage_label") val stageLabel: String? = null,
val progress: JobProgress? = null,
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
@SerialName("variant_count") val variantCount: Int = 0,
val thumbnail: String? = null,
@SerialName("thumbnail_url") val thumbnailUrl: String? = null,
val error: String? = null,
val original: FileRef? = null,
val rembg: FileRef? = null,
@SerialName("has_rembg") val hasRembg: Boolean = false,
val variants: List<VariantRef> = emptyList(),
val intermediates: List<FileRef> = emptyList(),
@SerialName("variant_errors") val variantErrors: List<String> = emptyList(),
@SerialName("source_file") val sourceFile: String? = null,
) {
val isActive: Boolean get() = status == "queued" || status == "processing" || status == "pending"
val isTerminal: Boolean get() = status == "done" || status == "error"
}
@Serializable
data class VariantRef(
val id: String? = null,
val file: String,
val url: String? = null,
val source: String? = null,
@SerialName("background_filter") val backgroundFilter: String? = null,
@SerialName("foreground_filter") val foregroundFilter: String? = null,
)
@Serializable
data class HealthResponse(
val ok: Boolean = false,
val upload: Boolean = false,
)
@@ -0,0 +1,38 @@
package rocks.f12.live.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore(name = "settings")
data class AppSettings(
val baseUrl: String,
val token: String,
)
class SettingsRepository(private val context: Context) {
val settings: Flow<AppSettings> =
context.dataStore.data.map { prefs ->
AppSettings(
baseUrl = prefs[KEY_BASE_URL]?.ifBlank { DEFAULT_BASE_URL } ?: DEFAULT_BASE_URL,
token = prefs[KEY_TOKEN].orEmpty(),
)
}
suspend fun update(baseUrl: String, token: String) {
context.dataStore.edit { prefs ->
prefs[KEY_BASE_URL] = baseUrl.trim().trimEnd('/')
prefs[KEY_TOKEN] = token.trim()
}
}
companion object {
const val DEFAULT_BASE_URL = "https://live.f12.rocks"
private val KEY_BASE_URL = stringPreferencesKey("base_url")
private val KEY_TOKEN = stringPreferencesKey("upload_token")
}
}
@@ -0,0 +1,95 @@
package rocks.f12.live.image
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import java.io.File
import java.io.FileOutputStream
import kotlin.math.max
import kotlin.math.roundToInt
object ImagePrep {
const val MAX_EDGE = 2000
const val JPEG_QUALITY = 92
fun prepare(context: Context, uri: Uri): File {
val resolver = context.contentResolver
val bounds =
resolver.openInputStream(uri)?.use { input ->
BitmapFactory.Options().also {
it.inJustDecodeBounds = true
BitmapFactory.decodeStream(input, null, it)
}
} ?: throw IllegalArgumentException("Bild nicht lesbar")
val longEdge = max(bounds.outWidth, bounds.outHeight).coerceAtLeast(1)
val sample = sampleSize(longEdge, MAX_EDGE)
val bitmap =
resolver.openInputStream(uri)?.use { input ->
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
BitmapFactory.decodeStream(input, null, opts)
} ?: throw IllegalArgumentException("Bild nicht dekodierbar")
val oriented = applyExif(resolver.openInputStream(uri), bitmap)
val scaled = scaleToMaxEdge(oriented, MAX_EDGE)
if (scaled !== oriented && oriented !== bitmap) {
oriented.recycle()
}
if (oriented !== bitmap && scaled !== bitmap) {
bitmap.recycle()
}
val out = File(context.cacheDir, "upload-${System.currentTimeMillis()}.jpg")
FileOutputStream(out).use { stream ->
if (!scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, stream)) {
throw IllegalStateException("JPEG-Encode fehlgeschlagen")
}
}
if (scaled !== bitmap) scaled.recycle()
return out
}
private fun sampleSize(longEdge: Int, maxEdge: Int): Int {
var sample = 1
var size = longEdge
while (size / 2 >= maxEdge) {
sample *= 2
size /= 2
}
return sample
}
private fun scaleToMaxEdge(src: Bitmap, maxEdge: Int): Bitmap {
val longEdge = max(src.width, src.height)
if (longEdge <= maxEdge) return src
val scale = maxEdge.toFloat() / longEdge
val w = (src.width * scale).roundToInt().coerceAtLeast(1)
val h = (src.height * scale).roundToInt().coerceAtLeast(1)
return Bitmap.createScaledBitmap(src, w, h, true)
}
private fun applyExif(stream: java.io.InputStream?, bitmap: Bitmap): Bitmap {
if (stream == null) return bitmap
val orientation =
stream.use { input ->
ExifInterface(input).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
else -> return bitmap
}
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
}
@@ -0,0 +1,65 @@
package rocks.f12.live.media
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import rocks.f12.live.data.FileRef
import rocks.f12.live.data.JobDetail
import rocks.f12.live.data.LiveF12Api
import rocks.f12.live.data.VariantRef
object GalleryStore {
fun saveJob(context: Context, api: LiveF12Api, baseUrl: String, job: JobDetail): Int {
val refs = mutableListOf<FileRef>()
job.original?.let(refs::add)
job.rembg?.let(refs::add)
job.variants.forEach { variant ->
refs.add(FileRef(variant.file, variant.url ?: "/jobs/${job.jobId}/files/${variant.file}"))
}
job.intermediates.forEach(refs::add)
var saved = 0
refs.distinctBy { it.file }.forEach { ref ->
val bytes = api.download(baseUrl, ref.url)
write(context, job.jobId, ref.file, bytes)
saved += 1
}
return saved
}
private fun write(context: Context, jobId: String, filename: String, bytes: ByteArray) {
val mime =
when {
filename.endsWith(".png", ignoreCase = true) -> "image/png"
filename.endsWith(".webp", ignoreCase = true) -> "image/webp"
else -> "image/jpeg"
}
val values =
ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
put(MediaStore.Images.Media.MIME_TYPE, mime)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(
MediaStore.Images.Media.RELATIVE_PATH,
"${Environment.DIRECTORY_PICTURES}/livef12/$jobId",
)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val resolver = context.contentResolver
val uri =
resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: throw IllegalStateException("MediaStore insert fehlgeschlagen")
resolver.openOutputStream(uri)?.use { it.write(bytes) }
?: throw IllegalStateException("MediaStore write fehlgeschlagen")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
}
}
fun variantUrl(jobId: String, variant: VariantRef): String =
variant.url ?: "/jobs/$jobId/files/${variant.file}"
}
@@ -0,0 +1,77 @@
package rocks.f12.live.notify
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import rocks.f12.live.MainActivity
import rocks.f12.live.R
object AppNotifications {
const val CHANNEL_PROGRESS = "progress"
const val CHANNEL_RESULT = "result"
const val PROGRESS_ID = 42
fun ensureChannels(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_PROGRESS,
"Jobs laufen",
NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) },
)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_RESULT,
"Job fertig",
NotificationManager.IMPORTANCE_DEFAULT,
),
)
}
fun progress(context: Context, jobId: String, text: String): android.app.Notification {
return NotificationCompat.Builder(context, CHANNEL_PROGRESS)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("Job läuft")
.setContentText(text.ifBlank { jobId })
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(openJob(context, jobId))
.build()
}
fun result(context: Context, jobId: String, title: String, text: String, success: Boolean) {
val notification =
NotificationCompat.Builder(context, CHANNEL_RESULT)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(text)
.setAutoCancel(true)
.setContentIntent(openJob(context, jobId))
.build()
try {
NotificationManagerCompat.from(context).notify(jobId.hashCode(), notification)
} catch (_: SecurityException) {
/* POST_NOTIFICATIONS denied */
}
NotificationManagerCompat.from(context).cancel(PROGRESS_ID)
}
private fun openJob(context: Context, jobId: String): PendingIntent {
val intent =
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(MainActivity.EXTRA_JOB_ID, jobId)
}
return PendingIntent.getActivity(
context,
jobId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}
@@ -0,0 +1,103 @@
package rocks.f12.live.ui
import android.net.Uri
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import rocks.f12.live.ui.screens.HomeScreen
import rocks.f12.live.ui.screens.HomeViewModel
import rocks.f12.live.ui.screens.JobScreen
import rocks.f12.live.ui.screens.JobViewModel
import rocks.f12.live.ui.screens.SettingsScreen
import rocks.f12.live.ui.screens.SettingsViewModel
import rocks.f12.live.ui.screens.UploadScreen
import rocks.f12.live.ui.screens.UploadViewModel
import rocks.f12.live.ui.theme.Bg
import rocks.f12.live.ui.theme.LiveF12Theme
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@Composable
fun LiveF12Root(
shareUri: Uri?,
openJobId: String?,
onPickPhoto: () -> Unit,
onShareConsumed: () -> Unit,
onOpenJobConsumed: () -> Unit,
) {
LiveF12Theme {
Surface(Modifier.fillMaxSize(), color = Bg) {
val nav = rememberNavController()
val homeVm: HomeViewModel = viewModel()
val settingsVm: SettingsViewModel = viewModel()
val jobVm: JobViewModel = viewModel()
val uploadVm: UploadViewModel = viewModel()
val homeState by homeVm.state.collectAsStateWithLifecycle()
LaunchedEffect(shareUri) {
val uri = shareUri ?: return@LaunchedEffect
val encoded = URLEncoder.encode(uri.toString(), StandardCharsets.UTF_8.name())
nav.navigate("upload?uri=$encoded")
onShareConsumed()
}
LaunchedEffect(openJobId) {
val id = openJobId ?: return@LaunchedEffect
nav.navigate("job/$id")
onOpenJobConsumed()
}
NavHost(navController = nav, startDestination = "home") {
composable("home") {
HomeScreen(
viewModel = homeVm,
onOpenJob = { nav.navigate("job/$it") },
onSettings = { nav.navigate("settings") },
onPickPhoto = onPickPhoto,
)
}
composable("settings") {
SettingsScreen(viewModel = settingsVm, onBack = { nav.popBackStack() })
}
composable(
"job/{jobId}",
arguments = listOf(navArgument("jobId") { type = NavType.StringType }),
) { entry ->
val jobId = entry.arguments?.getString("jobId").orEmpty()
JobScreen(
jobId = jobId,
viewModel = jobVm,
baseUrl = homeState.settings.baseUrl,
onBack = { nav.popBackStack() },
)
}
composable(
"upload?uri={uri}",
arguments = listOf(navArgument("uri") { type = NavType.StringType }),
) { entry ->
val raw = entry.arguments?.getString("uri").orEmpty()
val uri = Uri.parse(java.net.URLDecoder.decode(raw, StandardCharsets.UTF_8.name()))
UploadScreen(
uri = uri,
viewModel = uploadVm,
onDone = { jobId ->
nav.navigate("job/$jobId") {
popUpTo("home")
}
},
onErrorBack = { nav.popBackStack() },
)
}
}
}
}
}
@@ -0,0 +1,163 @@
package rocks.f12.live.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddPhotoAlternate
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import rocks.f12.live.data.JobSummary
import rocks.f12.live.data.LiveF12Api
import rocks.f12.live.ui.theme.Accent
import rocks.f12.live.ui.theme.AccentOn
import rocks.f12.live.ui.theme.Bg
import rocks.f12.live.ui.theme.BgElevated
import rocks.f12.live.ui.theme.Err
import rocks.f12.live.ui.theme.Ok
import rocks.f12.live.ui.theme.Pending
import rocks.f12.live.ui.theme.Text
import rocks.f12.live.ui.theme.TextDim
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
viewModel: HomeViewModel,
onOpenJob: (String) -> Unit,
onSettings: () -> Unit,
onPickPhoto: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
containerColor = Bg,
topBar = {
TopAppBar(
title = { Text("live.f12.rocks") },
actions = {
IconButton(onClick = onSettings) {
Icon(Icons.Outlined.Settings, contentDescription = "Einstellungen")
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = Bg,
titleContentColor = Text,
actionIconContentColor = Text,
),
)
},
floatingActionButton = {
FloatingActionButton(onClick = onPickPhoto, containerColor = Accent, contentColor = AccentOn) {
Icon(Icons.Outlined.AddPhotoAlternate, contentDescription = "Foto wählen")
}
},
) { padding ->
when {
state.settings.token.isBlank() -> {
Column(Modifier.padding(padding).padding(16.dp)) {
Text("Kein Token. In den Einstellungen den Upload-Token setzen.", color = Pending)
}
}
state.error != null && state.jobs.isEmpty() -> {
Column(Modifier.padding(padding).padding(16.dp)) {
Text(state.error ?: "", color = Err)
Text("Ziehen zum Neu laden passiert beim nächsten Poll.", color = TextDim, modifier = Modifier.padding(top = 8.dp))
}
}
state.jobs.isEmpty() && !state.loading -> {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text("Noch keine Jobs. Foto teilen oder + tippen.", color = TextDim)
}
}
else -> {
LazyVerticalGrid(
columns = GridCells.Adaptive(160.dp),
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(padding).fillMaxSize(),
) {
items(state.jobs, key = { it.jobId }) { job ->
JobCard(
job = job,
baseUrl = state.settings.baseUrl,
onClick = { onOpenJob(job.jobId) },
)
}
}
}
}
}
}
@Composable
private fun JobCard(job: JobSummary, baseUrl: String, onClick: () -> Unit) {
val statusColor =
when (job.status) {
"done" -> Ok
"error" -> Err
else -> Pending
}
Column(
modifier =
Modifier
.clip(RoundedCornerShape(8.dp))
.background(BgElevated)
.clickable(onClick = onClick),
) {
val thumb = job.thumbnailUrl?.let { LiveF12Api.resolve(baseUrl, it) }
if (thumb != null) {
AsyncImage(
model = thumb,
contentDescription = job.jobId,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(androidx.compose.ui.graphics.Color.Black),
)
} else {
Box(
Modifier.fillMaxWidth().aspectRatio(1f).background(androidx.compose.ui.graphics.Color.Black),
contentAlignment = Alignment.Center,
) {
Text(job.statusLabel, color = statusColor)
}
}
Column(Modifier.padding(10.dp)) {
Text(job.jobId, color = Text, maxLines = 1)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(job.statusLabel, color = statusColor)
Text(
job.stageLabel?.takeIf { job.status != "done" } ?: "${job.variantCount} Varianten",
color = TextDim,
)
}
}
}
}
@@ -0,0 +1,146 @@
package rocks.f12.live.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import rocks.f12.live.data.FileRef
import rocks.f12.live.data.LiveF12Api
import rocks.f12.live.ui.theme.Accent
import rocks.f12.live.ui.theme.Bg
import rocks.f12.live.ui.theme.BgElevated
import rocks.f12.live.ui.theme.Err
import rocks.f12.live.ui.theme.Ok
import rocks.f12.live.ui.theme.Pending
import rocks.f12.live.ui.theme.Text
import rocks.f12.live.ui.theme.TextDim
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JobScreen(
jobId: String,
viewModel: JobViewModel,
baseUrl: String,
onBack: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(jobId) { viewModel.start(jobId) }
val job = state.job
val statusColor =
when (job?.status) {
"done" -> Ok
"error" -> Err
else -> Pending
}
Scaffold(
containerColor = Bg,
topBar = {
TopAppBar(
title = { Text(jobId, maxLines = 1) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Zurück")
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = Bg,
titleContentColor = Text,
navigationIconContentColor = Text,
),
)
},
) { padding ->
if (job == null) {
Text(
state.error ?: "Lädt…",
color = if (state.error != null) Err else TextDim,
modifier = Modifier.padding(padding).padding(16.dp),
)
return@Scaffold
}
val files = buildList {
job.original?.let { add("Original" to it) }
job.rembg?.let { add("Rembg" to it) }
job.variants.forEach { v ->
add((v.id ?: "Variante") to FileRef(v.file, v.url ?: "/jobs/${job.jobId}/files/${v.file}"))
}
job.intermediates.forEach { add(it.file to it) }
}
LazyVerticalGrid(
columns = GridCells.Adaptive(140.dp),
modifier = Modifier.padding(padding).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("${job.statusLabel} · ${job.stageLabel ?: ""}", color = statusColor)
if (job.isActive && job.progress != null && job.progress.total > 0) {
LinearProgressIndicator(
progress = { job.progress.current.toFloat() / job.progress.total },
modifier = Modifier.fillMaxWidth(),
color = Accent,
)
}
job.error?.let { Text(it, color = Err) }
Button(
onClick = viewModel::saveToGallery,
enabled = !state.saving && job.variants.isNotEmpty(),
) {
Text(
when {
state.saving -> "Speichert…"
state.savedCount != null -> "${state.savedCount} Dateien gespeichert"
else -> "Aufs Handy speichern"
},
)
}
}
}
items(files, key = { it.second.file }) { (label, ref) ->
Column(
Modifier.clip(RoundedCornerShape(8.dp)).background(BgElevated),
) {
AsyncImage(
model = LiveF12Api.resolve(baseUrl, ref.url),
contentDescription = label,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(1f),
)
Text(label, color = TextDim, modifier = Modifier.padding(8.dp), maxLines = 2)
}
}
}
}
}
@@ -0,0 +1,96 @@
package rocks.f12.live.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import rocks.f12.live.ui.theme.Accent
import rocks.f12.live.ui.theme.Bg
import rocks.f12.live.ui.theme.Ok
import rocks.f12.live.ui.theme.Text
import rocks.f12.live.ui.theme.TextDim
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
containerColor = Bg,
topBar = {
TopAppBar(
title = { Text("Einstellungen") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Zurück")
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = Bg,
titleContentColor = Text,
navigationIconContentColor = Text,
),
)
},
) { padding ->
Column(
Modifier
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
Text("Server und Token nur auf diesem Handy. Gäste brauchen das nicht.", color = TextDim)
OutlinedTextField(
value = state.baseUrl,
onValueChange = viewModel::setBaseUrl,
label = { Text("Server-URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
)
OutlinedTextField(
value = state.token,
onValueChange = viewModel::setToken,
label = { Text("Upload-Token") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
)
Button(
onClick = viewModel::save,
modifier = Modifier.fillMaxWidth().padding(top = 20.dp),
) {
Text("Speichern")
}
OutlinedButton(
onClick = viewModel::probe,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text("Server prüfen")
}
if (state.saved) {
Text("Gespeichert.", color = Ok, modifier = Modifier.padding(top = 12.dp))
}
state.probe?.let { Text(it, color = Accent, modifier = Modifier.padding(top = 8.dp)) }
}
}
}
@@ -0,0 +1,60 @@
package rocks.f12.live.ui.screens
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import rocks.f12.live.ui.theme.Accent
import rocks.f12.live.ui.theme.Err
import rocks.f12.live.ui.theme.Text
@Composable
fun UploadScreen(
uri: Uri,
viewModel: UploadViewModel,
onDone: (String) -> Unit,
onErrorBack: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(uri) { viewModel.start(uri) }
LaunchedEffect(state.jobId) {
state.jobId?.let(onDone)
}
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (state.phase) {
"error" ->
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(24.dp)) {
Text(state.error ?: "Fehler", color = Err)
Text(
"Zurück",
color = Accent,
modifier = Modifier.padding(top = 16.dp).clickable(onClick = onErrorBack),
)
}
else -> {
CircularProgressIndicator(color = Accent)
Text(
when (state.phase) {
"prepare" -> "JPEG, längste Seite 2000px…"
"upload" -> "Lädt hoch…"
else -> "Job angelegt…"
},
color = Text,
modifier = Modifier.padding(top = 96.dp),
)
}
}
}
}
@@ -0,0 +1,256 @@
package rocks.f12.live.ui.screens
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rocks.f12.live.data.AppSettings
import rocks.f12.live.data.JobDetail
import rocks.f12.live.data.JobSummary
import rocks.f12.live.data.LiveF12Api
import rocks.f12.live.data.SettingsRepository
import rocks.f12.live.image.ImagePrep
import rocks.f12.live.media.GalleryStore
import rocks.f12.live.work.JobSyncWorker
data class HomeState(
val settings: AppSettings = AppSettings(SettingsRepository.DEFAULT_BASE_URL, ""),
val jobs: List<JobSummary> = emptyList(),
val loading: Boolean = true,
val error: String? = null,
)
class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val settingsRepo = SettingsRepository(application)
private val api = LiveF12Api()
private val _state = MutableStateFlow(HomeState())
val state: StateFlow<HomeState> = _state.asStateFlow()
private var poll: Job? = null
init {
viewModelScope.launch {
settingsRepo.settings.collect { settings ->
_state.value = _state.value.copy(settings = settings)
}
}
refresh()
startPoll()
}
fun refresh() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
runCatching {
withContext(Dispatchers.IO) { api.listJobs(_state.value.settings.baseUrl) }
}.onSuccess { response ->
_state.value = _state.value.copy(jobs = response.jobs, loading = false, error = null)
}.onFailure { err ->
_state.value =
_state.value.copy(
loading = false,
error = err.message ?: "Jobs konnten nicht geladen werden",
)
}
}
}
private fun startPoll() {
poll?.cancel()
poll =
viewModelScope.launch {
while (isActive) {
delay(4000)
val base = _state.value.settings.baseUrl
runCatching { withContext(Dispatchers.IO) { api.listJobs(base) } }
.onSuccess { response ->
_state.value = _state.value.copy(jobs = response.jobs, error = null)
}
}
}
}
}
data class SettingsState(
val baseUrl: String = SettingsRepository.DEFAULT_BASE_URL,
val token: String = "",
val saved: Boolean = false,
val probe: String? = null,
)
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
private val repo = SettingsRepository(application)
private val api = LiveF12Api()
private val _state = MutableStateFlow(SettingsState())
val state: StateFlow<SettingsState> = _state.asStateFlow()
init {
viewModelScope.launch {
repo.settings.collect { settings ->
_state.value =
_state.value.copy(
baseUrl = settings.baseUrl,
token = settings.token,
)
}
}
}
fun setBaseUrl(value: String) {
_state.value = _state.value.copy(baseUrl = value, saved = false)
}
fun setToken(value: String) {
_state.value = _state.value.copy(token = value, saved = false)
}
fun save() {
viewModelScope.launch {
repo.update(_state.value.baseUrl, _state.value.token)
_state.value = _state.value.copy(saved = true, probe = null)
}
}
fun probe() {
viewModelScope.launch {
val url = _state.value.baseUrl.trim().trimEnd('/')
val result =
runCatching { withContext(Dispatchers.IO) { api.health(url) } }
.fold(
onSuccess = { health ->
if (health.upload) "Server ok, Upload aktiv." else "Server erreichbar, Upload nicht konfiguriert."
},
onFailure = { err -> "Nicht erreichbar: ${err.message}" },
)
_state.value = _state.value.copy(probe = result)
}
}
}
data class JobUiState(
val job: JobDetail? = null,
val loading: Boolean = true,
val error: String? = null,
val saving: Boolean = false,
val savedCount: Int? = null,
)
class JobViewModel(application: Application) : AndroidViewModel(application) {
private val settingsRepo = SettingsRepository(application)
private val api = LiveF12Api()
private val _state = MutableStateFlow(JobUiState())
val state: StateFlow<JobUiState> = _state.asStateFlow()
private var poll: Job? = null
private var jobId: String? = null
fun start(id: String) {
if (jobId == id && poll?.isActive == true) return
jobId = id
poll?.cancel()
_state.value = JobUiState(loading = true)
loadOnce(id)
poll =
viewModelScope.launch {
while (isActive) {
delay(4000)
val current = _state.value.job
if (current?.isTerminal == true) break
loadOnce(id)
}
}
}
private fun loadOnce(id: String) {
viewModelScope.launch {
val base = settingsRepo.settings.first().baseUrl
runCatching { withContext(Dispatchers.IO) { api.job(base, id) } }
.onSuccess { job ->
_state.value = _state.value.copy(job = job, loading = false, error = null)
}
.onFailure { err ->
_state.value =
_state.value.copy(
loading = false,
error = err.message ?: "Job nicht geladen",
)
}
}
}
fun saveToGallery() {
val job = _state.value.job ?: return
viewModelScope.launch {
_state.value = _state.value.copy(saving = true)
val base = settingsRepo.settings.first().baseUrl
runCatching {
withContext(Dispatchers.IO) { GalleryStore.saveJob(getApplication(), api, base, job) }
}.onSuccess { count ->
_state.value = _state.value.copy(saving = false, savedCount = count, error = null)
}.onFailure { err ->
_state.value =
_state.value.copy(
saving = false,
error = err.message ?: "Speichern fehlgeschlagen",
)
}
}
}
}
data class UploadUiState(
val phase: String = "prepare",
val error: String? = null,
val jobId: String? = null,
)
class UploadViewModel(application: Application) : AndroidViewModel(application) {
private val settingsRepo = SettingsRepository(application)
private val api = LiveF12Api()
private val _state = MutableStateFlow(UploadUiState())
val state: StateFlow<UploadUiState> = _state.asStateFlow()
private var startedFor: Uri? = null
fun start(uri: Uri) {
if (startedFor == uri && _state.value.phase != "error") return
startedFor = uri
viewModelScope.launch {
_state.value = UploadUiState(phase = "prepare")
val settings = settingsRepo.settings.first()
if (settings.token.isBlank()) {
_state.value = UploadUiState(phase = "error", error = "Kein Upload-Token. Erst Einstellungen.")
return@launch
}
val file =
runCatching {
withContext(Dispatchers.IO) { ImagePrep.prepare(getApplication(), uri) }
}.onFailure { err ->
_state.value = UploadUiState(phase = "error", error = err.message ?: "Konvertierung fehlgeschlagen")
}.getOrNull() ?: return@launch
_state.value = UploadUiState(phase = "upload")
val uploaded =
runCatching {
withContext(Dispatchers.IO) {
api.upload(settings.baseUrl, settings.token, file, file.name)
}
}.onFailure { err ->
_state.value = UploadUiState(phase = "error", error = err.message ?: "Upload fehlgeschlagen")
}.getOrNull()
file.delete()
if (uploaded == null) return@launch
JobSyncWorker.enqueue(getApplication(), uploaded.jobId, settings.baseUrl)
_state.value = UploadUiState(phase = "done", jobId = uploaded.jobId)
}
}
}
@@ -0,0 +1,38 @@
package rocks.f12.live.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
val Bg = Color(0xFF14161A)
val BgElevated = Color(0xFF1D2025)
val Border = Color(0xFF2C3038)
val Text = Color(0xFFE7E9EC)
val TextDim = Color(0xFF9AA0AA)
val Accent = Color(0xFFFF5A1F)
val AccentOn = Color(0xFF14161A)
val Ok = Color(0xFF4CAF6A)
val Err = Color(0xFFE05A4A)
val Pending = Color(0xFFD9A441)
private val Colors: ColorScheme =
darkColorScheme(
primary = Accent,
onPrimary = AccentOn,
background = Bg,
onBackground = Text,
surface = BgElevated,
onSurface = Text,
surfaceVariant = Border,
onSurfaceVariant = TextDim,
error = Err,
onError = Text,
outline = Border,
)
@Composable
fun LiveF12Theme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = Colors, content = content)
}
@@ -0,0 +1,113 @@
package rocks.f12.live.work
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.delay
import rocks.f12.live.data.LiveF12Api
import rocks.f12.live.media.GalleryStore
import rocks.f12.live.notify.AppNotifications
class JobSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val api = LiveF12Api()
override suspend fun doWork(): Result {
val jobId = inputData.getString(KEY_JOB_ID) ?: return Result.failure()
val baseUrl = inputData.getString(KEY_BASE_URL) ?: return Result.failure()
setForeground(foreground("Wartet auf Worker…", jobId))
var lastLabel = ""
repeat(MAX_POLLS) { _ ->
val job =
try {
api.job(baseUrl, jobId)
} catch (_: Exception) {
delay(POLL_MS)
return@repeat
}
val label = job.stageLabel ?: job.statusLabel
if (label != lastLabel) {
setForeground(foreground(label, jobId))
lastLabel = label
}
when {
job.status == "done" -> {
val count = GalleryStore.saveJob(applicationContext, api, baseUrl, job)
AppNotifications.result(
applicationContext,
jobId,
"Job fertig",
"$count Dateien in Pictures/livef12",
success = true,
)
return Result.success()
}
job.status == "error" -> {
AppNotifications.result(
applicationContext,
jobId,
"Job fehlgeschlagen",
job.error ?: "Pipeline-Fehler",
success = false,
)
return Result.failure()
}
else -> delay(POLL_MS)
}
}
AppNotifications.result(
applicationContext,
jobId,
"Job hängt",
"Timeout nach Poll. In der App nachschauen.",
success = false,
)
return Result.retry()
}
private fun foreground(text: String, jobId: String): ForegroundInfo {
val notification = AppNotifications.progress(applicationContext, jobId, text)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(
AppNotifications.PROGRESS_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
ForegroundInfo(AppNotifications.PROGRESS_ID, notification)
}
}
companion object {
private const val KEY_JOB_ID = "job_id"
private const val KEY_BASE_URL = "base_url"
private const val POLL_MS = 4000L
private const val MAX_POLLS = 450 // ~30 min
fun enqueue(context: Context, jobId: String, baseUrl: String) {
val request =
OneTimeWorkRequestBuilder<JobSyncWorker>()
.setInputData(workDataOf(KEY_JOB_ID to jobId, KEY_BASE_URL to baseUrl))
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.addTag("job-$jobId")
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"job-$jobId",
ExistingWorkPolicy.KEEP,
request,
)
}
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FF5A1F"
android:pathData="M34,38h40c2.2,0 4,1.8 4,4v28c0,2.2 -1.8,4 -4,4H34c-2.2,0 -4,-1.8 -4,-4V42c0,-2.2 1.8,-4 4,-4z" />
<path
android:fillColor="#14161A"
android:pathData="M54,56m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0" />
<path
android:fillColor="#14161A"
android:pathData="M40,44h8v4h-8z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="bg">#14161A</color>
<color name="accent">#FF5A1F</color>
</resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">live.f12</string>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LiveF12" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@color/bg</item>
<item name="android:navigationBarColor">@color/bg</item>
<item name="android:windowBackground">@color/bg</item>
<item name="android:colorPrimary">@color/accent</item>
</style>
</resources>
+6
View File
@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=60000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "livef12"
include(":app")
+143
View File
@@ -0,0 +1,143 @@
"""JSON API for the organizer Android app (upload + job status)."""
from __future__ import annotations
import hashlib
import hmac
import logging
import uuid
from pathlib import Path
from fastapi import APIRouter, File, Header, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse
from . import config, jobs, pipeline
logger = logging.getLogger("livef12.api")
router = APIRouter(prefix="/api")
_CHUNK = 1024 * 1024
_JSON_HEADERS = {"Cache-Control": "no-store"}
def _provided_token(
authorization: str | None,
x_upload_token: str | None,
) -> str:
if x_upload_token and x_upload_token.strip():
return x_upload_token.strip()
if authorization:
scheme, _, rest = authorization.partition(" ")
if scheme.lower() == "bearer" and rest.strip():
return rest.strip()
return ""
def _token_ok(provided: str) -> bool:
expected = config.UPLOAD_TOKEN
if not expected or not provided:
return False
# Hash first so compare_digest never sees unequal lengths.
left = hashlib.sha256(provided.encode("utf-8")).digest()
right = hashlib.sha256(expected.encode("utf-8")).digest()
return hmac.compare_digest(left, right)
def require_upload_token(
authorization: str | None,
x_upload_token: str | None,
) -> None:
if not config.UPLOAD_TOKEN:
raise HTTPException(status_code=503, detail="Upload ist nicht konfiguriert")
if not _token_ok(_provided_token(authorization, x_upload_token)):
raise HTTPException(status_code=401, detail="Ungültiger Upload-Token")
@router.get("/health")
def health() -> JSONResponse:
return JSONResponse(
{"ok": True, "upload": bool(config.UPLOAD_TOKEN)},
headers=_JSON_HEADERS,
)
@router.get("/jobs")
def api_list_jobs() -> JSONResponse:
items = jobs.list_jobs()
return JSONResponse(
{
"jobs": items,
"active": any(j["status"] in jobs.ACTIVE_STATUSES for j in items),
},
headers=_JSON_HEADERS,
)
@router.get("/jobs/{job_id}")
def api_job_detail(job_id: str) -> JSONResponse:
return JSONResponse(jobs.job_detail_payload(job_id), headers=_JSON_HEADERS)
@router.post("/jobs", status_code=202)
async def api_upload_job(
request: Request,
file: UploadFile = File(...),
authorization: str | None = Header(default=None),
x_upload_token: str | None = Header(default=None),
) -> JSONResponse:
require_upload_token(authorization, x_upload_token)
original_name = file.filename or "photo.jpg"
dest_name = jobs.make_inbox_filename(original_name)
suffix = Path(dest_name).suffix.lower()
if suffix not in config.SUPPORTED_EXTENSIONS:
raise HTTPException(status_code=400, detail="Dateityp nicht unterstützt")
config.INBOX_DIR.mkdir(parents=True, exist_ok=True)
config.META_DIR.mkdir(parents=True, exist_ok=True)
tmp = config.INBOX_DIR / f".upload_{uuid.uuid4().hex}"
dest = config.INBOX_DIR / dest_name
written = 0
try:
with tmp.open("wb") as out:
while True:
chunk = await file.read(_CHUNK)
if not chunk:
break
written += len(chunk)
if written > config.MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"Datei zu groß (max {config.MAX_UPLOAD_BYTES // (1024 * 1024)} MB)",
)
out.write(chunk)
if written == 0:
raise HTTPException(status_code=400, detail="Leere Datei")
tmp.replace(dest)
except HTTPException:
tmp.unlink(missing_ok=True)
raise
except OSError as exc:
tmp.unlink(missing_ok=True)
logger.exception("upload write failed")
raise HTTPException(status_code=500, detail="Upload konnte nicht gespeichert werden") from exc
finally:
await file.close()
stem = pipeline.sanitize_stem(dest.stem)
paths = pipeline.job_paths(stem, suffix)
pipeline.write_status(
paths,
"queued",
source_file=dest.name,
source_stem=stem,
stage="queued",
progress={"current": 0, "total": config.OUTPUT_COUNT},
error=None,
uploaded_from=request.client.host if request.client else None,
)
logger.info("queued upload %s -> job %s (%d bytes)", dest.name, stem, written)
payload = jobs.summarize_job(stem)
payload["status"] = "queued"
return JSONResponse(payload, status_code=202, headers=_JSON_HEADERS)
+11 -1
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from .env_utils import env_bool
def _int_env(name: str, default: int) -> int: def _int_env(name: str, default: int) -> int:
try: try:
@@ -32,6 +34,8 @@ INTERMEDIATES_DIR = DATA_DIR / "intermediates"
# Web bookkeeping (status + manifest). Not needed on the phone — ignore via # Web bookkeeping (status + manifest). Not needed on the phone — ignore via
# Syncthing .stignore if desired. # Syncthing .stignore if desired.
META_DIR = DATA_DIR / "meta" META_DIR = DATA_DIR / "meta"
# Resized WebP cache for srcset (web container only; safe to wipe / .stignore).
WEB_CACHE_DIR = DATA_DIR / "webcache"
ASSETS_DIR = DATA_DIR / "assets" ASSETS_DIR = DATA_DIR / "assets"
PROCESSED_FILE = DATA_DIR / "processed.json" PROCESSED_FILE = DATA_DIR / "processed.json"
@@ -55,13 +59,15 @@ MAX_FILTER_ATTEMPTS = _int_env("MAX_FILTER_ATTEMPTS", 8)
# Longest edge after preprocess (ImageMagick). Keeps rembg/gmic sane on # Longest edge after preprocess (ImageMagick). Keeps rembg/gmic sane on
# large camera JPEGs. # large camera JPEGs.
MAX_EDGE_PX = _int_env("MAX_EDGE_PX", 2000) MAX_EDGE_PX = _int_env("MAX_EDGE_PX", 2000)
# ImageMagick preprocess step timeout (resize + orient).
PREPROCESS_TIMEOUT = _int_env("PREPROCESS_TIMEOUT", 120)
# Generous on purpose: the very first rembg call also downloads the model, # Generous on purpose: the very first rembg call also downloads the model,
# which can take a while depending on the link. # which can take a while depending on the link.
REMBG_TIMEOUT = _int_env("REMBG_TIMEOUT", 600) REMBG_TIMEOUT = _int_env("REMBG_TIMEOUT", 600)
# birefnet-general OOMs on the ~8GB event box; u2net + alpha matting fits # birefnet-general OOMs on the ~8GB event box; u2net + alpha matting fits
# and is the practical equivalent of `rembg i -a`. # and is the practical equivalent of `rembg i -a`.
REMBG_MODEL = os.environ.get("REMBG_MODEL", "u2net") REMBG_MODEL = os.environ.get("REMBG_MODEL", "u2net")
REMBG_ALPHA = os.environ.get("REMBG_ALPHA", "1").strip().lower() in {"1", "true", "yes", "on"} REMBG_ALPHA = env_bool("REMBG_ALPHA", True)
NICE_LEVEL = _int_env("NICE_LEVEL", 18) NICE_LEVEL = _int_env("NICE_LEVEL", 18)
# gmic CLI binary, override for local dev if not on PATH. rembg has no # gmic CLI binary, override for local dev if not on PATH. rembg has no
@@ -101,3 +107,7 @@ STABLE_WAIT_SECONDS = _float_env("STABLE_WAIT_SECONDS", 2)
# --- Web --------------------------------------------------------------- # --- Web ---------------------------------------------------------------
SITE_TITLE = os.environ.get("SITE_TITLE", "live.f12.rocks") SITE_TITLE = os.environ.get("SITE_TITLE", "live.f12.rocks")
# Shared secret for POST /api/jobs. Empty = upload API disabled (fail closed).
UPLOAD_TOKEN = os.environ.get("UPLOAD_TOKEN", "")
# Hard cap after client-side 2000px JPEG; 25 MB is generous for a bad encode.
MAX_UPLOAD_BYTES = _int_env("MAX_UPLOAD_BYTES", 25 * 1024 * 1024)
+12
View File
@@ -0,0 +1,12 @@
"""Shared environment-variable parsing."""
from __future__ import annotations
import os
def env_bool(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
+87
View File
@@ -0,0 +1,87 @@
"""On-demand web image resizing with disk cache (srcset helpers)."""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from urllib.parse import quote
from PIL import Image
from . import config
logger = logging.getLogger("livef12.images")
# Allowed ?w= values for /jobs/.../files/... — keep in sync with templates.
SRCSET_WIDTHS: tuple[int, ...] = (320, 640, 960, 1280)
# Default sizes for the 1/2/4-column job + variant grids.
GRID_SIZES = "(min-width: 960px) 25vw, (min-width: 640px) 50vw, 100vw"
# Original/rembg pair is always two columns.
PAIR_SIZES = "(min-width: 640px) 50vw, 50vw"
def file_url(job_id: str, filename: str, width: int | None = None) -> str:
base = f"/jobs/{quote(job_id, safe='')}/files/{quote(filename, safe='')}"
if width is None:
return base
return f"{base}?w={width}"
def srcset_for(job_id: str, filename: str) -> str:
return ", ".join(f"{file_url(job_id, filename, w)} {w}w" for w in SRCSET_WIDTHS)
def img_attrs(job_id: str, filename: str, *, sizes: str = GRID_SIZES) -> dict[str, str]:
"""Attrs for responsive <img>: src, srcset, sizes, full_src (lightbox)."""
return {
"src": file_url(job_id, filename, 640),
"srcset": srcset_for(job_id, filename),
"sizes": sizes,
# Lightbox: large webp, not the multi-MB master PNG/JPEG.
"full_src": file_url(job_id, filename, SRCSET_WIDTHS[-1]),
}
def get_or_create_resized(source: Path, width: int) -> Path:
"""Return a cached WebP whose longest edge is at most `width` (no upscale)."""
if width not in SRCSET_WIDTHS:
raise ValueError(f"unsupported width: {width}")
if not source.is_file():
raise FileNotFoundError(source)
config.WEB_CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_path = config.WEB_CACHE_DIR / f"{source.name}.w{width}.webp"
try:
if cache_path.is_file() and cache_path.stat().st_mtime >= source.stat().st_mtime:
return cache_path
except OSError:
pass
_write_resized(source, cache_path, width)
return cache_path
def _write_resized(source: Path, dest: Path, width: int) -> None:
with Image.open(source) as img:
img.load()
if img.mode == "P":
img = img.convert("RGBA" if "transparency" in img.info else "RGB")
elif img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
if img.width > width:
new_h = max(1, round(img.height * (width / img.width)))
img = img.resize((width, new_h), Image.Resampling.LANCZOS)
dest.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=dest.parent, suffix=".webp", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
img.save(tmp_path, format="WEBP", quality=78, method=4)
tmp_path.replace(dest)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
logger.debug("cached %s -> %s (%dx%d)", source.name, dest.name, img.width, img.height)
+42
View File
@@ -0,0 +1,42 @@
"""Small shared I/O helpers."""
from __future__ import annotations
import fcntl
import json
import os
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
def read_json(path: Path, *, default: Any = None) -> Any:
"""Read JSON from *path*, returning *default* when missing or corrupt."""
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return default
def write_json_atomic(path: Path, data: Any) -> None:
"""Write JSON via a temp file and atomic replace."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
@contextmanager
def file_lock(lock_path: Path) -> Iterator[None]:
"""Exclusive advisory lock via ``fcntl.flock`` (cross-process)."""
lock_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
+180
View File
@@ -0,0 +1,180 @@
"""Job listing, labels, and JSON payloads shared by the HTML UI and /api."""
from __future__ import annotations
import re
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from fastapi import HTTPException
from . import config, images, pipeline
_JOB_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
STATUS_LABELS: dict[str, str] = {
"queued": "Warteschlange",
"processing": "Läuft",
"done": "Fertig",
"error": "Fehler",
"unknown": "unbekannt",
}
ACTIVE_STATUSES = frozenset({"queued", "processing", "pending"})
class JobIdError(ValueError):
"""Invalid or missing job id."""
def validate_job_id(job_id: str) -> str:
raw = job_id or ""
if not raw.strip() or "/" in raw or "\\" in raw or ".." in raw:
raise JobIdError("Ungültige Job-ID")
stem = pipeline.sanitize_stem(raw)
if not _JOB_ID_RE.match(stem):
raise JobIdError("Ungültige Job-ID")
return stem
def require_job_id(job_id: str) -> str:
try:
stem = validate_job_id(job_id)
except JobIdError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if pipeline.read_status(stem) is None and not pipeline.job_paths(stem).original.exists():
raise HTTPException(status_code=404, detail="Job nicht gefunden")
return stem
def status_label(status: str | None) -> str:
key = (status or "unknown").strip() or "unknown"
return STATUS_LABELS.get(key, key)
def stage_label(status: str | None, stage: str | None, progress: dict[str, Any] | None) -> str:
if status == "queued":
return "Wartet auf Worker"
if status == "done":
return "Fertig"
if status == "error":
return "Fehler"
if stage == "preprocess":
return "Vorverarbeitung"
if stage == "rembg":
return "Freistellen"
if stage == "variant":
current = (progress or {}).get("current")
total = (progress or {}).get("total")
if current and total:
return f"Variante {current}/{total}"
return "Varianten"
if status == "processing":
return "Läuft"
return status_label(status)
def file_url(job_id: str, filename: str | None) -> str | None:
if not filename:
return None
return images.file_url(job_id, filename)
def list_intermediates(stem: str) -> list[str]:
paths = pipeline.job_paths(stem)
if not paths.intermediates.exists():
return []
return sorted(p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file())
def _progress(data: dict[str, Any]) -> dict[str, int] | None:
raw = data.get("progress")
if not isinstance(raw, dict):
return None
try:
current = int(raw.get("current", 0))
total = int(raw.get("total", 0))
except (TypeError, ValueError):
return None
if total <= 0:
return None
return {"current": current, "total": total}
def _file_entry(job_id: str, filename: str | None) -> dict[str, str] | None:
if not filename:
return None
return {"file": filename, "url": images.file_url(job_id, filename)}
def summarize_job(job_id: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
meta = data if data is not None else (pipeline.read_meta(job_id) or {})
variants = meta.get("variants") or []
status = meta.get("status", "unknown")
progress = _progress(meta)
stage = meta.get("stage")
thumb = variants[-1].get("file") if variants else None
return {
"job_id": job_id,
"status": status,
"status_label": status_label(status),
"stage": stage,
"stage_label": stage_label(status, stage if isinstance(stage, str) else None, progress),
"progress": progress,
"created_at": meta.get("created_at") or "",
"updated_at": meta.get("updated_at") or "",
"variant_count": len(variants),
"thumbnail": thumb,
"thumbnail_url": file_url(job_id, thumb),
"error": meta.get("error"),
}
def list_jobs() -> list[dict[str, Any]]:
jobs = [summarize_job(job_id) for job_id in pipeline.list_job_ids()]
jobs.sort(key=lambda j: j["created_at"] or j["job_id"], reverse=True)
return jobs
def job_detail_payload(job_id: str) -> dict[str, Any]:
stem = require_job_id(job_id)
meta = pipeline.read_meta(stem) or {}
paths = pipeline.job_paths(stem)
summary = summarize_job(stem, meta)
variants = []
for item in meta.get("variants") or []:
if not isinstance(item, dict) or not item.get("file"):
continue
entry = dict(item)
entry["url"] = images.file_url(stem, item["file"])
variants.append(entry)
intermediates = [_file_entry(stem, name) for name in list_intermediates(stem)]
original_name = paths.original.name if paths.original.exists() else meta.get("original_file")
rembg_name = paths.rembg.name if paths.rembg.exists() else meta.get("rembg_file")
summary.update(
{
"original": _file_entry(stem, original_name if original_name else None),
"rembg": _file_entry(stem, rembg_name if rembg_name else None),
"has_rembg": paths.rembg.exists(),
"variants": variants,
"intermediates": [item for item in intermediates if item],
"variant_errors": meta.get("variant_errors") or [],
"source_file": meta.get("source_file"),
}
)
return summary
def make_inbox_filename(original_name: str) -> str:
"""Unique inbox name so two party.jpg uploads don't clobber each other."""
stem = pipeline.sanitize_stem(original_name)
suffix = Path(original_name).suffix.lower()
if suffix not in config.SUPPORTED_EXTENSIONS:
suffix = ".jpg"
if suffix == ".jpeg":
suffix = ".jpg"
stamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
short = uuid.uuid4().hex[:6]
return f"{stamp}_{short}_{stem}{suffix}"
+11
View File
@@ -0,0 +1,11 @@
"""Shared logging setup for entry-point modules."""
from __future__ import annotations
import logging
_LOG_FORMAT = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
def configure_logging(level: int = logging.INFO) -> None:
logging.basicConfig(level=level, format=_LOG_FORMAT)
+49 -43
View File
@@ -3,9 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from pathlib import Path from pathlib import Path
from typing import Any
from urllib.parse import quote from urllib.parse import quote
from fastapi import FastAPI, Form, HTTPException, Query, Request from fastapi import FastAPI, Form, HTTPException, Query, Request
@@ -13,33 +11,35 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from . import config, pipeline, remix from . import api, config, images, jobs, pipeline, remix
from .logging_config import configure_logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s") configure_logging()
logger = logging.getLogger("livef12.web") logger = logging.getLogger("livef12.web")
app = FastAPI(title=config.SITE_TITLE) app = FastAPI(title=config.SITE_TITLE)
app.include_router(api.router)
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
templates.env.globals["img_attrs"] = images.img_attrs
templates.env.globals["PAIR_SIZES"] = images.PAIR_SIZES
templates.env.globals["GRID_SIZES"] = images.GRID_SIZES
templates.env.globals["status_label"] = jobs.status_label
templates.env.globals["stage_label"] = jobs.stage_label
templates.env.globals["ACTIVE_STATUSES"] = jobs.ACTIVE_STATUSES
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
# Job id = sanitized source stem (may include dots).
_JOB_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
def _validate_job_id(job_id: str) -> str: def _validate_job_id(job_id: str) -> str:
stem = pipeline.sanitize_stem(job_id) try:
if not _JOB_ID_RE.match(stem): return jobs.validate_job_id(job_id)
raise HTTPException(status_code=400, detail="Ungueltige Job-ID") except jobs.JobIdError as exc:
return stem raise HTTPException(status_code=400, detail=str(exc)) from exc
def _job_exists(job_id: str) -> str: def _job_exists(job_id: str) -> str:
stem = _validate_job_id(job_id) return jobs.require_job_id(job_id)
if pipeline.read_status(stem) is None and not pipeline.job_paths(stem).original.exists():
raise HTTPException(status_code=404, detail="Job nicht gefunden")
return stem
def _safe_job_file(job_id: str, filename: str) -> Path: def _safe_job_file(job_id: str, filename: str) -> Path:
@@ -47,9 +47,9 @@ def _safe_job_file(job_id: str, filename: str) -> Path:
stem = _validate_job_id(job_id) stem = _validate_job_id(job_id)
name = Path(filename).name name = Path(filename).name
if name != filename or "/" in filename or "\\" in filename or name.startswith("."): if name != filename or "/" in filename or "\\" in filename or name.startswith("."):
raise HTTPException(status_code=400, detail="Ungueltiger Pfad") raise HTTPException(status_code=400, detail="Ungültiger Pfad")
if not name.startswith(f"{stem}_"): if not name.startswith(f"{stem}_"):
raise HTTPException(status_code=400, detail="Ungueltiger Pfad") raise HTTPException(status_code=400, detail="Ungültiger Pfad")
for directory in (config.VARIANTS_DIR, config.INTERMEDIATES_DIR): for directory in (config.VARIANTS_DIR, config.INTERMEDIATES_DIR):
candidate = (directory / name).resolve() candidate = (directory / name).resolve()
@@ -60,29 +60,17 @@ def _safe_job_file(job_id: str, filename: str) -> Path:
raise HTTPException(status_code=404, detail="Datei nicht gefunden") raise HTTPException(status_code=404, detail="Datei nicht gefunden")
def list_jobs() -> list[dict[str, Any]]:
jobs = []
for job_id in pipeline.list_job_ids():
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["created_at"] or j["job_id"], reverse=True)
return jobs
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse: def index(request: Request) -> HTMLResponse:
job_list = jobs.list_jobs()
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"index.html", "index.html",
{"request": request, "jobs": list_jobs(), "site_title": config.SITE_TITLE}, {
"jobs": job_list,
"site_title": config.SITE_TITLE,
"has_active": any(j["status"] in jobs.ACTIVE_STATUSES for j in job_list),
},
) )
@@ -92,14 +80,12 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse:
paths = pipeline.job_paths(stem) paths = pipeline.job_paths(stem)
status = pipeline.read_status(stem) or {} status = pipeline.read_status(stem) or {}
manifest = pipeline.read_manifest(stem) or {"variants": []} manifest = pipeline.read_manifest(stem) or {"variants": []}
intermediates = sorted( intermediates = jobs.list_intermediates(stem)
p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file()
) if paths.intermediates.exists() else []
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"job.html", "job.html",
{ {
"request": request,
"site_title": config.SITE_TITLE, "site_title": config.SITE_TITLE,
"job_id": stem, "job_id": stem,
"status": status, "status": status,
@@ -108,14 +94,31 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse:
"rembg_name": paths.rembg.name if paths.rembg.exists() else None, "rembg_name": paths.rembg.name if paths.rembg.exists() else None,
"has_rembg": paths.rembg.exists(), "has_rembg": paths.rembg.exists(),
"intermediates": intermediates, "intermediates": intermediates,
"live": (status.get("status") or "") in jobs.ACTIVE_STATUSES,
}, },
) )
@app.get("/jobs/{job_id}/files/{filename:path}") @app.get("/jobs/{job_id}/files/{filename:path}")
def job_file(job_id: str, filename: str) -> FileResponse: def job_file(
job_id: str,
filename: str,
w: int | None = Query(None, description="Longest-edge width for srcset WebP"),
) -> FileResponse:
path = _safe_job_file(job_id, filename) path = _safe_job_file(job_id, filename)
if w is None:
return FileResponse(path) return FileResponse(path)
if w not in images.SRCSET_WIDTHS:
raise HTTPException(
status_code=400,
detail=f"Ungültige Breite (erlaubt: {', '.join(map(str, images.SRCSET_WIDTHS))})",
)
try:
cached = images.get_or_create_resized(path, w)
except OSError as exc:
logger.warning("resize failed for %s w=%s: %s", path.name, w, exc)
raise HTTPException(status_code=500, detail="Bild konnte nicht skaliert werden") from exc
return FileResponse(cached, media_type="image/webp", filename=cached.name)
@app.get("/jobs/{job_id}/remix", response_class=HTMLResponse) @app.get("/jobs/{job_id}/remix", response_class=HTMLResponse)
@@ -128,7 +131,10 @@ def remix_form(
stem = _job_exists(job_id) stem = _job_exists(job_id)
paths = pipeline.job_paths(stem) paths = pipeline.job_paths(stem)
if not paths.rembg.exists(): if not paths.rembg.exists():
raise HTTPException(status_code=409, detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht moeglich.") raise HTTPException(
status_code=409,
detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht möglich.",
)
assets = pipeline.load_assets() assets = pipeline.load_assets()
options = remix.build_remix_options(assets) options = remix.build_remix_options(assets)
@@ -163,9 +169,9 @@ def remix_form(
} }
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"remix.html", "remix.html",
{ {
"request": request,
"site_title": config.SITE_TITLE, "site_title": config.SITE_TITLE,
"job_id": stem, "job_id": stem,
"options": options, "options": options,
+145 -49
View File
@@ -15,22 +15,25 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import threading
import time import time
import uuid import uuid
from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any, Iterator from typing import Any
from . import config from . import config
from .io_utils import file_lock, read_json, write_json_atomic
logger = logging.getLogger("livef12.pipeline") logger = logging.getLogger("livef12.pipeline")
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
# Optional override for run_gmic timeout (used by the long-retry pass). # Per-thread override for run_gmic timeout (used by the long-retry pass).
_filter_timeout_override: int | None = None _filter_timeout_state = threading.local()
class PipelineError(RuntimeError): class PipelineError(RuntimeError):
@@ -67,19 +70,19 @@ def _nice(cmd: list[str]) -> list[str]:
def _active_filter_timeout() -> int: def _active_filter_timeout() -> int:
return _filter_timeout_override if _filter_timeout_override is not None else config.FILTER_TIMEOUT override = getattr(_filter_timeout_state, "override", None)
return override if override is not None else config.FILTER_TIMEOUT
@contextmanager @contextmanager
def filter_timeout(seconds: int) -> Iterator[None]: def filter_timeout(seconds: int) -> Iterator[None]:
"""Temporarily override FILTER_TIMEOUT for run_gmic (long-retry pass).""" """Temporarily override FILTER_TIMEOUT for run_gmic (long-retry pass)."""
global _filter_timeout_override previous = getattr(_filter_timeout_state, "override", None)
previous = _filter_timeout_override _filter_timeout_state.override = seconds
_filter_timeout_override = seconds
try: try:
yield yield
finally: finally:
_filter_timeout_override = previous _filter_timeout_state.override = previous
def _magick_bin() -> str: def _magick_bin() -> str:
@@ -125,7 +128,9 @@ def load_assets() -> FilterAssets:
if not bg_names or not fg_names: if not bg_names or not fg_names:
raise PipelineError("Keine gueltigen Filter in background/foreground Listen") 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) 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]: def run_gmic(args: list[str], output_image: Path) -> tuple[bool, str, float]:
@@ -167,7 +172,9 @@ def apply_filter(image: Path, full_command: str, output_image: Path) -> tuple[bo
return ok, err return ok, err
def apply_filter_chain(image: Path, commands: tuple[str, ...], output_image: Path, tmp_dir: Path, prefix: str) -> tuple[bool, str]: def apply_filter_chain(
image: Path, commands: tuple[str, ...], output_image: Path, tmp_dir: Path, prefix: str
) -> tuple[bool, str]:
current = image current = image
for i, command in enumerate(commands): for i, command in enumerate(commands):
target = output_image if i == len(commands) - 1 else tmp_dir / f"{prefix}_post_{i}.png" target = output_image if i == len(commands) - 1 else tmp_dir / f"{prefix}_post_{i}.png"
@@ -178,11 +185,6 @@ def apply_filter_chain(image: Path, commands: tuple[str, ...], output_image: Pat
return True, "" 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]: 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) ok, err, _ = run_gmic([str(base), str(overlay), "blend", f"{mode},{opacity}"], output_image)
return ok, err return ok, err
@@ -236,10 +238,10 @@ def preprocess_original(paths: JobPaths) -> JobPaths:
] ]
) )
try: try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120) proc = subprocess.run(cmd, capture_output=True, text=True, timeout=config.PREPROCESS_TIMEOUT)
except subprocess.TimeoutExpired as exc: except subprocess.TimeoutExpired as exc:
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)
raise PipelineError("Preprocess Timeout nach 120s") from exc raise PipelineError(f"Preprocess Timeout nach {config.PREPROCESS_TIMEOUT}s") from exc
if proc.returncode != 0 or not tmp.exists() or tmp.stat().st_size == 0: if proc.returncode != 0 or not tmp.exists() or tmp.stat().st_size == 0:
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)
err = strip_ansi((proc.stderr or proc.stdout or "").strip()) err = strip_ansi((proc.stderr or proc.stdout or "").strip())
@@ -297,7 +299,7 @@ def pick_working_filter(
def now_iso() -> str: def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds") return datetime.now(UTC).isoformat(timespec="seconds")
_UNSAFE_STEM_RE = re.compile(r"[^\w.\-]+", re.UNICODE) _UNSAFE_STEM_RE = re.compile(r"[^\w.\-]+", re.UNICODE)
@@ -305,8 +307,10 @@ _UNSAFE_STEM_RE = re.compile(r"[^\w.\-]+", re.UNICODE)
def sanitize_stem(name: str) -> str: def sanitize_stem(name: str) -> str:
"""Safe basename stem for flat files (no path separators / junk).""" """Safe basename stem for flat files (no path separators / junk)."""
stem = Path(name).stem if name else "" # Replace separators before Path.stem so "a/b" → "a_b", not "b".
stem = stem.replace("/", "_").replace("\\", "_").strip().strip(".") stem = (name or "").replace("/", "_").replace("\\", "_")
stem = Path(stem).stem
stem = stem.strip().strip(".")
stem = _UNSAFE_STEM_RE.sub("_", stem).strip("._") stem = _UNSAFE_STEM_RE.sub("_", stem).strip("._")
return stem or "photo" return stem or "photo"
@@ -346,30 +350,54 @@ def job_paths(stem: str, original_suffix: str = ".jpg") -> JobPaths:
) )
def _read_meta(stem: str) -> dict[str, Any] | None: def read_meta(stem: str) -> dict[str, Any] | None:
"""Return the full meta record for a job, or None if missing/corrupt."""
paths = job_paths(stem) paths = job_paths(stem)
if not paths.meta.exists(): data = read_json(paths.meta)
return None return data if isinstance(data, dict) else None
try:
return json.loads(paths.meta.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def _write_meta(paths: JobPaths, data: dict[str, Any]) -> None: def _meta_lock_path(stem: str) -> Path:
paths.meta.parent.mkdir(parents=True, exist_ok=True) return config.META_DIR / f"{sanitize_stem(stem)}.lock"
@contextmanager
def meta_lock(stem: str) -> Iterator[None]:
"""Exclusive lock for meta/{stem}.json read-modify-write."""
with file_lock(_meta_lock_path(stem)):
yield
def _write_meta_unlocked(paths: JobPaths, data: dict[str, Any]) -> None:
data["updated_at"] = now_iso() data["updated_at"] = now_iso()
tmp = paths.meta.with_suffix(".json.tmp") write_json_atomic(paths.meta, data)
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(paths.meta)
def _merge_variants(existing: list[Any], incoming: list[Any]) -> list[dict[str, Any]]:
"""Union variants by id; incoming wins on conflict; preserve discovery order."""
by_id: dict[str, dict[str, Any]] = {}
order: list[str] = []
for group in (existing, incoming):
for item in group:
if not isinstance(item, dict):
continue
vid = item.get("id")
if not isinstance(vid, str) or not vid:
continue
if vid not in by_id:
order.append(vid)
by_id[vid] = item
return [by_id[vid] for vid in order]
def read_status(job_id: str) -> dict[str, Any] | None: def read_status(job_id: str) -> dict[str, Any] | None:
data = _read_meta(job_id) data = read_meta(job_id)
if not data: if not data:
return None return None
return { return {
"status": data.get("status", "unknown"), "status": data.get("status", "unknown"),
"stage": data.get("stage"),
"progress": data.get("progress"),
"created_at": data.get("created_at"), "created_at": data.get("created_at"),
"updated_at": data.get("updated_at"), "updated_at": data.get("updated_at"),
"error": data.get("error"), "error": data.get("error"),
@@ -380,17 +408,18 @@ def read_status(job_id: str) -> dict[str, Any] | None:
def write_status(paths: JobPaths, status: str, **extra: Any) -> None: def write_status(paths: JobPaths, status: str, **extra: Any) -> None:
data = _read_meta(paths.stem) or {} with meta_lock(paths.stem):
data = read_meta(paths.stem) or {}
data["job_id"] = paths.stem data["job_id"] = paths.stem
data["source_stem"] = paths.stem data["source_stem"] = paths.stem
data["status"] = status data["status"] = status
data.setdefault("created_at", now_iso()) data.setdefault("created_at", now_iso())
data.update(extra) data.update(extra)
_write_meta(paths, data) _write_meta_unlocked(paths, data)
def read_manifest(job_id: str) -> dict[str, Any] | None: def read_manifest(job_id: str) -> dict[str, Any] | None:
data = _read_meta(job_id) data = read_meta(job_id)
if not data: if not data:
return None return None
return { return {
@@ -405,16 +434,52 @@ def read_manifest(job_id: str) -> dict[str, Any] | None:
def write_manifest(paths: JobPaths, manifest: dict[str, Any]) -> None: def write_manifest(paths: JobPaths, manifest: dict[str, Any]) -> None:
data = _read_meta(paths.stem) or {} """Upsert manifest fields; merge variants by id so concurrent writers do not clobber."""
with meta_lock(paths.stem):
data = read_meta(paths.stem) or {}
data["job_id"] = paths.stem data["job_id"] = paths.stem
data["source_stem"] = manifest.get("source_stem") or paths.stem if manifest.get("source_stem"):
data["original_file"] = manifest.get("original_file") data["source_stem"] = manifest["source_stem"]
data["rembg_file"] = manifest.get("rembg_file") else:
data["variants"] = manifest.get("variants") or [] data.setdefault("source_stem", paths.stem)
if "original_file" in manifest and manifest["original_file"] is not None:
data["original_file"] = manifest["original_file"]
if "rembg_file" in manifest and manifest["rembg_file"] is not None:
data["rembg_file"] = manifest["rembg_file"]
data["variants"] = _merge_variants(data.get("variants") or [], manifest.get("variants") or [])
if "created_at" in manifest: if "created_at" in manifest:
data.setdefault("created_at", manifest["created_at"]) data.setdefault("created_at", manifest["created_at"])
data.setdefault("status", data.get("status", "processing")) data.setdefault("status", data.get("status", "processing"))
_write_meta(paths, data) _write_meta_unlocked(paths, data)
def append_manifest_variant(
paths: JobPaths,
entry: dict[str, Any],
*,
original_file: str | None = None,
rembg_file: str | None = None,
source_stem: str | None = None,
created_at: str | None = None,
) -> None:
"""Append one variant under meta lock (re-reads disk so concurrent updates survive)."""
with meta_lock(paths.stem):
data = read_meta(paths.stem) or {}
data["job_id"] = paths.stem
if source_stem:
data["source_stem"] = source_stem
else:
data.setdefault("source_stem", paths.stem)
if original_file:
data["original_file"] = original_file
if rembg_file:
data["rembg_file"] = rembg_file
if created_at:
data.setdefault("created_at", created_at)
data.setdefault("created_at", now_iso())
data.setdefault("status", data.get("status", "done"))
data["variants"] = _merge_variants(data.get("variants") or [], [entry])
_write_meta_unlocked(paths, data)
def list_job_ids() -> list[str]: def list_job_ids() -> list[str]:
@@ -483,14 +548,18 @@ def compose_variant(
if not bg_command: if not bg_command:
raise PipelineError(f"Unbekannter Background-Filter: {bg_name}") raise PipelineError(f"Unbekannter Background-Filter: {bg_name}")
else: else:
bg_name, bg_command = pick_working_filter(assets.background_names, assets.commands, paths.original, tmp_dir, "bg", rng) bg_name, bg_command = pick_working_filter(
assets.background_names, assets.commands, paths.original, tmp_dir, "bg", rng
)
if fg_name: if fg_name:
fg_command = assets.commands.get(fg_name) fg_command = assets.commands.get(fg_name)
if not fg_command: if not fg_command:
raise PipelineError(f"Unbekannter Foreground-Filter: {fg_name}") raise PipelineError(f"Unbekannter Foreground-Filter: {fg_name}")
else: else:
fg_name, fg_command = pick_working_filter(assets.foreground_names, assets.commands, paths.rembg, tmp_dir, "fg", rng) fg_name, fg_command = pick_working_filter(
assets.foreground_names, assets.commands, paths.rembg, tmp_dir, "fg", rng
)
p = { p = {
"bg_filtered": tmp_dir / f"{prefix}_bg_filtered.png", "bg_filtered": tmp_dir / f"{prefix}_bg_filtered.png",
@@ -561,7 +630,15 @@ def process_job(
paths.intermediates.mkdir(parents=True, exist_ok=True) paths.intermediates.mkdir(parents=True, exist_ok=True)
paths.meta.parent.mkdir(parents=True, exist_ok=True) paths.meta.parent.mkdir(parents=True, exist_ok=True)
write_status(paths, "processing", source_file=str(source_path.name), source_stem=stem) write_status(
paths,
"processing",
source_file=str(source_path.name),
source_stem=stem,
stage="preprocess",
progress={"current": 0, "total": config.OUTPUT_COUNT},
error=None,
)
try: try:
paths = preprocess_original(paths) paths = preprocess_original(paths)
@@ -576,6 +653,12 @@ def process_job(
} }
write_manifest(paths, manifest) write_manifest(paths, manifest)
write_status(
paths,
"processing",
stage="rembg",
progress={"current": 0, "total": config.OUTPUT_COUNT},
)
ok, err = run_rembg(paths.original, paths.rembg) ok, err = run_rembg(paths.original, paths.rembg)
if not ok: if not ok:
raise PipelineError(f"rembg fehlgeschlagen: {err}") raise PipelineError(f"rembg fehlgeschlagen: {err}")
@@ -587,6 +670,12 @@ def process_job(
for i in range(1, config.OUTPUT_COUNT + 1): for i in range(1, config.OUTPUT_COUNT + 1):
variant_id = f"v{i}" variant_id = f"v{i}"
write_status(
paths,
"processing",
stage="variant",
progress={"current": i, "total": config.OUTPUT_COUNT},
)
try: try:
entry = compose_variant( entry = compose_variant(
paths, paths,
@@ -633,10 +722,17 @@ def process_job(
if not manifest["variants"]: if not manifest["variants"]:
raise PipelineError("Keine Variante erfolgreich erzeugt: " + "; ".join(variant_errors)) raise PipelineError("Keine Variante erfolgreich erzeugt: " + "; ".join(variant_errors))
write_status(paths, "done", variant_errors=variant_errors) done_count = len(manifest["variants"])
write_status(
paths,
"done",
stage="done",
progress={"current": done_count, "total": config.OUTPUT_COUNT},
variant_errors=variant_errors,
)
except PipelineError as exc: except PipelineError as exc:
logger.error("[%s] job failed: %s", stem, exc) logger.error("[%s] job failed: %s", stem, exc)
write_status(paths, "error", error=str(exc)) write_status(paths, "error", stage="error", error=str(exc))
except Exception as exc: # noqa: BLE001 - keep the worker loop alive except Exception as exc: # noqa: BLE001 - keep the worker loop alive
logger.exception("[%s] unexpected error", stem) logger.exception("[%s] unexpected error", stem)
write_status(paths, "error", error=f"Unerwarteter Fehler: {exc}") write_status(paths, "error", stage="error", error=f"Unerwarteter Fehler: {exc}")
+2 -7
View File
@@ -18,12 +18,7 @@ import sys
import traceback import traceback
from pathlib import Path from pathlib import Path
from .env_utils import env_bool
def _env_bool(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def main() -> int: def main() -> int:
@@ -42,7 +37,7 @@ def main() -> int:
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
model = os.environ.get("REMBG_MODEL", "u2net") model = os.environ.get("REMBG_MODEL", "u2net")
alpha = _env_bool("REMBG_ALPHA", True) alpha = env_bool("REMBG_ALPHA", True)
session = new_session(model) session = new_session(model)
output_path.write_bytes( output_path.write_bytes(
remove( remove(
+11 -4
View File
@@ -3,6 +3,7 @@ cached original + rembg output and user-chosen filters/blends/opacity."""
from __future__ import annotations from __future__ import annotations
import uuid
from dataclasses import dataclass from dataclasses import dataclass
from . import pipeline from . import pipeline
@@ -52,8 +53,8 @@ def create_remix_variant(job_id: str, choice: RemixChoice) -> dict:
"variants": [], "variants": [],
} }
variant_stem = pipeline.resolve_source_stem(manifest, paths) variant_stem = pipeline.resolve_source_stem(manifest, paths)
manifest.setdefault("source_stem", variant_stem) # Unique id avoids collisions when two remixes run in parallel.
variant_id = pipeline.next_variant_id(manifest) variant_id = f"v{uuid.uuid4().hex[:8]}"
entry = pipeline.compose_variant( entry = pipeline.compose_variant(
paths, paths,
@@ -67,6 +68,12 @@ def create_remix_variant(job_id: str, choice: RemixChoice) -> dict:
fg_mode=choice.fg_blend, fg_mode=choice.fg_blend,
opacity=choice.opacity, opacity=choice.opacity,
) )
manifest["variants"].append(entry) pipeline.append_manifest_variant(
pipeline.write_manifest(paths, manifest) paths,
entry,
original_file=paths.original.name,
rembg_file=paths.rembg.name,
source_stem=variant_stem,
created_at=manifest.get("created_at") if isinstance(manifest.get("created_at"), str) else None,
)
return entry return entry
+160
View File
@@ -0,0 +1,160 @@
/* live.f12.rocks — live poll + lightbox gallery. No framework. */
(function () {
"use strict";
var POLL_MS = 4000;
function $(id) {
return document.getElementById(id);
}
/* --- live reload while jobs run --- */
function fingerprint(payload) {
if (!payload) return "";
if (payload.job_id) {
return [
payload.status,
payload.stage,
payload.updated_at,
payload.variant_count,
(payload.intermediates || []).length,
payload.error || "",
].join("|");
}
var jobs = payload.jobs || [];
return jobs
.map(function (j) {
return [j.job_id, j.status, j.updated_at, j.variant_count, j.stage].join(":");
})
.join(",");
}
function startLivePoll() {
if (!document.body.hasAttribute("data-live")) return;
var jobId = document.body.getAttribute("data-job-id");
var url = jobId ? "/api/jobs/" + encodeURIComponent(jobId) : "/api/jobs";
var last = "";
var first = true;
function tick() {
fetch(url, { headers: { Accept: "application/json" } })
.then(function (r) {
if (!r.ok) throw new Error("poll " + r.status);
return r.json();
})
.then(function (data) {
var next = fingerprint(data);
if (first) {
last = next;
first = false;
return;
}
if (next !== last) {
window.location.reload();
}
})
.catch(function () {
/* venue wifi: try again next tick */
});
}
tick();
setInterval(tick, POLL_MS);
}
/* --- lightbox with prev/next --- */
function startLightbox() {
var box = $("lightbox");
var img = $("lightbox-img");
var caption = $("lightbox-caption");
if (!box || !img) return;
var items = [];
var index = 0;
function collect() {
items = Array.prototype.slice
.call(document.querySelectorAll("img.lightboxable"))
.map(function (el) {
return {
src: el.getAttribute("data-full-src") || el.currentSrc || el.src,
alt: el.alt || "",
};
});
}
function show(i) {
if (!items.length) return;
index = (i + items.length) % items.length;
img.src = items[index].src;
img.alt = items[index].alt;
if (caption) caption.textContent = items[index].alt;
box.hidden = false;
document.body.classList.add("lightbox-open");
}
function close() {
box.hidden = true;
img.removeAttribute("src");
document.body.classList.remove("lightbox-open");
}
document.addEventListener("click", function (e) {
var target = e.target;
if (!(target instanceof HTMLImageElement)) return;
if (!target.classList.contains("lightboxable")) return;
e.preventDefault();
e.stopPropagation();
collect();
var full = target.getAttribute("data-full-src") || target.currentSrc || target.src;
var found = items.findIndex(function (it) {
return it.src === full;
});
show(found >= 0 ? found : 0);
});
box.addEventListener("click", function (e) {
if (e.target === box || (e.target && e.target.classList.contains("lightbox-close"))) {
close();
return;
}
if (e.target && e.target.classList.contains("lightbox-next")) {
show(index + 1);
}
if (e.target && e.target.classList.contains("lightbox-prev")) {
show(index - 1);
}
});
document.addEventListener("keydown", function (e) {
if (box.hidden) return;
if (e.key === "Escape") close();
if (e.key === "ArrowRight") show(index + 1);
if (e.key === "ArrowLeft") show(index - 1);
});
var startX = 0;
box.addEventListener(
"touchstart",
function (e) {
if (e.changedTouches && e.changedTouches[0]) startX = e.changedTouches[0].screenX;
},
{ passive: true }
);
box.addEventListener(
"touchend",
function (e) {
if (!e.changedTouches || !e.changedTouches[0]) return;
var dx = e.changedTouches[0].screenX - startX;
if (Math.abs(dx) < 40) return;
show(index + (dx < 0 ? 1 : -1));
},
{ passive: true }
);
}
startLivePoll();
startLightbox();
})();
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#14161a"/>
<rect x="10" y="14" width="44" height="36" rx="4" fill="#1d2025" stroke="#ff5a1f" stroke-width="3"/>
<circle cx="24" cy="28" r="5" fill="#ff5a1f"/>
<path d="M14 44l12-12 8 8 8-6 8 10H14z" fill="#ff5a1f"/>
</svg>

After

Width:  |  Height:  |  Size: 335 B

+94
View File
@@ -52,6 +52,17 @@ a:focus-visible {
.site-header { .site-header {
padding: 1rem var(--gap); padding: 1rem var(--gap);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.header-note {
color: var(--text-dim);
font-size: 0.8rem;
letter-spacing: 0.04em;
text-transform: uppercase;
} }
.brand { .brand {
@@ -147,6 +158,33 @@ h2 {
} }
} }
.job-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin: 0.5rem 0 1rem;
}
.job-heading h1 {
margin: 0;
}
.progress {
height: 4px;
background: var(--border);
border-radius: 999px;
overflow: hidden;
margin: -0.5rem 0 1rem;
}
.progress span {
display: block;
height: 100%;
background: var(--accent);
min-width: 0;
}
.job-card { .job-card {
background: var(--bg-elevated); background: var(--bg-elevated);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -154,6 +192,27 @@ h2 {
overflow: hidden; overflow: hidden;
} }
.job-card.is-live {
border-color: var(--pending);
animation: live-pulse 1.8s ease-in-out infinite;
}
@keyframes live-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(217, 164, 65, 0);
}
50% {
box-shadow: 0 0 0 3px rgba(217, 164, 65, 0.35);
}
}
@media (prefers-reduced-motion: reduce) {
.job-card.is-live {
animation: none;
}
}
.job-card a { .job-card a {
color: var(--text); color: var(--text);
display: block; display: block;
@@ -390,4 +449,39 @@ body.lightbox-open {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
min-height: 2.75rem; min-height: 2.75rem;
min-width: 2.75rem; min-width: 2.75rem;
z-index: 1;
}
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
border: 0;
background: transparent;
color: var(--text);
font-size: 2.4rem;
line-height: 1;
cursor: pointer;
padding: 0.5rem 0.75rem;
min-height: 2.75rem;
min-width: 2.75rem;
}
.lightbox-prev {
left: 0.25rem;
}
.lightbox-next {
right: 0.25rem;
}
.lightbox-caption {
position: absolute;
bottom: 0.75rem;
left: 1rem;
right: 1rem;
margin: 0;
text-align: center;
color: var(--text-dim);
font-size: 0.85rem;
} }
+11 -41
View File
@@ -3,60 +3,30 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#14161a">
<meta name="color-scheme" content="dark">
<title>{% block title %}{{ site_title }}{% endblock %}</title> <title>{% block title %}{{ site_title }}{% endblock %}</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body{% if live is defined and live %} data-live="1"{% endif %}{% if job_id is defined %} data-job-id="{{ job_id }}"{% endif %}{% if has_active is defined and has_active %} data-live="1"{% endif %}>
<header class="site-header"> <header class="site-header">
<a class="brand" href="/">{{ site_title }}</a> <a class="brand" href="/">{{ site_title }}</a>
<span class="header-note">Event-Pep</span>
</header> </header>
<main> <main>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<div id="lightbox" class="lightbox" hidden> <div id="lightbox" class="lightbox" hidden>
<button type="button" class="lightbox-close" aria-label="Schliessen">&times;</button> <button type="button" class="lightbox-close" aria-label="Schließen">&times;</button>
<button type="button" class="lightbox-nav lightbox-prev" aria-label="Vorheriges Bild">‹</button>
<img id="lightbox-img" alt=""> <img id="lightbox-img" alt="">
<button type="button" class="lightbox-nav lightbox-next" aria-label="Nächstes Bild">›</button>
<p id="lightbox-caption" class="lightbox-caption"></p>
</div> </div>
<script> <script src="/static/app.js" defer></script>
(function () {
var box = document.getElementById("lightbox");
var img = document.getElementById("lightbox-img");
if (!box || !img) return;
function openLightbox(src, alt) {
img.src = src;
img.alt = alt || "";
box.hidden = false;
document.body.classList.add("lightbox-open");
}
function closeLightbox() {
box.hidden = true;
img.removeAttribute("src");
document.body.classList.remove("lightbox-open");
}
document.addEventListener("click", function (e) {
var target = e.target;
if (!(target instanceof HTMLImageElement)) return;
if (!target.classList.contains("lightboxable")) return;
e.preventDefault();
e.stopPropagation();
openLightbox(target.currentSrc || target.src, target.alt);
});
box.addEventListener("click", function (e) {
if (e.target === box || (e.target && e.target.classList.contains("lightbox-close"))) {
closeLightbox();
}
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !box.hidden) closeLightbox();
});
})();
</script>
</body> </body>
</html> </html>
+19 -5
View File
@@ -2,20 +2,34 @@
{% block title %}{{ site_title }}{% endblock %} {% block title %}{{ site_title }}{% endblock %}
{% block content %} {% block content %}
{% if not jobs %} {% if not jobs %}
<p class="empty-state">Noch keine Jobs. Bild in den Sync-Ordner legen, dann kurz warten.</p> <p class="empty-state">Noch keine Jobs. Foto in der App teilen, dann kurz warten.</p>
{% else %} {% else %}
<ul class="job-grid"> <ul class="job-grid">
{% for job in jobs %} {% for job in jobs %}
<li class="job-card"> <li class="job-card{% if job.status in ACTIVE_STATUSES %} is-live{% endif %}">
<a href="/jobs/{{ job.job_id }}"> <a href="/jobs/{{ job.job_id }}">
{% if job.thumbnail %} {% if job.thumbnail %}
<img class="thumb lightboxable" src="/jobs/{{ job.job_id }}/files/{{ job.thumbnail }}" alt="Variante von Job {{ job.job_id }}" loading="lazy"> {% set img = img_attrs(job.job_id, job.thumbnail) %}
<img class="thumb"
src="{{ img.src }}"
srcset="{{ img.srcset }}"
sizes="{{ img.sizes }}"
alt="Variante von Job {{ job.job_id }}"
loading="lazy"
decoding="async">
{% else %} {% else %}
<span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status }}</span> <span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status_label }}</span>
{% endif %} {% endif %}
<span class="job-meta"> <span class="job-meta">
<span class="job-id">{{ job.job_id }}</span> <span class="job-id">{{ job.job_id }}</span>
<span class="job-status status-{{ job.status }}">{{ job.status }} &middot; {{ job.variant_count }} Varianten</span> <span class="job-status status-{{ job.status }}">
{{ job.status_label }}
{% if job.stage_label and job.status in ACTIVE_STATUSES %}
&middot; {{ job.stage_label }}
{% else %}
&middot; {{ job.variant_count }} Varianten
{% endif %}
</span>
</span> </span>
</a> </a>
</li> </li>
+51 -5
View File
@@ -3,8 +3,20 @@
{% block content %} {% block content %}
<p class="back-link"><a href="/">&larr; Alle Jobs</a></p> <p class="back-link"><a href="/">&larr; Alle Jobs</a></p>
<div class="job-heading">
<h1>Job {{ job_id }}</h1> <h1>Job {{ job_id }}</h1>
<p class="job-status status-{{ status.status }}">Status: {{ status.status or "unbekannt" }}</p> <p class="job-status status-{{ status.status }}">
{{ status_label(status.status) }}
{% if status.status in ACTIVE_STATUSES %}
&middot; {{ stage_label(status.status, status.stage, status.progress) }}
{% endif %}
</p>
</div>
{% if status.status in ACTIVE_STATUSES and status.progress and status.progress.total %}
<div class="progress" aria-hidden="true">
<span style="width: {{ (status.progress.current / status.progress.total * 100) | int }}%"></span>
</div>
{% endif %}
{% if status.error %} {% if status.error %}
<p class="error-box">Fehler: {{ status.error }}</p> <p class="error-box">Fehler: {{ status.error }}</p>
{% endif %} {% endif %}
@@ -13,17 +25,35 @@
<h2>Original &amp; Rembg</h2> <h2>Original &amp; Rembg</h2>
<div class="pair-grid"> <div class="pair-grid">
{% if original_name %} {% if original_name %}
{% set img = img_attrs(job_id, original_name, sizes=PAIR_SIZES) %}
<figure> <figure>
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ original_name }}" alt="Original" loading="lazy"> <img class="lightboxable"
src="{{ img.src }}"
srcset="{{ img.srcset }}"
sizes="{{ img.sizes }}"
data-full-src="{{ img.full_src }}"
alt="Original"
loading="lazy"
decoding="async">
<figcaption> <figcaption>
Original &middot; Original &middot;
<a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a> <a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a>
</figcaption> </figcaption>
</figure> </figure>
{% else %}
<p class="empty-state">Original kommt, sobald der Worker greift.</p>
{% endif %} {% endif %}
{% if has_rembg and rembg_name %} {% if has_rembg and rembg_name %}
{% set img = img_attrs(job_id, rembg_name, sizes=PAIR_SIZES) %}
<figure> <figure>
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ rembg_name }}" alt="Freigestellt (rembg)" loading="lazy"> <img class="lightboxable"
src="{{ img.src }}"
srcset="{{ img.srcset }}"
sizes="{{ img.sizes }}"
data-full-src="{{ img.full_src }}"
alt="Freigestellt (rembg)"
loading="lazy"
decoding="async">
<figcaption> <figcaption>
Rembg &middot; Rembg &middot;
<a href="/jobs/{{ job_id }}/files/{{ rembg_name }}" download>Download</a> <a href="/jobs/{{ job_id }}/files/{{ rembg_name }}" download>Download</a>
@@ -45,8 +75,16 @@
{% else %} {% else %}
<ul class="variant-grid"> <ul class="variant-grid">
{% for v in manifest.variants %} {% for v in manifest.variants %}
{% set img = img_attrs(job_id, v.file) %}
<li class="variant-card"> <li class="variant-card">
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ v.file }}" alt="Variante {{ v.id }}" loading="lazy"> <img class="lightboxable"
src="{{ img.src }}"
srcset="{{ img.srcset }}"
sizes="{{ img.sizes }}"
data-full-src="{{ img.full_src }}"
alt="Variante {{ v.id }}"
loading="lazy"
decoding="async">
<div class="variant-meta"> <div class="variant-meta">
<strong>{{ v.id }}</strong> <span class="tag">{{ v.source }}</span> <strong>{{ v.id }}</strong> <span class="tag">{{ v.source }}</span>
<dl> <dl>
@@ -72,8 +110,16 @@
<h2>Zwischenschritte</h2> <h2>Zwischenschritte</h2>
<ul class="intermediate-grid"> <ul class="intermediate-grid">
{% for name in intermediates %} {% for name in intermediates %}
{% set img = img_attrs(job_id, name) %}
<li> <li>
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ name }}" alt="{{ name }}" loading="lazy"> <img class="lightboxable"
src="{{ img.src }}"
srcset="{{ img.srcset }}"
sizes="{{ img.sizes }}"
data-full-src="{{ img.full_src }}"
alt="{{ name }}"
loading="lazy"
decoding="async">
<span>{{ name }}</span> <span>{{ name }}</span>
</li> </li>
{% endfor %} {% endfor %}
+1 -1
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Remix {{ job_id }} — {{ site_title }}{% endblock %} {% block title %}Remix {{ job_id }} — {{ site_title }}{% endblock %}
{% block content %} {% block content %}
<p class="back-link"><a href="/jobs/{{ job_id }}">&larr; Zurueck zum Job</a></p> <p class="back-link"><a href="/jobs/{{ job_id }}">&larr; Zurück zum Job</a></p>
<h1>Remix &middot; Job {{ job_id }}</h1> <h1>Remix &middot; Job {{ job_id }}</h1>
{% if from_variant %} {% if from_variant %}
+8 -14
View File
@@ -13,7 +13,6 @@ pipeline is CPU heavy (gmic/rembg) and the worker container is capped at
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import shutil import shutil
import time import time
@@ -21,11 +20,10 @@ from pathlib import Path
from typing import Any from typing import Any
from . import config, pipeline from . import config, pipeline
from .io_utils import read_json, write_json_atomic
from .logging_config import configure_logging
logging.basicConfig( configure_logging()
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logger = logging.getLogger("livef12.worker") logger = logging.getLogger("livef12.worker")
@@ -42,17 +40,15 @@ def ensure_dirs() -> None:
def load_processed() -> dict[str, Any]: def load_processed() -> dict[str, Any]:
if not config.PROCESSED_FILE.exists(): if not config.PROCESSED_FILE.exists():
return {} return {}
try: data = read_json(config.PROCESSED_FILE)
return json.loads(config.PROCESSED_FILE.read_text(encoding="utf-8")) if isinstance(data, dict):
except (OSError, ValueError): return data
logger.warning("processed.json unreadable, starting fresh") logger.warning("processed.json unreadable, starting fresh")
return {} return {}
def save_processed(processed: dict[str, Any]) -> None: def save_processed(processed: dict[str, Any]) -> None:
tmp = config.PROCESSED_FILE.with_suffix(".json.tmp") write_json_atomic(config.PROCESSED_FILE, processed)
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: def _file_key(path: Path) -> str:
@@ -70,9 +66,7 @@ def _is_ignored_name(name: str) -> bool:
"""Skip Syncthing metadata/temp files and other dotfiles.""" """Skip Syncthing metadata/temp files and other dotfiles."""
if name.startswith("."): if name.startswith("."):
return True return True
if name.startswith(".syncthing.") or ".syncthing." in name: return bool(name.startswith(".syncthing.") or ".syncthing." in name)
return True
return False
def _is_stable(path: Path) -> bool: def _is_stable(path: Path) -> bool:
-30
View File
@@ -1,30 +0,0 @@
# Background-removal comparison
Inbox originals from `live.f12.rocks` vs. backends.
## Layout
- `inputs/` — originals from boka inbox
- `outputs/` — results, naming:
```
<stem>__rembg-u2net__no-alpha.png
<stem>__rembg-u2net__alpha.png
<stem>__rembg-default__no-alpha.png
<stem>__rembg-default__alpha.png
<stem>__rembg-birefnet-general__no-alpha.png
<stem>__rembg-birefnet-general__alpha.png
<stem>__withoutbg-open-weights.png
```
- `rembg-default` = `rembg.remove()` without session (library default = u2net)
- `rembg-u2net` = explicit `new_session("u2net")` — expect identical to default
- `withoutbg` open-weights includes matting in-graph (~2GB RAM claimed)
## Re-run
```bash
./run.sh
```
Skips existing non-empty outputs. Models cached in `model-cache/`.
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
# Run rembg/withoutbg comparison in Python 3.11 (host is 3.14-only).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
IMG="livef12-bg-compare:py311"
LOG="$ROOT/compare.log"
docker build -t "$IMG" - <<'EOF'
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir 'rembg>=2.0.59,<2.1' 'pillow>=10.4,<11' \
'onnxruntime>=1.19,<1.20' withoutbg
WORKDIR /work
EOF
echo "Starting comparison; log: $LOG"
docker run --rm \
--name livef12-bg-compare \
-e TQDM_DISABLE=1 \
-e COMPARE_INPUT=/data/inputs \
-e COMPARE_OUTPUT=/data/outputs \
-e HOME=/cache \
-v "$ROOT/inputs:/data/inputs:ro" \
-v "$ROOT/outputs:/data/outputs" \
-v "$ROOT/run_compare.py:/work/run_compare.py:ro" \
-v "$ROOT/model-cache:/cache" \
"$IMG" python -u /work/run_compare.py 2>&1 | tee "$LOG"
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""Compare background-removal backends on the live.f12.rocks inbox uploads.
Outputs land next to each other with explicit names so Fränky can pick a winner:
<stem>__rembg-u2net__no-alpha.png
<stem>__rembg-u2net__alpha.png
<stem>__rembg-default__no-alpha.png
<stem>__rembg-default__alpha.png
<stem>__rembg-birefnet-general__no-alpha.png
<stem>__rembg-birefnet-general__alpha.png
<stem>__withoutbg-open-weights.png
`rembg-default` = rembg.remove() with no session (library default model).
`rembg-u2net` = rembg.new_session("u2net") explicitly.
withoutbg embeds matting in its open-weights graph — one output only.
"""
from __future__ import annotations
import os
import sys
import time
import traceback
from pathlib import Path
os.environ.setdefault("TQDM_DISABLE", "1")
INPUT_DIR = Path(os.environ.get("COMPARE_INPUT", "/data/inputs"))
OUTPUT_DIR = Path(os.environ.get("COMPARE_OUTPUT", "/data/outputs"))
EXTS = {".jpg", ".jpeg", ".png", ".webp"}
def log(msg: str) -> None:
print(msg, flush=True)
def run_rembg_named(data: bytes, label: str, model: str | None, alpha: bool, out: Path) -> None:
from rembg import new_session, remove
t0 = time.monotonic()
kwargs: dict = {"alpha_matting": alpha}
if model is None:
result = remove(data, **kwargs)
model_note = "default"
else:
session = new_session(model)
result = remove(data, session=session, **kwargs)
model_note = model
out.write_bytes(result)
log(f" OK {label} model={model_note} alpha={alpha} {time.monotonic()-t0:.1f}s -> {out.name} ({out.stat().st_size} bytes)")
def run_withoutbg(path: Path, out: Path) -> None:
from withoutbg import WithoutBG
t0 = time.monotonic()
model = WithoutBG.open_weights()
result = model.remove_background(str(path))
result.save(out)
log(f" OK withoutbg-open-weights {time.monotonic()-t0:.1f}s -> {out.name} ({out.stat().st_size} bytes)")
def process_one(path: Path) -> None:
stem = path.stem
log(f"=== {path.name} ===")
data = path.read_bytes()
jobs = [
("rembg-u2net", "u2net", False),
("rembg-u2net", "u2net", True),
("rembg-default", None, False),
("rembg-default", None, True),
("rembg-birefnet-general", "birefnet-general", False),
("rembg-birefnet-general", "birefnet-general", True),
]
for label, model, alpha in jobs:
tag = "alpha" if alpha else "no-alpha"
out = OUTPUT_DIR / f"{stem}__{label}__{tag}.png"
if out.exists() and out.stat().st_size > 0:
log(f" SKIP {out.name} (exists)")
continue
try:
run_rembg_named(data, label, model, alpha, out)
except Exception as exc: # noqa: BLE001
err = OUTPUT_DIR / f"{stem}__{label}__{tag}.ERROR.txt"
err.write_text(f"{type(exc).__name__}: {exc}\n\n{traceback.format_exc()}", encoding="utf-8")
log(f" FAIL {label} alpha={alpha}: {type(exc).__name__}: {exc}")
out_w = OUTPUT_DIR / f"{stem}__withoutbg-open-weights.png"
if out_w.exists() and out_w.stat().st_size > 0:
log(f" SKIP {out_w.name} (exists)")
else:
try:
run_withoutbg(path, out_w)
except Exception as exc: # noqa: BLE001
err = OUTPUT_DIR / f"{stem}__withoutbg-open-weights.ERROR.txt"
err.write_text(f"{type(exc).__name__}: {exc}\n\n{traceback.format_exc()}", encoding="utf-8")
log(f" FAIL withoutbg: {type(exc).__name__}: {exc}")
def main() -> int:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
inputs = sorted(p for p in INPUT_DIR.iterdir() if p.is_file() and p.suffix.lower() in EXTS)
if not inputs:
log(f"No images in {INPUT_DIR}")
return 1
log(f"Comparing {len(inputs)} image(s); outputs -> {OUTPUT_DIR}")
for path in inputs:
process_one(path)
log("DONE")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+18 -25
View File
@@ -1,21 +1,14 @@
# live.f12.rocks — event photo pipeline: Syncthing drop -> gmic/rembg -> web. # live.f12.rocks — event photo pipeline: Android upload -> gmic/rembg -> web.
# #
# worker + web share one host directory (Syncthing share root): # worker + web share one host directory:
# ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12} -> /data # ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12} -> /data
# worker watches /data/incoming, writes /data/variants + /data/intermediates # worker watches /data/incoming, writes /data/variants + /data/intermediates
# web reads meta/ + image dirs, writes remix variants # web serves HTML + /api (POST /api/jobs needs UPLOAD_TOKEN)
# #
# Syncthing itself runs on the host (not in this compose). Copy .env.example # Copy .env.example to .env for local overrides (DATA_HOST_DIR=./data,
# to .env for local overrides (e.g. DATA_HOST_DIR=./data). Never commit .env. # UPLOAD_TOKEN=...). Never commit .env.
services: x-app-env: &app-env
worker:
build: .
restart: unless-stopped
# Same UID as Syncthing (PUID=1002) so shared /data stays writable.
user: "1002:1002"
command: ["python3", "-m", "app.worker"]
environment:
DATA_DIR: /data DATA_DIR: /data
OUTPUT_COUNT: ${OUTPUT_COUNT:-3} OUTPUT_COUNT: ${OUTPUT_COUNT:-3}
BLEND_OPACITY: ${BLEND_OPACITY:-30%} BLEND_OPACITY: ${BLEND_OPACITY:-30%}
@@ -28,6 +21,16 @@ services:
REMBG_MODEL: ${REMBG_MODEL:-u2net} REMBG_MODEL: ${REMBG_MODEL:-u2net}
REMBG_ALPHA: ${REMBG_ALPHA:-1} REMBG_ALPHA: ${REMBG_ALPHA:-1}
NICE_LEVEL: ${NICE_LEVEL:-18} NICE_LEVEL: ${NICE_LEVEL:-18}
services:
worker:
build: .
restart: unless-stopped
# Same UID as Syncthing (PUID=1002) so shared /data stays writable.
user: "1002:1002"
command: ["python3", "-m", "app.worker"]
environment:
<<: *app-env
volumes: volumes:
- ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data - ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data
- ./assets:/data/assets:ro - ./assets:/data/assets:ro
@@ -48,19 +51,9 @@ services:
depends_on: depends_on:
- worker - worker
environment: environment:
DATA_DIR: /data <<: *app-env
OUTPUT_COUNT: ${OUTPUT_COUNT:-3}
BLEND_OPACITY: ${BLEND_OPACITY:-30%}
BLEND_OPACITY_MIN: ${BLEND_OPACITY_MIN:-10}
BLEND_OPACITY_MAX: ${BLEND_OPACITY_MAX:-50}
FILTER_TIMEOUT: ${FILTER_TIMEOUT:-120}
FILTER_TIMEOUT_LONG: ${FILTER_TIMEOUT_LONG:-300}
MAX_FILTER_ATTEMPTS: ${MAX_FILTER_ATTEMPTS:-8}
MAX_EDGE_PX: ${MAX_EDGE_PX:-2000}
REMBG_MODEL: ${REMBG_MODEL:-u2net}
REMBG_ALPHA: ${REMBG_ALPHA:-1}
NICE_LEVEL: ${NICE_LEVEL:-18}
SITE_TITLE: live.f12.rocks SITE_TITLE: live.f12.rocks
UPLOAD_TOKEN: ${UPLOAD_TOKEN:-}
volumes: volumes:
- ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data - ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data
- ./assets:/data/assets:ro - ./assets:/data/assets:ro
+41
View File
@@ -0,0 +1,41 @@
[project]
name = "livef12rocks"
version = "0.1.0"
description = "Event photo pep pipeline: Android upload -> gmic/rembg -> web"
readme = "README.md"
requires-python = ">=3.11,<3.12"
[project.optional-dependencies]
dev = [
"pytest>=8.3,<9",
"httpx>=0.27,<0.29",
"ruff>=0.8,<0.10",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
[tool.ruff]
target-version = "py311"
line-length = 120
src = ["app", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
]
ignore = [
"B008", # FastAPI Depends/Form in defaults is intentional
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"] # assert in tests
[tool.ruff.format]
quote-style = "double"
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
pytest>=8.3,<9
httpx>=0.27,<0.29
ruff>=0.8,<0.10
+28
View File
@@ -0,0 +1,28 @@
"""Shared test fixtures."""
from __future__ import annotations
from pathlib import Path
import pytest
@pytest.fixture
def data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point config path constants at a temporary data directory."""
variants = tmp_path / "variants"
intermediates = tmp_path / "intermediates"
meta = tmp_path / "meta"
inbox = tmp_path / "incoming"
webcache = tmp_path / "webcache"
for directory in (variants, intermediates, meta, inbox, webcache):
directory.mkdir()
monkeypatch.setattr("app.config.DATA_DIR", tmp_path)
monkeypatch.setattr("app.config.VARIANTS_DIR", variants)
monkeypatch.setattr("app.config.INTERMEDIATES_DIR", intermediates)
monkeypatch.setattr("app.config.META_DIR", meta)
monkeypatch.setattr("app.config.INBOX_DIR", inbox)
monkeypatch.setattr("app.config.WEB_CACHE_DIR", webcache)
monkeypatch.setattr("app.config.PROCESSED_FILE", tmp_path / "processed.json")
return tmp_path
+135
View File
@@ -0,0 +1,135 @@
"""Tests for JSON API upload and job payloads."""
from __future__ import annotations
from pathlib import Path
import pytest
from app import config, pipeline
from app.main import app
from fastapi.testclient import TestClient
from PIL import Image
@pytest.fixture
def client(data_dir: Path) -> TestClient:
return TestClient(app)
@pytest.fixture
def upload_token(monkeypatch: pytest.MonkeyPatch) -> str:
token = "test-upload-token"
monkeypatch.setattr(config, "UPLOAD_TOKEN", token)
return token
def _jpeg_bytes(size: tuple[int, int] = (80, 60)) -> bytes:
from io import BytesIO
buf = BytesIO()
Image.new("RGB", size, color=(12, 34, 56)).save(buf, format="JPEG", quality=85)
return buf.getvalue()
def test_health_reports_upload_disabled(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "UPLOAD_TOKEN", "")
response = client.get("/api/health")
assert response.status_code == 200
assert response.json() == {"ok": True, "upload": False}
def test_upload_disabled_without_token(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(config, "UPLOAD_TOKEN", "")
response = client.post(
"/api/jobs",
files={"file": ("party.jpg", _jpeg_bytes(), "image/jpeg")},
)
assert response.status_code == 503
def test_upload_rejects_bad_token(client: TestClient, upload_token: str) -> None:
response = client.post(
"/api/jobs",
files={"file": ("party.jpg", _jpeg_bytes(), "image/jpeg")},
headers={"Authorization": "Bearer nope"},
)
assert response.status_code == 401
def test_upload_accepts_bearer_and_queues(client: TestClient, upload_token: str, data_dir: Path) -> None:
response = client.post(
"/api/jobs",
files={"file": ("party.jpg", _jpeg_bytes(), "image/jpeg")},
headers={"Authorization": f"Bearer {upload_token}"},
)
assert response.status_code == 202
body = response.json()
assert body["status"] == "queued"
job_id = body["job_id"]
assert job_id.endswith("_party")
inbox = list(config.INBOX_DIR.glob("*_party.jpg"))
assert len(inbox) == 1
assert not inbox[0].name.startswith(".")
meta = pipeline.read_meta(job_id)
assert meta is not None
assert meta["status"] == "queued"
def test_upload_accepts_x_upload_token_header(client: TestClient, upload_token: str) -> None:
response = client.post(
"/api/jobs",
files={"file": ("cam.png", _jpeg_bytes(), "image/png")},
headers={"X-Upload-Token": upload_token},
)
assert response.status_code == 202
assert response.json()["status"] == "queued"
def test_upload_rejects_empty_file(client: TestClient, upload_token: str) -> None:
response = client.post(
"/api/jobs",
files={"file": ("empty.jpg", b"", "image/jpeg")},
headers={"Authorization": f"Bearer {upload_token}"},
)
assert response.status_code == 400
def test_api_job_detail_and_list(client: TestClient, data_dir: Path) -> None:
stem = "photo01"
paths = pipeline.job_paths(stem)
paths.variants.mkdir(parents=True, exist_ok=True)
png = paths.variants / f"{stem}_v1.png"
Image.new("RGB", (32, 32), color=(1, 2, 3)).save(png, format="PNG")
pipeline.write_status(
paths,
"processing",
stage="variant",
progress={"current": 2, "total": 3},
created_at="2026-01-01T00:00:00",
)
pipeline.write_manifest(
paths,
{
"variants": [{"id": "v1", "file": png.name}],
"created_at": "2026-01-01T00:00:00",
},
)
listed = client.get("/api/jobs")
assert listed.status_code == 200
payload = listed.json()
assert payload["active"] is True
assert payload["jobs"][0]["job_id"] == stem
assert payload["jobs"][0]["status_label"] == "Läuft"
assert payload["jobs"][0]["stage_label"] == "Variante 2/3"
detail = client.get(f"/api/jobs/{stem}")
assert detail.status_code == 200
body = detail.json()
assert body["has_rembg"] is False
assert body["variants"][0]["url"].endswith(f"/jobs/{stem}/files/{png.name}")
assert body["progress"] == {"current": 2, "total": 3}
def test_api_job_missing(client: TestClient) -> None:
assert client.get("/api/jobs/does-not-exist").status_code == 404
+30
View File
@@ -0,0 +1,30 @@
"""Tests for config helpers."""
from __future__ import annotations
from app import config
from app.config import _float_env, _int_env
def test_int_env_valid(monkeypatch) -> None:
monkeypatch.setenv("TEST_INT", "42")
assert _int_env("TEST_INT", 0) == 42
def test_int_env_invalid_falls_back(monkeypatch) -> None:
monkeypatch.setenv("TEST_INT", "nope")
assert _int_env("TEST_INT", 7) == 7
def test_float_env_valid(monkeypatch) -> None:
monkeypatch.setenv("TEST_FLOAT", "2.5")
assert _float_env("TEST_FLOAT", 0.0) == 2.5
def test_supported_extensions() -> None:
assert ".jpg" in config.SUPPORTED_EXTENSIONS
assert ".jpeg" in config.SUPPORTED_EXTENSIONS
def test_opacity_choices_percentages() -> None:
assert all(choice.endswith("%") for choice in config.OPACITY_CHOICES)
+22
View File
@@ -0,0 +1,22 @@
"""Tests for env_utils."""
from __future__ import annotations
from app.env_utils import env_bool
def test_env_bool_default(monkeypatch) -> None:
monkeypatch.delenv("TEST_FLAG", raising=False)
assert env_bool("TEST_FLAG", True) is True
assert env_bool("TEST_FLAG", False) is False
def test_env_bool_truthy(monkeypatch) -> None:
for value in ("1", "true", "TRUE", "yes", "on"):
monkeypatch.setenv("TEST_FLAG", value)
assert env_bool("TEST_FLAG", False) is True
def test_env_bool_falsy(monkeypatch) -> None:
monkeypatch.setenv("TEST_FLAG", "0")
assert env_bool("TEST_FLAG", True) is False
+44
View File
@@ -0,0 +1,44 @@
"""Tests for io_utils."""
from __future__ import annotations
import json
from pathlib import Path
from app.io_utils import read_json, write_json_atomic
def test_read_json_missing_returns_default(tmp_path: Path) -> None:
assert read_json(tmp_path / "missing.json", default={}) == {}
def test_read_json_corrupt_returns_default(tmp_path: Path) -> None:
path = tmp_path / "bad.json"
path.write_text("not json", encoding="utf-8")
assert read_json(path, default=None) is None
def test_write_json_atomic_roundtrip(tmp_path: Path) -> None:
path = tmp_path / "data.json"
payload = {"job_id": "photo", "variants": []}
write_json_atomic(path, payload)
assert json.loads(path.read_text(encoding="utf-8")) == payload
assert not path.with_suffix(path.suffix + ".tmp").exists()
def test_file_lock_exclusive(tmp_path: Path) -> None:
from concurrent.futures import ThreadPoolExecutor
from app.io_utils import file_lock
lock_path = tmp_path / "job.lock"
counter = {"n": 0}
def bump() -> None:
with file_lock(lock_path):
current = counter["n"]
counter["n"] = current + 1
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(lambda _: bump(), range(40)))
assert counter["n"] == 40
+54
View File
@@ -0,0 +1,54 @@
"""Tests for job helpers (labels, inbox names, payloads)."""
from __future__ import annotations
from pathlib import Path
import pytest
from app import jobs, pipeline
def test_status_label_known_and_passthrough() -> None:
assert jobs.status_label("done") == "Fertig"
assert jobs.status_label("queued") == "Warteschlange"
assert jobs.status_label("weird") == "weird"
def test_stage_label_variant_progress() -> None:
label = jobs.stage_label("processing", "variant", {"current": 1, "total": 3})
assert label == "Variante 1/3"
assert jobs.stage_label("queued", None, None) == "Wartet auf Worker"
assert jobs.stage_label("processing", "rembg", None) == "Freistellen"
def test_make_inbox_filename_unique_and_safe() -> None:
a = jobs.make_inbox_filename("party.jpg")
b = jobs.make_inbox_filename("party.jpg")
assert a != b
assert a.endswith("_party.jpg")
assert "/" not in a
jpeg = jobs.make_inbox_filename("IMG_0001.JPEG")
assert jpeg.lower().endswith(".jpg")
fallback = jobs.make_inbox_filename("noext")
assert fallback.endswith("_noext.jpg")
def test_validate_job_id_rejects_paths() -> None:
with pytest.raises(jobs.JobIdError):
jobs.validate_job_id("../evil")
assert jobs.validate_job_id("foo@bar") == "foo_bar"
def test_summarize_job_includes_labels(data_dir: Path) -> None:
paths = pipeline.job_paths("event01")
pipeline.write_status(
paths,
"processing",
stage="rembg",
progress={"current": 0, "total": 3},
created_at="2026-01-01T00:00:00",
)
summary = jobs.summarize_job("event01")
assert summary["status_label"] == "Läuft"
assert summary["stage_label"] == "Freistellen"
assert summary["variant_count"] == 0
+106
View File
@@ -0,0 +1,106 @@
"""Tests for FastAPI routes and validation."""
from __future__ import annotations
from pathlib import Path
import pytest
from app import config, images, pipeline
from app.main import _safe_job_file, _validate_job_id, app
from fastapi import HTTPException
from fastapi.testclient import TestClient
from PIL import Image
@pytest.fixture
def client(data_dir: Path) -> TestClient:
return TestClient(app)
def _write_test_png(path: Path, size: tuple[int, int] = (1200, 800)) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", size, color=(40, 80, 120)).save(path, format="PNG")
def test_validate_job_id_sanitizes_input() -> None:
assert _validate_job_id("foo@bar") == "foo_bar"
assert _validate_job_id("photo01") == "photo01"
def test_validate_job_id_rejects_path_like() -> None:
with pytest.raises(HTTPException) as exc:
_validate_job_id("a/b")
assert exc.value.status_code == 400
with pytest.raises(HTTPException):
_validate_job_id("../evil")
with pytest.raises(HTTPException):
_validate_job_id("")
def test_safe_job_file_rejects_wrong_prefix(data_dir: Path) -> None:
stem = "photo01"
paths = pipeline.job_paths(stem)
paths.variants.mkdir(parents=True, exist_ok=True)
target = paths.variants / f"{stem}_v1.png"
target.write_bytes(b"x")
with pytest.raises(HTTPException):
_safe_job_file(stem, "other_v1.png")
def test_index_empty(client: TestClient) -> None:
response = client.get("/")
assert response.status_code == 200
assert config.SITE_TITLE in response.text
def test_job_not_found(client: TestClient) -> None:
response = client.get("/jobs/does-not-exist")
assert response.status_code == 404
def test_job_file_srcset_resize(client: TestClient, data_dir: Path) -> None:
stem = "photo01"
paths = pipeline.job_paths(stem)
png = paths.variants / f"{stem}_v1.png"
_write_test_png(png)
pipeline.write_status(paths, "done", created_at="2026-01-01T00:00:00")
pipeline.write_manifest(
paths,
{
"variants": [{"id": "v1", "file": png.name}],
"created_at": "2026-01-01T00:00:00",
},
)
full = client.get(f"/jobs/{stem}/files/{png.name}")
assert full.status_code == 200
assert full.headers["content-type"].startswith("image/")
bad = client.get(f"/jobs/{stem}/files/{png.name}?w=999")
assert bad.status_code == 400
resized = client.get(f"/jobs/{stem}/files/{png.name}?w=320")
assert resized.status_code == 200
assert resized.headers["content-type"] == "image/webp"
assert len(resized.content) < len(full.content)
cached = config.WEB_CACHE_DIR / f"{png.name}.w320.webp"
assert cached.is_file()
with Image.open(cached) as img:
assert img.width == 320
index = client.get("/")
assert index.status_code == 200
assert f"/jobs/{stem}/files/{png.name}?w=640" in index.text
assert "srcset=" in index.text
detail = client.get(f"/jobs/{stem}")
assert detail.status_code == 200
assert "data-full-src=" in detail.text
def test_img_attrs_builds_srcset() -> None:
attrs = images.img_attrs("job1", "job1_v1.png")
assert attrs["src"].endswith("?w=640")
assert "320w" in attrs["srcset"]
assert attrs["full_src"].endswith("?w=1280")
+134
View File
@@ -0,0 +1,134 @@
"""Tests for pipeline pure helpers and meta I/O."""
from __future__ import annotations
from pathlib import Path
import pytest
from app import pipeline
@pytest.mark.parametrize(
("raw", "expected"),
[
("photo.jpg", "photo"),
("../evil", "evil"),
("a/b", "a_b"),
("", "photo"),
(" spaced ", "spaced"),
("foo@bar", "foo_bar"),
],
)
def test_sanitize_stem(raw: str, expected: str) -> None:
assert pipeline.sanitize_stem(raw) == expected
def test_variant_filename() -> None:
assert pipeline.variant_filename("photo", "v1") == "photo_v1.png"
@pytest.mark.parametrize(
("command", "expected_tail"),
[
("fx_blur 3", ["fx_blur", "3"]),
("normalize", ["normalize"]),
],
)
def test_gmic_filter_args(tmp_path: Path, command: str, expected_tail: list[str]) -> None:
image = tmp_path / "in.png"
image.touch()
args = pipeline.gmic_filter_args(image, command)
assert args[0] == str(image)
assert args[1:] == expected_tail
def test_next_variant_id() -> None:
manifest = {"variants": [{"id": "v1"}, {"id": "v2"}]}
assert pipeline.next_variant_id(manifest) == "v3"
def test_next_variant_id_after_gap() -> None:
manifest = {"variants": [{"id": "v1"}, {"id": "v3"}]}
assert pipeline.next_variant_id(manifest) == "v4"
def test_strip_ansi() -> None:
assert pipeline.strip_ansi("\x1b[31merror\x1b[0m") == "error"
def test_read_write_meta_roundtrip(data_dir: Path) -> None:
paths = pipeline.job_paths("event01")
pipeline.write_status(paths, "processing", source_file="event01.jpg")
meta = pipeline.read_meta("event01")
assert meta is not None
assert meta["status"] == "processing"
assert meta["source_file"] == "event01.jpg"
status = pipeline.read_status("event01")
assert status is not None
assert status["status"] == "processing"
manifest = pipeline.read_manifest("event01")
assert manifest is not None
assert manifest["variants"] == []
def test_list_job_ids(data_dir: Path) -> None:
paths = pipeline.job_paths("a")
pipeline.write_status(paths, "done")
paths = pipeline.job_paths("b")
pipeline.write_status(paths, "processing")
assert sorted(pipeline.list_job_ids()) == ["a", "b"]
def test_parallel_manifest_merge_keeps_all_variants(data_dir: Path) -> None:
"""Concurrent write_manifest callers must not clobber each other's variants."""
from concurrent.futures import ThreadPoolExecutor
paths = pipeline.job_paths("race")
pipeline.write_status(paths, "processing")
pipeline.write_manifest(
paths,
{
"source_stem": "race",
"original_file": "race_original.jpg",
"rembg_file": "race_rembg.png",
"created_at": pipeline.now_iso(),
"variants": [],
},
)
def write_one(i: int) -> None:
pipeline.write_manifest(
paths,
{
"variants": [{"id": f"v{i}", "file": f"race_v{i}.png"}],
},
)
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(write_one, range(20)))
meta = pipeline.read_meta("race")
assert meta is not None
ids = {v["id"] for v in meta["variants"]}
assert ids == {f"v{i}" for i in range(20)}
assert meta["original_file"] == "race_original.jpg"
def test_filter_timeout_is_thread_local() -> None:
import threading
results: dict[str, int] = {}
def worker(name: str, value: int) -> None:
with pipeline.filter_timeout(value):
results[name] = pipeline._active_filter_timeout()
t1 = threading.Thread(target=worker, args=("a", 11))
t2 = threading.Thread(target=worker, args=("b", 22))
t1.start()
t2.start()
t1.join()
t2.join()
assert results == {"a": 11, "b": 22}
+46
View File
@@ -0,0 +1,46 @@
"""Tests for worker inbox helpers."""
from __future__ import annotations
from pathlib import Path
from app import config
from app.worker import _file_key, _is_ignored_name, find_new_files, load_processed, save_processed
def test_is_ignored_name() -> None:
assert _is_ignored_name(".stfolder") is True
assert _is_ignored_name(".syncthing.tmp") is True
assert _is_ignored_name("photo.jpg") is False
def test_find_new_files_skips_processed(data_dir: Path) -> None:
photo = config.INBOX_DIR / "party.jpg"
photo.write_bytes(b"fake")
stat = photo.stat()
processed = {
_file_key(photo): {
"size": stat.st_size,
"mtime": stat.st_mtime,
}
}
assert find_new_files(processed) == []
def test_find_new_files_picks_supported(data_dir: Path) -> None:
(config.INBOX_DIR / "party.jpg").write_bytes(b"fake")
(config.INBOX_DIR / "notes.txt").write_text("nope", encoding="utf-8")
assert len(find_new_files({})) == 1
def test_load_processed_corrupt_warns(data_dir: Path, caplog) -> None:
config.PROCESSED_FILE.write_text("{bad", encoding="utf-8")
with caplog.at_level("WARNING"):
assert load_processed() == {}
assert "processed.json unreadable" in caplog.text
def test_save_processed_roundtrip(data_dir: Path) -> None:
payload = {"incoming/party.jpg": {"size": 1, "mtime": 2}}
save_processed(payload)
assert load_processed() == payload