Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c03f17488c | |||
| 5dcc3717c7 | |||
| dec74a46d7 | |||
| b09f90bfcc | |||
| 9a9b143f08 |
+7
-1
@@ -2,7 +2,8 @@
|
||||
|
||||
# --- Host data path (Syncthing share root) ------------------------------
|
||||
# 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:
|
||||
# DATA_HOST_DIR=./data
|
||||
# DATA_HOST_DIR=/home/frank/sync.schwenk.online/data/livef12
|
||||
@@ -22,3 +23,8 @@ PREPROCESS_TIMEOUT=120
|
||||
REMBG_MODEL=u2net
|
||||
REMBG_ALPHA=1
|
||||
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=
|
||||
|
||||
+5
-7
@@ -4,13 +4,6 @@
|
||||
# Runtime data (bind-mounted / Syncthing share — never in git)
|
||||
/data/
|
||||
|
||||
# Local rembg/withoutbg comparison scratch
|
||||
/compare-bg/inputs/
|
||||
/compare-bg/outputs/
|
||||
/compare-bg/model-cache/
|
||||
/compare-bg/compare.log
|
||||
.venv-compare/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -32,3 +25,8 @@ htmlcov/
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Android
|
||||
android/local.properties
|
||||
android/.gradle/
|
||||
android/**/build/
|
||||
|
||||
+44
-21
@@ -3,21 +3,24 @@
|
||||
## Overview
|
||||
|
||||
```
|
||||
Phone/Syncthing Docker host
|
||||
─────────────── ───────────
|
||||
incoming/ ──watch──► worker ──► variants/ + intermediates/
|
||||
│ │
|
||||
Organizer phone Docker host
|
||||
────────────── ───────────
|
||||
Share / pick
|
||||
→ JPEG 2000px
|
||||
→ POST /api/jobs ──► incoming/ ──watch──► worker
|
||||
token │
|
||||
└── rembg + gmic pipeline
|
||||
│
|
||||
meta/{stem}.json ◄──────────────────────┘
|
||||
meta/{stem}.json ◄───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
web (FastAPI) ──► browser UI + remix POST
|
||||
├── 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`). Syncthing runs on the host, not in
|
||||
compose.
|
||||
directory (`DATA_HOST_DIR` → `/data`). The organizer app is in `android/`.
|
||||
Syncthing is an optional fallback writer for `incoming/`, not required.
|
||||
|
||||
## Python modules
|
||||
|
||||
@@ -26,42 +29,61 @@ compose.
|
||||
| `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 routes, Jinja templates, static files |
|
||||
| `app/config.py` | Environment → constants (paths, timeouts, tuning) |
|
||||
| `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 (from incoming filename). Flat files:
|
||||
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 + manifest (variants list, filter metadata)
|
||||
- `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 choice
|
||||
## Background removal
|
||||
|
||||
`compare-bg/` is a **standalone benchmark** (rembg models vs withoutbg).
|
||||
It is not part of the runtime stack. Production uses `u2net` + alpha
|
||||
matting via `app/rembg_cli.py` (see `config.REMBG_MODEL`).
|
||||
Production uses `u2net` + alpha matting via `app/rembg_cli.py`
|
||||
(see `config.REMBG_MODEL`).
|
||||
|
||||
## Known limitations (intentional)
|
||||
|
||||
- **No web auth** — anyone with the link sees all jobs (event tool).
|
||||
- **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 both read-modify-write `meta/*.json`
|
||||
without file locking; concurrent remix during active processing can race.
|
||||
- **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
|
||||
@@ -73,7 +95,8 @@ ruff check app/ tests/
|
||||
pytest -q
|
||||
```
|
||||
|
||||
Full stack (gmic/rembg): `docker compose up --build` with `DATA_HOST_DIR=./data`.
|
||||
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.
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ Phase 0 analysis (2026-07-18). Working branch: `cleanup/code-quality`.
|
||||
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/`** — separate experiment, leave as-is (gitignored cache)
|
||||
7. ~~`compare-bg/`~~ — removed (was a standalone experiment)
|
||||
8. **`make_random.py`** — external reference only, document in ARCHITECTURE
|
||||
|
||||
## Risk matrix
|
||||
@@ -63,6 +63,6 @@ Phase 0 analysis (2026-07-18). Working branch: `cleanup/code-quality`.
|
||||
- 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
|
||||
- ~~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
|
||||
|
||||
+11
-10
@@ -31,19 +31,21 @@ behavior changes** to the photo pipeline, worker loop, or web UX.
|
||||
| Item | Reason |
|
||||
|------|--------|
|
||||
| `pipeline.py` module split | Medium risk without broader integration tests; deferred |
|
||||
| Meta file locking | Behavior change; documented in `ARCHITECTURE.md` |
|
||||
| Dependency major bumps | Per plan — list only, no auto-bump |
|
||||
| `compare-bg/` | Separate experiment; left untouched |
|
||||
| Deploy CI | Manual deploy on boka stays; STANDARDS says ask first |
|
||||
| FastAPI `TemplateResponse` API migration | Deprecation warning only; no functional change |
|
||||
| `_validate_job_id` always sanitizes to valid ID | Pre-existing; documented in tests |
|
||||
|
||||
## Bugs noticed (not fixed)
|
||||
## Follow-ups (later branch)
|
||||
|
||||
1. **Meta JSON race** — worker and web concurrent RMW without locking.
|
||||
2. **`_filter_timeout_override` global** — thread-unsafe under concurrent remix (uvicorn workers).
|
||||
3. **`sanitize_stem("a/b")` → `"b"`** — `Path.stem` drops path prefix before slash replacement.
|
||||
4. **`_validate_job_id`** — sanitization makes rejection path effectively unreachable.
|
||||
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)
|
||||
|
||||
@@ -84,6 +86,5 @@ docs: update README and add ARCHITECTURE
|
||||
|
||||
## External references
|
||||
|
||||
- `compare-bg/` — local venv `.venv-compare/` and 1.5GB model cache (gitignored)
|
||||
- `make_random.py` — external reference script, not vendored
|
||||
- Submodule/symlink: none
|
||||
|
||||
@@ -16,3 +16,29 @@
|
||||
- Old path `/home/frank/live.f12.rocks/data` no longer used by compose
|
||||
(migration/cleanup manual)
|
||||
- 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
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
# live.f12.rocks
|
||||
|
||||
Event photo pep pipeline: guests drop a photo into a Syncthing folder, a
|
||||
worker runs it through `rembg` (background removal) + `gmic` (random
|
||||
filter/blend compositing, ported from `make_random.py`), and a small web
|
||||
UI shows the results with a "remix" option to try different filters on
|
||||
the same photo. Finished jobs sync back via the same share.
|
||||
Event photo pep pipeline: the organizer shares a photo into the Android
|
||||
app, a worker runs it through `rembg` (background removal) + `gmic`
|
||||
(random filter/blend compositing, ported from `make_random.py`), and a
|
||||
small web UI shows the results with a "remix" option. Finished jobs
|
||||
land on the phone (gallery + local notification) and on the public page.
|
||||
|
||||
No web 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`)
|
||||
|
||||
| Service | What | Port |
|
||||
|----------|--------------------------------------------------|--------------------------|
|
||||
| `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`.
|
||||
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`
|
||||
|
||||
```
|
||||
incoming/ # phone drop — worker only ever reads/copies from here
|
||||
incoming/ # HTTP upload (and optional Syncthing drop)
|
||||
variants/
|
||||
{stem}_original.jpg # preprocessed private copy
|
||||
{stem}_rembg.png
|
||||
@@ -32,28 +34,38 @@ intermediates/
|
||||
{stem}_v1_bg_filtered.png
|
||||
{stem}_v1_post_0.png # …
|
||||
meta/
|
||||
{stem}.json # status + manifest for the web UI
|
||||
processed.json # worker bookkeeping: which incoming files were handled
|
||||
{stem}.json # status + stage + manifest
|
||||
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
|
||||
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
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# set UPLOAD_TOKEN (python3 -c "import secrets; print(secrets.token_urlsafe(32))")
|
||||
# local: set DATA_HOST_DIR=./data
|
||||
# prod: leave unset (defaults to the Syncthing share path)
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Drop a photo into `incoming/` (via Syncthing or locally). After a few
|
||||
seconds (poll interval + processing time) it shows up on the web UI; flat
|
||||
files land in `variants/` and `intermediates/` for the phone.
|
||||
Share a photo into the Android app, or drop one into `incoming/` locally.
|
||||
After a few seconds it shows up on the web UI.
|
||||
|
||||
Android build: [`android/README.md`](android/README.md).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -71,13 +83,14 @@ pytest -q # unit tests (no gmic/rembg)
|
||||
|
||||
CI (`.gitea/workflows/ci.yml`): ruff + pytest on push/PR.
|
||||
|
||||
Architecture notes: `ARCHITECTURE.md`. Cleanup log: `CLEANUP_PLAN.md`.
|
||||
Architecture notes: `ARCHITECTURE.md`.
|
||||
|
||||
## Configuration (`.env`, see `.env.example`)
|
||||
|
||||
| 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 |
|
||||
| `BLEND_OPACITY` | `30%` | Default blend opacity for auto-generated variants |
|
||||
| `FILTER_TIMEOUT` | `120` | Seconds before a single gmic call is killed |
|
||||
@@ -90,17 +103,13 @@ Architecture notes: `ARCHITECTURE.md`. Cleanup log: `CLEANUP_PLAN.md`.
|
||||
|
||||
- **Firewall:** Traefik must already route `live.f12.rocks` — this repo
|
||||
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
|
||||
`u2net` ONNX model (~176 MB) from GitHub. This needs outbound internet
|
||||
on first run and can take a minute or two depending on the link; the
|
||||
model is cached in the `rembg_cache` named volume afterwards, so
|
||||
restarts don't re-download it.
|
||||
`u2net` ONNX model (~176 MB) from GitHub. Cached in `rembg_cache`.
|
||||
- **Incoming is append-only:** the worker only ever copies out of
|
||||
`incoming/` and never deletes or moves anything there — plan disk space
|
||||
accordingly, or clean up `incoming/` manually between events.
|
||||
`incoming/` and never deletes or moves anything there.
|
||||
- **Sequential processing:** the worker handles one photo at a time
|
||||
(`cpus: "1.0"`, no concurrency) — fine for an event pace, but a burst of
|
||||
uploads will just queue up and get processed in order.
|
||||
(`cpus: "1.0"`, no concurrency).
|
||||
- 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.
|
||||
|
||||
@@ -6,31 +6,33 @@
|
||||
|
||||
## One-Liner
|
||||
|
||||
Event photo pep pipeline: Syncthing drop -> gmic/rembg -> web. Upload a
|
||||
photo at the event, get back weird/fun filtered variants seconds later.
|
||||
Event photo pep pipeline: organizer Android app -> gmic/rembg -> web.
|
||||
Upload a photo at the event, get back weird/fun filtered variants seconds
|
||||
later.
|
||||
|
||||
## Vision
|
||||
|
||||
At an event (f12 meetup/party), someone drops a photo into a Syncthing
|
||||
folder from a phone/camera rig. A worker rips the background off, runs it
|
||||
through a random gmic filter/blend chain, and a handful of variants show
|
||||
up on a shared web page almost immediately — no app install, no login, no
|
||||
waiting for someone to "process the photos later". If a variant is fun,
|
||||
remix it with different filters right there. Finished jobs sync back to
|
||||
the phone via the same Syncthing share.
|
||||
At an event (f12 meetup/party), the organizer shares a photo into the
|
||||
Android app. The phone converts it to a 2000px JPEG, uploads it, a worker
|
||||
rips the background off, runs it through a random gmic filter/blend chain,
|
||||
and a handful of variants show up on a shared web page almost immediately —
|
||||
guests need no app, no login, no "process the photos later". If a variant
|
||||
is fun, remix it with different filters right there. Finished jobs land on
|
||||
the organizer's phone (gallery + local notification) and stay on the web
|
||||
for everyone with the link.
|
||||
|
||||
## Audience
|
||||
|
||||
Event organizers and attendees at f12 events, on their phones, often on
|
||||
bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link
|
||||
(and whoever's syncing photos in) is the whole audience.
|
||||
Event organizers (upload via the Android app) and attendees on their
|
||||
phones, often on bad venue wifi. Nobody signs up, nobody logs in. Guests
|
||||
with the link see everything; only the organizer's app can upload.
|
||||
|
||||
## Tone & Wording
|
||||
|
||||
- **Voice:** direct, casual, no explaining-the-obvious. It's a fun tool
|
||||
for an event, not enterprise software.
|
||||
- **Formality:** du. Short German labels in the UI (Ansehen, Remix,
|
||||
Download, Varianten, Zwischenschritte).
|
||||
Download, Varianten, Zwischenschritte, Warteschlange, Fertig).
|
||||
- **Error messages:** say what broke, don't blame the visitor — most
|
||||
errors here are gmic filter flakiness, not user error.
|
||||
- **Forbidden words/phrases:** no corporate buzzwords, no "Erlebnis",
|
||||
@@ -42,7 +44,7 @@ bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link
|
||||
|---------|------|-----|
|
||||
| Success | `Remix erzeugt.` | `Dein Kunstwerk ist fertig! 🎉` |
|
||||
| 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
|
||||
|
||||
@@ -54,46 +56,51 @@ bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No web auth / accounts / login product — anyone with the link sees
|
||||
everything, by design, for this event tool.
|
||||
- No camera-to-phone client in this repo — upload path is Syncthing
|
||||
(phone/folder setup is out of band).
|
||||
- No web auth / accounts / login product — anyone with the link **sees**
|
||||
everything, by design. Upload is a separate shared token in the
|
||||
organizer app, not a user account.
|
||||
- No Play Store / FCM / ntfy dependency. Notifications are local Android.
|
||||
- No beamer/projector product built into this repo.
|
||||
- Not a DAM (digital asset manager) — no albums, tagging, search,
|
||||
retention policies. `incoming/`, `variants/`, and `intermediates/`
|
||||
are the whole data model.
|
||||
- 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)
|
||||
|
||||
- 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`.
|
||||
Bind-mounted into `worker` and `web` at `/data` (see `compose.yml`).
|
||||
- Layout under the share:
|
||||
- `incoming/` — phone drop
|
||||
- Layout:
|
||||
- `incoming/` — HTTP upload (and optional Syncthing drop)
|
||||
- `variants/` — `{stem}_original.jpg`, `{stem}_rembg.png`, `{stem}_vN.png`
|
||||
- `intermediates/` — `{stem}_vN_*.png` (incl. `_post_0` …)
|
||||
- `meta/` — web bookkeeping JSON (optional `.stignore` on phone)
|
||||
- `meta/` — status + manifest JSON
|
||||
- `processed.json`
|
||||
- Domain: `live.f12.rocks`, routed via the shared external `traefik`
|
||||
network, TLS via `myresolver` (see `INFRASTRUCTURE.md`).
|
||||
- rembg model cache lives in the named Docker volume `rembg_cache`, not
|
||||
under the Syncthing share — container state, not event data.
|
||||
- rembg model cache lives in the named Docker volume `rembg_cache`.
|
||||
- Organizer Android app lives in `android/` (sideload APK).
|
||||
- `UPLOAD_TOKEN` in server `.env` — required for `POST /api/jobs`.
|
||||
|
||||
## Project-Specific Rules
|
||||
|
||||
```markdown
|
||||
- Never delete or move files under incoming/ from worker code — Syncthing
|
||||
is the writer there. (reason: uploads are the one copy of the original
|
||||
that exists outside a phone's camera roll)
|
||||
- Never delete or move files under incoming/ from worker code — the
|
||||
upload API (and optional Syncthing) is the writer there. (reason:
|
||||
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
|
||||
up automatically. (reason: useful for debugging bad filter picks,
|
||||
disk is cheap compared to re-running gmic)
|
||||
- gmic/rembg subprocess calls always run under `nice` (see NICE_LEVEL) —
|
||||
the worker box needs to stay responsive for other things during an
|
||||
event. (reason: explicit requirement, avoid CPU starvation)
|
||||
- POST /api/jobs is fail-closed: empty UPLOAD_TOKEN disables uploads.
|
||||
The public web UI stays read-only.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.gradle/
|
||||
**/build/
|
||||
local.properties
|
||||
*.apk
|
||||
*.aab
|
||||
*.iml
|
||||
.idea/
|
||||
captures/
|
||||
.cxx/
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# Keep rules unused while minify is off; ready if we ever shrink release.
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
+252
@@ -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" "$@"
|
||||
Vendored
+94
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -34,6 +34,8 @@ INTERMEDIATES_DIR = DATA_DIR / "intermediates"
|
||||
# Web bookkeeping (status + manifest). Not needed on the phone — ignore via
|
||||
# Syncthing .stignore if desired.
|
||||
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"
|
||||
PROCESSED_FILE = DATA_DIR / "processed.json"
|
||||
|
||||
@@ -105,3 +107,7 @@ STABLE_WAIT_SECONDS = _float_env("STABLE_WAIT_SECONDS", 2)
|
||||
|
||||
# --- Web ---------------------------------------------------------------
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -23,3 +27,16 @@ def write_json_atomic(path: Path, data: Any) -> None:
|
||||
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
@@ -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}"
|
||||
+47
-44
@@ -3,9 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||
@@ -13,34 +11,35 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from . import config, pipeline, remix
|
||||
from . import api, config, images, jobs, pipeline, remix
|
||||
from .logging_config import configure_logging
|
||||
|
||||
configure_logging()
|
||||
logger = logging.getLogger("livef12.web")
|
||||
|
||||
app = FastAPI(title=config.SITE_TITLE)
|
||||
app.include_router(api.router)
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
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")
|
||||
|
||||
# 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:
|
||||
stem = pipeline.sanitize_stem(job_id)
|
||||
if not _JOB_ID_RE.match(stem):
|
||||
raise HTTPException(status_code=400, detail="Ungueltige Job-ID")
|
||||
return stem
|
||||
try:
|
||||
return jobs.validate_job_id(job_id)
|
||||
except jobs.JobIdError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _job_exists(job_id: str) -> str:
|
||||
stem = _validate_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
|
||||
return jobs.require_job_id(job_id)
|
||||
|
||||
|
||||
def _safe_job_file(job_id: str, filename: str) -> Path:
|
||||
@@ -48,9 +47,9 @@ def _safe_job_file(job_id: str, filename: str) -> Path:
|
||||
stem = _validate_job_id(job_id)
|
||||
name = Path(filename).name
|
||||
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}_"):
|
||||
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):
|
||||
candidate = (directory / name).resolve()
|
||||
@@ -61,29 +60,17 @@ def _safe_job_file(job_id: str, filename: str) -> Path:
|
||||
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():
|
||||
data = pipeline.read_meta(job_id) or {}
|
||||
variants = data.get("variants") or []
|
||||
jobs.append(
|
||||
{
|
||||
"job_id": job_id,
|
||||
"status": data.get("status", "unknown"),
|
||||
"created_at": data.get("created_at") or "",
|
||||
"variant_count": len(variants),
|
||||
"thumbnail": variants[-1].get("file") if variants else None,
|
||||
}
|
||||
)
|
||||
jobs.sort(key=lambda j: j["created_at"] or j["job_id"], reverse=True)
|
||||
return jobs
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request) -> HTMLResponse:
|
||||
job_list = jobs.list_jobs()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"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),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -93,16 +80,12 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse:
|
||||
paths = pipeline.job_paths(stem)
|
||||
status = pipeline.read_status(stem) or {}
|
||||
manifest = pipeline.read_manifest(stem) or {"variants": []}
|
||||
intermediates = (
|
||||
sorted(p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file())
|
||||
if paths.intermediates.exists()
|
||||
else []
|
||||
)
|
||||
intermediates = jobs.list_intermediates(stem)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"job.html",
|
||||
{
|
||||
"request": request,
|
||||
"site_title": config.SITE_TITLE,
|
||||
"job_id": stem,
|
||||
"status": status,
|
||||
@@ -111,14 +94,31 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse:
|
||||
"rembg_name": paths.rembg.name if paths.rembg.exists() else None,
|
||||
"has_rembg": paths.rembg.exists(),
|
||||
"intermediates": intermediates,
|
||||
"live": (status.get("status") or "") in jobs.ACTIVE_STATUSES,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
if w is None:
|
||||
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)
|
||||
@@ -131,7 +131,10 @@ def remix_form(
|
||||
stem = _job_exists(job_id)
|
||||
paths = pipeline.job_paths(stem)
|
||||
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()
|
||||
options = remix.build_remix_options(assets)
|
||||
@@ -166,9 +169,9 @@ def remix_form(
|
||||
}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"remix.html",
|
||||
{
|
||||
"request": request,
|
||||
"site_title": config.SITE_TITLE,
|
||||
"job_id": stem,
|
||||
"options": options,
|
||||
|
||||
+118
-21
@@ -15,6 +15,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
@@ -25,14 +26,14 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import config
|
||||
from .io_utils import read_json, write_json_atomic
|
||||
from .io_utils import file_lock, read_json, write_json_atomic
|
||||
|
||||
logger = logging.getLogger("livef12.pipeline")
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
# Optional override for run_gmic timeout (used by the long-retry pass).
|
||||
_filter_timeout_override: int | None = None
|
||||
# Per-thread override for run_gmic timeout (used by the long-retry pass).
|
||||
_filter_timeout_state = threading.local()
|
||||
|
||||
|
||||
class PipelineError(RuntimeError):
|
||||
@@ -69,19 +70,19 @@ def _nice(cmd: list[str]) -> list[str]:
|
||||
|
||||
|
||||
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
|
||||
def filter_timeout(seconds: int) -> Iterator[None]:
|
||||
"""Temporarily override FILTER_TIMEOUT for run_gmic (long-retry pass)."""
|
||||
global _filter_timeout_override
|
||||
previous = _filter_timeout_override
|
||||
_filter_timeout_override = seconds
|
||||
previous = getattr(_filter_timeout_state, "override", None)
|
||||
_filter_timeout_state.override = seconds
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_filter_timeout_override = previous
|
||||
_filter_timeout_state.override = previous
|
||||
|
||||
|
||||
def _magick_bin() -> str:
|
||||
@@ -306,8 +307,10 @@ _UNSAFE_STEM_RE = re.compile(r"[^\w.\-]+", re.UNICODE)
|
||||
|
||||
def sanitize_stem(name: str) -> str:
|
||||
"""Safe basename stem for flat files (no path separators / junk)."""
|
||||
stem = Path(name).stem if name else ""
|
||||
stem = stem.replace("/", "_").replace("\\", "_").strip().strip(".")
|
||||
# Replace separators before Path.stem so "a/b" → "a_b", not "b".
|
||||
stem = (name or "").replace("/", "_").replace("\\", "_")
|
||||
stem = Path(stem).stem
|
||||
stem = stem.strip().strip(".")
|
||||
stem = _UNSAFE_STEM_RE.sub("_", stem).strip("._")
|
||||
return stem or "photo"
|
||||
|
||||
@@ -354,17 +357,47 @@ def read_meta(stem: str) -> dict[str, Any] | None:
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _write_meta(paths: JobPaths, data: dict[str, Any]) -> None:
|
||||
def _meta_lock_path(stem: str) -> Path:
|
||||
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()
|
||||
write_json_atomic(paths.meta, data)
|
||||
|
||||
|
||||
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:
|
||||
data = read_meta(job_id)
|
||||
if not data:
|
||||
return None
|
||||
return {
|
||||
"status": data.get("status", "unknown"),
|
||||
"stage": data.get("stage"),
|
||||
"progress": data.get("progress"),
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"error": data.get("error"),
|
||||
@@ -375,13 +408,14 @@ def read_status(job_id: str) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
def write_status(paths: JobPaths, status: str, **extra: Any) -> None:
|
||||
with meta_lock(paths.stem):
|
||||
data = read_meta(paths.stem) or {}
|
||||
data["job_id"] = paths.stem
|
||||
data["source_stem"] = paths.stem
|
||||
data["status"] = status
|
||||
data.setdefault("created_at", now_iso())
|
||||
data.update(extra)
|
||||
_write_meta(paths, data)
|
||||
_write_meta_unlocked(paths, data)
|
||||
|
||||
|
||||
def read_manifest(job_id: str) -> dict[str, Any] | None:
|
||||
@@ -400,16 +434,52 @@ def read_manifest(job_id: str) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
def write_manifest(paths: JobPaths, manifest: dict[str, Any]) -> None:
|
||||
"""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["source_stem"] = manifest.get("source_stem") or paths.stem
|
||||
data["original_file"] = manifest.get("original_file")
|
||||
data["rembg_file"] = manifest.get("rembg_file")
|
||||
data["variants"] = manifest.get("variants") or []
|
||||
if manifest.get("source_stem"):
|
||||
data["source_stem"] = manifest["source_stem"]
|
||||
else:
|
||||
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:
|
||||
data.setdefault("created_at", manifest["created_at"])
|
||||
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]:
|
||||
@@ -560,7 +630,15 @@ def process_job(
|
||||
paths.intermediates.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:
|
||||
paths = preprocess_original(paths)
|
||||
@@ -575,6 +653,12 @@ def process_job(
|
||||
}
|
||||
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)
|
||||
if not ok:
|
||||
raise PipelineError(f"rembg fehlgeschlagen: {err}")
|
||||
@@ -586,6 +670,12 @@ def process_job(
|
||||
|
||||
for i in range(1, config.OUTPUT_COUNT + 1):
|
||||
variant_id = f"v{i}"
|
||||
write_status(
|
||||
paths,
|
||||
"processing",
|
||||
stage="variant",
|
||||
progress={"current": i, "total": config.OUTPUT_COUNT},
|
||||
)
|
||||
try:
|
||||
entry = compose_variant(
|
||||
paths,
|
||||
@@ -632,10 +722,17 @@ def process_job(
|
||||
if not manifest["variants"]:
|
||||
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:
|
||||
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
|
||||
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}")
|
||||
|
||||
+11
-4
@@ -3,6 +3,7 @@ cached original + rembg output and user-chosen filters/blends/opacity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import pipeline
|
||||
@@ -52,8 +53,8 @@ def create_remix_variant(job_id: str, choice: RemixChoice) -> dict:
|
||||
"variants": [],
|
||||
}
|
||||
variant_stem = pipeline.resolve_source_stem(manifest, paths)
|
||||
manifest.setdefault("source_stem", variant_stem)
|
||||
variant_id = pipeline.next_variant_id(manifest)
|
||||
# Unique id avoids collisions when two remixes run in parallel.
|
||||
variant_id = f"v{uuid.uuid4().hex[:8]}"
|
||||
|
||||
entry = pipeline.compose_variant(
|
||||
paths,
|
||||
@@ -67,6 +68,12 @@ def create_remix_variant(job_id: str, choice: RemixChoice) -> dict:
|
||||
fg_mode=choice.fg_blend,
|
||||
opacity=choice.opacity,
|
||||
)
|
||||
manifest["variants"].append(entry)
|
||||
pipeline.write_manifest(paths, manifest)
|
||||
pipeline.append_manifest_variant(
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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 |
@@ -52,6 +52,17 @@ a:focus-visible {
|
||||
.site-header {
|
||||
padding: 1rem var(--gap);
|
||||
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 {
|
||||
@@ -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 {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
@@ -154,6 +192,27 @@ h2 {
|
||||
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 {
|
||||
color: var(--text);
|
||||
display: block;
|
||||
@@ -390,4 +449,39 @@ body.lightbox-open {
|
||||
padding: 0.25rem 0.5rem;
|
||||
min-height: 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
@@ -3,60 +3,30 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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>
|
||||
<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">
|
||||
</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">
|
||||
<a class="brand" href="/">{{ site_title }}</a>
|
||||
<span class="header-note">Event-Pep</span>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<div id="lightbox" class="lightbox" hidden>
|
||||
<button type="button" class="lightbox-close" aria-label="Schliessen">×</button>
|
||||
<button type="button" class="lightbox-close" aria-label="Schließen">×</button>
|
||||
<button type="button" class="lightbox-nav lightbox-prev" aria-label="Vorheriges Bild">‹</button>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,20 +2,34 @@
|
||||
{% block title %}{{ site_title }}{% endblock %}
|
||||
{% block content %}
|
||||
{% 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 %}
|
||||
<ul class="job-grid">
|
||||
{% 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 }}">
|
||||
{% 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 %}
|
||||
<span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status }}</span>
|
||||
<span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status_label }}</span>
|
||||
{% endif %}
|
||||
<span class="job-meta">
|
||||
<span class="job-id">{{ job.job_id }}</span>
|
||||
<span class="job-status status-{{ job.status }}">{{ job.status }} · {{ job.variant_count }} Varianten</span>
|
||||
<span class="job-status status-{{ job.status }}">
|
||||
{{ job.status_label }}
|
||||
{% if job.stage_label and job.status in ACTIVE_STATUSES %}
|
||||
· {{ job.stage_label }}
|
||||
{% else %}
|
||||
· {{ job.variant_count }} Varianten
|
||||
{% endif %}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
+51
-5
@@ -3,8 +3,20 @@
|
||||
{% block content %}
|
||||
<p class="back-link"><a href="/">← Alle Jobs</a></p>
|
||||
|
||||
<div class="job-heading">
|
||||
<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 %}
|
||||
· {{ 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 %}
|
||||
<p class="error-box">Fehler: {{ status.error }}</p>
|
||||
{% endif %}
|
||||
@@ -13,17 +25,35 @@
|
||||
<h2>Original & Rembg</h2>
|
||||
<div class="pair-grid">
|
||||
{% if original_name %}
|
||||
{% set img = img_attrs(job_id, original_name, sizes=PAIR_SIZES) %}
|
||||
<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>
|
||||
Original ·
|
||||
<a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a>
|
||||
</figcaption>
|
||||
</figure>
|
||||
{% else %}
|
||||
<p class="empty-state">Original kommt, sobald der Worker greift.</p>
|
||||
{% endif %}
|
||||
{% if has_rembg and rembg_name %}
|
||||
{% set img = img_attrs(job_id, rembg_name, sizes=PAIR_SIZES) %}
|
||||
<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>
|
||||
Rembg ·
|
||||
<a href="/jobs/{{ job_id }}/files/{{ rembg_name }}" download>Download</a>
|
||||
@@ -45,8 +75,16 @@
|
||||
{% else %}
|
||||
<ul class="variant-grid">
|
||||
{% for v in manifest.variants %}
|
||||
{% set img = img_attrs(job_id, v.file) %}
|
||||
<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">
|
||||
<strong>{{ v.id }}</strong> <span class="tag">{{ v.source }}</span>
|
||||
<dl>
|
||||
@@ -72,8 +110,16 @@
|
||||
<h2>Zwischenschritte</h2>
|
||||
<ul class="intermediate-grid">
|
||||
{% for name in intermediates %}
|
||||
{% set img = img_attrs(job_id, name) %}
|
||||
<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>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Remix {{ job_id }} — {{ site_title }}{% endblock %}
|
||||
{% block content %}
|
||||
<p class="back-link"><a href="/jobs/{{ job_id }}">← Zurueck zum Job</a></p>
|
||||
<p class="back-link"><a href="/jobs/{{ job_id }}">← Zurück zum Job</a></p>
|
||||
|
||||
<h1>Remix · Job {{ job_id }}</h1>
|
||||
{% if from_variant %}
|
||||
|
||||
@@ -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/`.
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
+6
-5
@@ -1,12 +1,12 @@
|
||||
# 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
|
||||
# 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
|
||||
# to .env for local overrides (e.g. DATA_HOST_DIR=./data). Never commit .env.
|
||||
# Copy .env.example to .env for local overrides (DATA_HOST_DIR=./data,
|
||||
# UPLOAD_TOKEN=...). Never commit .env.
|
||||
|
||||
x-app-env: &app-env
|
||||
DATA_DIR: /data
|
||||
@@ -53,6 +53,7 @@ services:
|
||||
environment:
|
||||
<<: *app-env
|
||||
SITE_TITLE: live.f12.rocks
|
||||
UPLOAD_TOKEN: ${UPLOAD_TOKEN:-}
|
||||
volumes:
|
||||
- ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data
|
||||
- ./assets:/data/assets:ro
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "livef12rocks"
|
||||
version = "0.1.0"
|
||||
description = "Event photo pep pipeline: Syncthing drop -> gmic/rembg -> web"
|
||||
description = "Event photo pep pipeline: Android upload -> gmic/rembg -> web"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11,<3.12"
|
||||
|
||||
|
||||
+3
-1
@@ -14,7 +14,8 @@ def data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
intermediates = tmp_path / "intermediates"
|
||||
meta = tmp_path / "meta"
|
||||
inbox = tmp_path / "incoming"
|
||||
for directory in (variants, intermediates, meta, inbox):
|
||||
webcache = tmp_path / "webcache"
|
||||
for directory in (variants, intermediates, meta, inbox, webcache):
|
||||
directory.mkdir()
|
||||
|
||||
monkeypatch.setattr("app.config.DATA_DIR", tmp_path)
|
||||
@@ -22,5 +23,6 @@ def data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -24,3 +24,21 @@ def test_write_json_atomic_roundtrip(tmp_path: Path) -> None:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
+65
-1
@@ -5,10 +5,11 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from app import config, pipeline
|
||||
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
|
||||
@@ -16,11 +17,26 @@ 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)
|
||||
@@ -40,3 +56,51 @@ def test_index_empty(client: TestClient) -> None:
|
||||
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")
|
||||
|
||||
@@ -13,6 +13,7 @@ from app import pipeline
|
||||
[
|
||||
("photo.jpg", "photo"),
|
||||
("../evil", "evil"),
|
||||
("a/b", "a_b"),
|
||||
("", "photo"),
|
||||
(" spaced ", "spaced"),
|
||||
("foo@bar", "foo_bar"),
|
||||
@@ -78,3 +79,56 @@ def test_list_job_ids(data_dir: Path) -> None:
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user