Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c03f17488c |
@@ -23,3 +23,8 @@ PREPROCESS_TIMEOUT=120
|
|||||||
REMBG_MODEL=u2net
|
REMBG_MODEL=u2net
|
||||||
REMBG_ALPHA=1
|
REMBG_ALPHA=1
|
||||||
NICE_LEVEL=18
|
NICE_LEVEL=18
|
||||||
|
|
||||||
|
# --- Upload API (organizer Android app) ----------------------------------
|
||||||
|
# Shared secret for POST /api/jobs. Leave empty to disable uploads.
|
||||||
|
# Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
UPLOAD_TOKEN=
|
||||||
|
|||||||
@@ -25,3 +25,8 @@ htmlcov/
|
|||||||
*~
|
*~
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# Android
|
||||||
|
android/local.properties
|
||||||
|
android/.gradle/
|
||||||
|
android/**/build/
|
||||||
|
|||||||
+38
-15
@@ -3,21 +3,24 @@
|
|||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
Phone/Syncthing Docker host
|
Organizer phone Docker host
|
||||||
─────────────── ───────────
|
────────────── ───────────
|
||||||
incoming/ ──watch──► worker ──► variants/ + intermediates/
|
Share / pick
|
||||||
│ │
|
→ JPEG 2000px
|
||||||
|
→ POST /api/jobs ──► incoming/ ──watch──► worker
|
||||||
|
token │
|
||||||
└── rembg + gmic pipeline
|
└── rembg + gmic pipeline
|
||||||
│
|
│
|
||||||
meta/{stem}.json ◄──────────────────────┘
|
meta/{stem}.json ◄───────────────────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
├── web (HTML) ──► guest browsers + remix
|
||||||
web (FastAPI) ──► browser UI + remix POST
|
└── GET /api/jobs/{id} ──► app polls, local notification,
|
||||||
|
save variants + intermediates
|
||||||
```
|
```
|
||||||
|
|
||||||
Two containers share one image (`Dockerfile`) and one bind-mounted data
|
Two containers share one image (`Dockerfile`) and one bind-mounted data
|
||||||
directory (`DATA_HOST_DIR` → `/data`). Syncthing runs on the host, not in
|
directory (`DATA_HOST_DIR` → `/data`). The organizer app is in `android/`.
|
||||||
compose.
|
Syncthing is an optional fallback writer for `incoming/`, not required.
|
||||||
|
|
||||||
## Python modules
|
## Python modules
|
||||||
|
|
||||||
@@ -26,24 +29,42 @@ compose.
|
|||||||
| `app/worker.py` | Poll `incoming/`, copy to `variants/`, run `process_job()` |
|
| `app/worker.py` | Poll `incoming/`, copy to `variants/`, run `process_job()` |
|
||||||
| `app/pipeline.py` | gmic/rembg orchestration, meta/manifest I/O, variant compose |
|
| `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/remix.py` | User-selected remix variant (one-shot compose + manifest append) |
|
||||||
| `app/main.py` | FastAPI routes, Jinja templates, static files |
|
| `app/main.py` | FastAPI HTML routes, Jinja templates, static files |
|
||||||
| `app/config.py` | Environment → constants (paths, timeouts, tuning) |
|
| `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/rembg_cli.py` | Subprocess entry for rembg (avoids heavy `rembg[cli]` extra) |
|
||||||
| `app/io_utils.py` | Atomic JSON read/write |
|
| `app/io_utils.py` | Atomic JSON read/write |
|
||||||
| `app/env_utils.py` | Shared bool env parsing |
|
| `app/env_utils.py` | Shared bool env parsing |
|
||||||
| `app/logging_config.py` | Entry-point logging setup |
|
| `app/logging_config.py` | Entry-point logging setup |
|
||||||
|
| `app/images.py` | On-demand WebP srcset cache |
|
||||||
|
|
||||||
## Data model
|
## 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}_original.jpg` — preprocessed copy
|
||||||
- `variants/{stem}_rembg.png` — background removal
|
- `variants/{stem}_rembg.png` — background removal
|
||||||
- `variants/{stem}_vN.png` — final composed images
|
- `variants/{stem}_vN.png` — final composed images
|
||||||
- `intermediates/{stem}_vN_*.png` — step images (kept for debugging)
|
- `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)
|
- `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
|
## Pipeline origin
|
||||||
|
|
||||||
The gmic filter/blend chain is ported from an external reference script
|
The gmic filter/blend chain is ported from an external reference script
|
||||||
@@ -57,7 +78,8 @@ Production uses `u2net` + alpha matting via `app/rembg_cli.py`
|
|||||||
|
|
||||||
## Known limitations (intentional)
|
## 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.
|
- **Sequential worker** — one photo at a time; burst uploads queue.
|
||||||
- **Meta JSON** — worker and web share `meta/*.json` via locked
|
- **Meta JSON** — worker and web share `meta/*.json` via locked
|
||||||
read-modify-write (`fcntl.flock`); variants are merged by id so
|
read-modify-write (`fcntl.flock`); variants are merged by id so
|
||||||
@@ -73,7 +95,8 @@ ruff check app/ tests/
|
|||||||
pytest -q
|
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
|
Python **3.11** matches the Docker image; host 3.14+ cannot install pinned
|
||||||
`onnxruntime` — use Docker or a 3.11 venv for parity.
|
`onnxruntime` — use Docker or a 3.11 venv for parity.
|
||||||
|
|||||||
@@ -16,3 +16,29 @@
|
|||||||
- Old path `/home/frank/live.f12.rocks/data` no longer used by compose
|
- Old path `/home/frank/live.f12.rocks/data` no longer used by compose
|
||||||
(migration/cleanup manual)
|
(migration/cleanup manual)
|
||||||
- Host port 12121 can be closed after cutover
|
- Host port 12121 can be closed after cutover
|
||||||
|
|
||||||
|
## Android upload (2026-08-27)
|
||||||
|
|
||||||
|
- Syncthing is fallback only. Product path: organizer Android app
|
||||||
|
(`android/`) → `POST /api/jobs` with `UPLOAD_TOKEN` → worker as before.
|
||||||
|
- App polls JSON, local notification, saves to `Pictures/livef12/{job_id}/`.
|
||||||
|
- After deploy: set `UPLOAD_TOKEN` in server `.env`, recreate `web`.
|
||||||
|
- Sideload debug APK; no Play Store / ntfy.
|
||||||
|
- Leftover branch `fix/concurrency-and-cleanup` deleted (already in main).
|
||||||
|
|
||||||
|
## Deploy 2026-07-16
|
||||||
|
|
||||||
|
- Live on boka: `/home/frank/live.f12.rocks`
|
||||||
|
- Web: https://live.f12.rocks
|
||||||
|
- Password / secrets in server `.env` only (`chmod 600`)
|
||||||
|
- First rembg job downloads ~176 MB model into docker volume `rembg_cache`
|
||||||
|
|
||||||
|
## Syncthing cutover (2026-07-18)
|
||||||
|
|
||||||
|
- SFTPGo removed; drop path is Syncthing
|
||||||
|
- Data share: `/home/frank/sync.schwenk.online/data/livef12`
|
||||||
|
(`incoming/` + `variants/` + `intermediates/` + `meta/` + `processed.json`)
|
||||||
|
- Flat phone-friendly names: `{stem}_original.jpg`, `{stem}_v1.png`, …
|
||||||
|
- Old path `/home/frank/live.f12.rocks/data` no longer used by compose
|
||||||
|
(migration/cleanup manual)
|
||||||
|
- Host port 12121 can be closed after cutover
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
# live.f12.rocks
|
# live.f12.rocks
|
||||||
|
|
||||||
Event photo pep pipeline: guests drop a photo into a Syncthing folder, a
|
Event photo pep pipeline: the organizer shares a photo into the Android
|
||||||
worker runs it through `rembg` (background removal) + `gmic` (random
|
app, a worker runs it through `rembg` (background removal) + `gmic`
|
||||||
filter/blend compositing, ported from `make_random.py`), and a small web
|
(random filter/blend compositing, ported from `make_random.py`), and a
|
||||||
UI shows the results with a "remix" option to try different filters on
|
small web UI shows the results with a "remix" option. Finished jobs
|
||||||
the same photo. Finished jobs sync back via the same share.
|
land on the phone (gallery + local notification) and on the public page.
|
||||||
|
|
||||||
No web auth. This is an event tool, not a DAM.
|
No web login. Upload uses a shared token in the organizer app. This is
|
||||||
|
an event tool, not a DAM.
|
||||||
|
|
||||||
## Services (`compose.yml`)
|
## Services (`compose.yml`)
|
||||||
|
|
||||||
| Service | What | Port |
|
| Service | What | Port |
|
||||||
|----------|--------------------------------------------------|--------------------------|
|
|----------|--------------------------------------------------|--------------------------|
|
||||||
| `worker` | Watches `incoming/`, runs the compose pipeline | none published |
|
| `worker` | Watches `incoming/`, runs the compose pipeline | none published |
|
||||||
| `web` | FastAPI + Jinja UI, browse jobs, remix | via Traefik only |
|
| `web` | FastAPI HTML + JSON API, remix | via Traefik only |
|
||||||
|
|
||||||
Both share one host directory (`DATA_HOST_DIR`) mounted at `/data`.
|
Both share one host directory (`DATA_HOST_DIR`) mounted at `/data`.
|
||||||
Syncthing runs on the host — not in this compose.
|
The organizer app lives in `android/`. Syncthing can still write
|
||||||
|
`incoming/` as a fallback — it is not in this compose.
|
||||||
|
|
||||||
## Data layout (Syncthing share)
|
## Data layout
|
||||||
|
|
||||||
Prod path: `/home/frank/sync.schwenk.online/data/livef12`
|
Prod path: `/home/frank/sync.schwenk.online/data/livef12`
|
||||||
|
|
||||||
```
|
```
|
||||||
incoming/ # phone drop — worker only ever reads/copies from here
|
incoming/ # HTTP upload (and optional Syncthing drop)
|
||||||
variants/
|
variants/
|
||||||
{stem}_original.jpg # preprocessed private copy
|
{stem}_original.jpg # preprocessed private copy
|
||||||
{stem}_rembg.png
|
{stem}_rembg.png
|
||||||
@@ -32,28 +34,38 @@ intermediates/
|
|||||||
{stem}_v1_bg_filtered.png
|
{stem}_v1_bg_filtered.png
|
||||||
{stem}_v1_post_0.png # …
|
{stem}_v1_post_0.png # …
|
||||||
meta/
|
meta/
|
||||||
{stem}.json # status + manifest for the web UI
|
{stem}.json # status + stage + manifest
|
||||||
processed.json # worker bookkeeping: which incoming files were handled
|
processed.json # worker bookkeeping
|
||||||
```
|
```
|
||||||
|
|
||||||
Phone tip: `.stignore` can drop `meta/` and `processed.json` if you only
|
|
||||||
want images on the device.
|
|
||||||
|
|
||||||
`assets/` (filter lists + trimmed `filters.json`) lives in the repo and is
|
`assets/` (filter lists + trimmed `filters.json`) lives in the repo and is
|
||||||
bind-mounted read-only into `worker`/`web` at `/data/assets`.
|
bind-mounted read-only into `worker`/`web` at `/data/assets`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Path | Auth |
|
||||||
|
|--------|------|------|
|
||||||
|
| `GET /api/health` | `{ok, upload}` | no |
|
||||||
|
| `GET /api/jobs` | job list | no |
|
||||||
|
| `GET /api/jobs/{id}` | status, variants, intermediates | no |
|
||||||
|
| `POST /api/jobs` | multipart `file` | `Authorization: Bearer <UPLOAD_TOKEN>` |
|
||||||
|
|
||||||
|
Empty `UPLOAD_TOKEN` disables uploads (fail closed). File download stays
|
||||||
|
on `GET /jobs/{id}/files/{name}`.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
# set UPLOAD_TOKEN (python3 -c "import secrets; print(secrets.token_urlsafe(32))")
|
||||||
# local: set DATA_HOST_DIR=./data
|
# local: set DATA_HOST_DIR=./data
|
||||||
# prod: leave unset (defaults to the Syncthing share path)
|
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Drop a photo into `incoming/` (via Syncthing or locally). After a few
|
Share a photo into the Android app, or drop one into `incoming/` locally.
|
||||||
seconds (poll interval + processing time) it shows up on the web UI; flat
|
After a few seconds it shows up on the web UI.
|
||||||
files land in `variants/` and `intermediates/` for the phone.
|
|
||||||
|
Android build: [`android/README.md`](android/README.md).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -71,13 +83,14 @@ pytest -q # unit tests (no gmic/rembg)
|
|||||||
|
|
||||||
CI (`.gitea/workflows/ci.yml`): ruff + pytest on push/PR.
|
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`)
|
## Configuration (`.env`, see `.env.example`)
|
||||||
|
|
||||||
| Var | Default | Notes |
|
| Var | Default | Notes |
|
||||||
|-----|---------|-------|
|
|-----|---------|-------|
|
||||||
| `DATA_HOST_DIR` | `/home/frank/sync.schwenk.online/data/livef12` | Syncthing share root; use `./data` locally |
|
| `DATA_HOST_DIR` | `/home/frank/sync.schwenk.online/data/livef12` | data root; use `./data` locally |
|
||||||
|
| `UPLOAD_TOKEN` | empty | required for `POST /api/jobs`; empty disables upload |
|
||||||
| `OUTPUT_COUNT` | `3` | Variants generated per uploaded photo |
|
| `OUTPUT_COUNT` | `3` | Variants generated per uploaded photo |
|
||||||
| `BLEND_OPACITY` | `30%` | Default blend opacity for auto-generated variants |
|
| `BLEND_OPACITY` | `30%` | Default blend opacity for auto-generated variants |
|
||||||
| `FILTER_TIMEOUT` | `120` | Seconds before a single gmic call is killed |
|
| `FILTER_TIMEOUT` | `120` | Seconds before a single gmic call is killed |
|
||||||
@@ -90,17 +103,13 @@ Architecture notes: `ARCHITECTURE.md`. Cleanup log: `CLEANUP_PLAN.md`.
|
|||||||
|
|
||||||
- **Firewall:** Traefik must already route `live.f12.rocks` — this repo
|
- **Firewall:** Traefik must already route `live.f12.rocks` — this repo
|
||||||
only adds the router labels; it assumes the external `traefik` docker
|
only adds the router labels; it assumes the external `traefik` docker
|
||||||
network exists. No SFTP port needed.
|
network exists.
|
||||||
- **rembg model download:** first background-removal call downloads the
|
- **rembg model download:** first background-removal call downloads the
|
||||||
`u2net` ONNX model (~176 MB) from GitHub. This needs outbound internet
|
`u2net` ONNX model (~176 MB) from GitHub. Cached in `rembg_cache`.
|
||||||
on first run and can take a minute or two depending on the link; the
|
|
||||||
model is cached in the `rembg_cache` named volume afterwards, so
|
|
||||||
restarts don't re-download it.
|
|
||||||
- **Incoming is append-only:** the worker only ever copies out of
|
- **Incoming is append-only:** the worker only ever copies out of
|
||||||
`incoming/` and never deletes or moves anything there — plan disk space
|
`incoming/` and never deletes or moves anything there.
|
||||||
accordingly, or clean up `incoming/` manually between events.
|
|
||||||
- **Sequential processing:** the worker handles one photo at a time
|
- **Sequential processing:** the worker handles one photo at a time
|
||||||
(`cpus: "1.0"`, no concurrency) — fine for an event pace, but a burst of
|
(`cpus: "1.0"`, no concurrency).
|
||||||
uploads will just queue up and get processed in order.
|
|
||||||
- Deploy itself (`docker compose up -d` on the server) is on you — this
|
- Deploy itself (`docker compose up -d` on the server) is on you — this
|
||||||
repo doesn't run it automatically.
|
repo doesn't run it automatically. Remember `UPLOAD_TOKEN` in the
|
||||||
|
server `.env` or the app cannot upload.
|
||||||
|
|||||||
@@ -6,31 +6,33 @@
|
|||||||
|
|
||||||
## One-Liner
|
## One-Liner
|
||||||
|
|
||||||
Event photo pep pipeline: Syncthing drop -> gmic/rembg -> web. Upload a
|
Event photo pep pipeline: organizer Android app -> gmic/rembg -> web.
|
||||||
photo at the event, get back weird/fun filtered variants seconds later.
|
Upload a photo at the event, get back weird/fun filtered variants seconds
|
||||||
|
later.
|
||||||
|
|
||||||
## Vision
|
## Vision
|
||||||
|
|
||||||
At an event (f12 meetup/party), someone drops a photo into a Syncthing
|
At an event (f12 meetup/party), the organizer shares a photo into the
|
||||||
folder from a phone/camera rig. A worker rips the background off, runs it
|
Android app. The phone converts it to a 2000px JPEG, uploads it, a worker
|
||||||
through a random gmic filter/blend chain, and a handful of variants show
|
rips the background off, runs it through a random gmic filter/blend chain,
|
||||||
up on a shared web page almost immediately — no app install, no login, no
|
and a handful of variants show up on a shared web page almost immediately —
|
||||||
waiting for someone to "process the photos later". If a variant is fun,
|
guests need no app, no login, no "process the photos later". If a variant
|
||||||
remix it with different filters right there. Finished jobs sync back to
|
is fun, remix it with different filters right there. Finished jobs land on
|
||||||
the phone via the same Syncthing share.
|
the organizer's phone (gallery + local notification) and stay on the web
|
||||||
|
for everyone with the link.
|
||||||
|
|
||||||
## Audience
|
## Audience
|
||||||
|
|
||||||
Event organizers and attendees at f12 events, on their phones, often on
|
Event organizers (upload via the Android app) and attendees on their
|
||||||
bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link
|
phones, often on bad venue wifi. Nobody signs up, nobody logs in. Guests
|
||||||
(and whoever's syncing photos in) is the whole audience.
|
with the link see everything; only the organizer's app can upload.
|
||||||
|
|
||||||
## Tone & Wording
|
## Tone & Wording
|
||||||
|
|
||||||
- **Voice:** direct, casual, no explaining-the-obvious. It's a fun tool
|
- **Voice:** direct, casual, no explaining-the-obvious. It's a fun tool
|
||||||
for an event, not enterprise software.
|
for an event, not enterprise software.
|
||||||
- **Formality:** du. Short German labels in the UI (Ansehen, Remix,
|
- **Formality:** du. Short German labels in the UI (Ansehen, Remix,
|
||||||
Download, Varianten, Zwischenschritte).
|
Download, Varianten, Zwischenschritte, Warteschlange, Fertig).
|
||||||
- **Error messages:** say what broke, don't blame the visitor — most
|
- **Error messages:** say what broke, don't blame the visitor — most
|
||||||
errors here are gmic filter flakiness, not user error.
|
errors here are gmic filter flakiness, not user error.
|
||||||
- **Forbidden words/phrases:** no corporate buzzwords, no "Erlebnis",
|
- **Forbidden words/phrases:** no corporate buzzwords, no "Erlebnis",
|
||||||
@@ -42,7 +44,7 @@ bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link
|
|||||||
|---------|------|-----|
|
|---------|------|-----|
|
||||||
| Success | `Remix erzeugt.` | `Dein Kunstwerk ist fertig! 🎉` |
|
| Success | `Remix erzeugt.` | `Dein Kunstwerk ist fertig! 🎉` |
|
||||||
| Error | `Foreground-Filter fehlgeschlagen: Timeout nach 120s` | `Etwas ist schiefgelaufen.` |
|
| Error | `Foreground-Filter fehlgeschlagen: Timeout nach 120s` | `Etwas ist schiefgelaufen.` |
|
||||||
| Empty state | `Noch keine Jobs. Bild in den Sync-Ordner legen, dann kurz warten.` | `Hier ist noch nichts los... lade doch was hoch! 😊` |
|
| Empty state | `Noch keine Jobs. Foto in der App teilen, dann kurz warten.` | `Hier ist noch nichts los... lade doch was hoch! 😊` |
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
@@ -54,46 +56,51 @@ bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link
|
|||||||
|
|
||||||
## Non-Goals
|
## Non-Goals
|
||||||
|
|
||||||
- No web auth / accounts / login product — anyone with the link sees
|
- No web auth / accounts / login product — anyone with the link **sees**
|
||||||
everything, by design, for this event tool.
|
everything, by design. Upload is a separate shared token in the
|
||||||
- No camera-to-phone client in this repo — upload path is Syncthing
|
organizer app, not a user account.
|
||||||
(phone/folder setup is out of band).
|
- No Play Store / FCM / ntfy dependency. Notifications are local Android.
|
||||||
- No beamer/projector product built into this repo.
|
- No beamer/projector product built into this repo.
|
||||||
- Not a DAM (digital asset manager) — no albums, tagging, search,
|
- Not a DAM (digital asset manager) — no albums, tagging, search,
|
||||||
retention policies. `incoming/`, `variants/`, and `intermediates/`
|
retention policies. `incoming/`, `variants/`, and `intermediates/`
|
||||||
are the whole data model.
|
are the whole data model.
|
||||||
- No auto-deletion of incoming uploads. Ever. The worker only reads/copies.
|
- No auto-deletion of incoming uploads. Ever. The worker only reads/copies.
|
||||||
- No Syncthing container in this compose — Syncthing runs on the host.
|
- No Syncthing container in this compose. A host Syncthing share can
|
||||||
|
still write `incoming/` as a fallback; it is not the product path.
|
||||||
|
|
||||||
## Infrastructure (project-local)
|
## Infrastructure (project-local)
|
||||||
|
|
||||||
- Workdir on server: `/home/frank/live.f12.rocks`
|
- Workdir on server: `/home/frank/live.f12.rocks`
|
||||||
- Data volume (Syncthing share): `${DATA_HOST_DIR}` — prod default
|
- Data volume: `${DATA_HOST_DIR}` — prod default
|
||||||
`/home/frank/sync.schwenk.online/data/livef12`, local override `./data`.
|
`/home/frank/sync.schwenk.online/data/livef12`, local override `./data`.
|
||||||
Bind-mounted into `worker` and `web` at `/data` (see `compose.yml`).
|
Bind-mounted into `worker` and `web` at `/data` (see `compose.yml`).
|
||||||
- Layout under the share:
|
- Layout:
|
||||||
- `incoming/` — phone drop
|
- `incoming/` — HTTP upload (and optional Syncthing drop)
|
||||||
- `variants/` — `{stem}_original.jpg`, `{stem}_rembg.png`, `{stem}_vN.png`
|
- `variants/` — `{stem}_original.jpg`, `{stem}_rembg.png`, `{stem}_vN.png`
|
||||||
- `intermediates/` — `{stem}_vN_*.png` (incl. `_post_0` …)
|
- `intermediates/` — `{stem}_vN_*.png` (incl. `_post_0` …)
|
||||||
- `meta/` — web bookkeeping JSON (optional `.stignore` on phone)
|
- `meta/` — status + manifest JSON
|
||||||
- `processed.json`
|
- `processed.json`
|
||||||
- Domain: `live.f12.rocks`, routed via the shared external `traefik`
|
- Domain: `live.f12.rocks`, routed via the shared external `traefik`
|
||||||
network, TLS via `myresolver` (see `INFRASTRUCTURE.md`).
|
network, TLS via `myresolver` (see `INFRASTRUCTURE.md`).
|
||||||
- rembg model cache lives in the named Docker volume `rembg_cache`, not
|
- rembg model cache lives in the named Docker volume `rembg_cache`.
|
||||||
under the Syncthing share — container state, not event data.
|
- Organizer Android app lives in `android/` (sideload APK).
|
||||||
|
- `UPLOAD_TOKEN` in server `.env` — required for `POST /api/jobs`.
|
||||||
|
|
||||||
## Project-Specific Rules
|
## Project-Specific Rules
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
- Never delete or move files under incoming/ from worker code — Syncthing
|
- Never delete or move files under incoming/ from worker code — the
|
||||||
is the writer there. (reason: uploads are the one copy of the original
|
upload API (and optional Syncthing) is the writer there. (reason:
|
||||||
that exists outside a phone's camera roll)
|
uploads are the one copy of the original that exists outside a
|
||||||
|
phone's camera roll)
|
||||||
- Keep every intermediate image under intermediates/ — don't clean them
|
- Keep every intermediate image under intermediates/ — don't clean them
|
||||||
up automatically. (reason: useful for debugging bad filter picks,
|
up automatically. (reason: useful for debugging bad filter picks,
|
||||||
disk is cheap compared to re-running gmic)
|
disk is cheap compared to re-running gmic)
|
||||||
- gmic/rembg subprocess calls always run under `nice` (see NICE_LEVEL) —
|
- gmic/rembg subprocess calls always run under `nice` (see NICE_LEVEL) —
|
||||||
the worker box needs to stay responsive for other things during an
|
the worker box needs to stay responsive for other things during an
|
||||||
event. (reason: explicit requirement, avoid CPU starvation)
|
event. (reason: explicit requirement, avoid CPU starvation)
|
||||||
|
- POST /api/jobs is fail-closed: empty UPLOAD_TOKEN disables uploads.
|
||||||
|
The public web UI stays read-only.
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -107,3 +107,7 @@ STABLE_WAIT_SECONDS = _float_env("STABLE_WAIT_SECONDS", 2)
|
|||||||
|
|
||||||
# --- Web ---------------------------------------------------------------
|
# --- Web ---------------------------------------------------------------
|
||||||
SITE_TITLE = os.environ.get("SITE_TITLE", "live.f12.rocks")
|
SITE_TITLE = os.environ.get("SITE_TITLE", "live.f12.rocks")
|
||||||
|
# Shared secret for POST /api/jobs. Empty = upload API disabled (fail closed).
|
||||||
|
UPLOAD_TOKEN = os.environ.get("UPLOAD_TOKEN", "")
|
||||||
|
# Hard cap after client-side 2000px JPEG; 25 MB is generous for a bad encode.
|
||||||
|
MAX_UPLOAD_BYTES = _int_env("MAX_UPLOAD_BYTES", 25 * 1024 * 1024)
|
||||||
|
|||||||
+1
-3
@@ -76,9 +76,7 @@ def _write_resized(source: Path, dest: Path, width: int) -> None:
|
|||||||
img = img.resize((width, new_h), Image.Resampling.LANCZOS)
|
img = img.resize((width, new_h), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(dir=dest.parent, suffix=".webp", delete=False) as tmp:
|
||||||
dir=dest.parent, suffix=".webp", delete=False
|
|
||||||
) as tmp:
|
|
||||||
tmp_path = Path(tmp.name)
|
tmp_path = Path(tmp.name)
|
||||||
try:
|
try:
|
||||||
img.save(tmp_path, format="WEBP", quality=78, method=4)
|
img.save(tmp_path, format="WEBP", quality=78, method=4)
|
||||||
|
|||||||
+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}"
|
||||||
+25
-45
@@ -3,9 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||||
@@ -13,40 +11,35 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from . import config, images, pipeline, remix
|
from . import api, config, images, jobs, pipeline, remix
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
|
|
||||||
configure_logging()
|
configure_logging()
|
||||||
logger = logging.getLogger("livef12.web")
|
logger = logging.getLogger("livef12.web")
|
||||||
|
|
||||||
app = FastAPI(title=config.SITE_TITLE)
|
app = FastAPI(title=config.SITE_TITLE)
|
||||||
|
app.include_router(api.router)
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||||
templates.env.globals["img_attrs"] = images.img_attrs
|
templates.env.globals["img_attrs"] = images.img_attrs
|
||||||
templates.env.globals["PAIR_SIZES"] = images.PAIR_SIZES
|
templates.env.globals["PAIR_SIZES"] = images.PAIR_SIZES
|
||||||
templates.env.globals["GRID_SIZES"] = images.GRID_SIZES
|
templates.env.globals["GRID_SIZES"] = images.GRID_SIZES
|
||||||
|
templates.env.globals["status_label"] = jobs.status_label
|
||||||
|
templates.env.globals["stage_label"] = jobs.stage_label
|
||||||
|
templates.env.globals["ACTIVE_STATUSES"] = jobs.ACTIVE_STATUSES
|
||||||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||||
|
|
||||||
# Job id = sanitized source stem (may include dots).
|
|
||||||
_JOB_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_job_id(job_id: str) -> str:
|
def _validate_job_id(job_id: str) -> str:
|
||||||
raw = job_id or ""
|
try:
|
||||||
if not raw.strip() or "/" in raw or "\\" in raw or ".." in raw:
|
return jobs.validate_job_id(job_id)
|
||||||
raise HTTPException(status_code=400, detail="Ungueltige Job-ID")
|
except jobs.JobIdError as exc:
|
||||||
stem = pipeline.sanitize_stem(raw)
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
if not _JOB_ID_RE.match(stem):
|
|
||||||
raise HTTPException(status_code=400, detail="Ungueltige Job-ID")
|
|
||||||
return stem
|
|
||||||
|
|
||||||
|
|
||||||
def _job_exists(job_id: str) -> str:
|
def _job_exists(job_id: str) -> str:
|
||||||
stem = _validate_job_id(job_id)
|
return jobs.require_job_id(job_id)
|
||||||
if pipeline.read_status(stem) is None and not pipeline.job_paths(stem).original.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
|
||||||
return stem
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_job_file(job_id: str, filename: str) -> Path:
|
def _safe_job_file(job_id: str, filename: str) -> Path:
|
||||||
@@ -54,9 +47,9 @@ def _safe_job_file(job_id: str, filename: str) -> Path:
|
|||||||
stem = _validate_job_id(job_id)
|
stem = _validate_job_id(job_id)
|
||||||
name = Path(filename).name
|
name = Path(filename).name
|
||||||
if name != filename or "/" in filename or "\\" in filename or name.startswith("."):
|
if name != filename or "/" in filename or "\\" in filename or name.startswith("."):
|
||||||
raise HTTPException(status_code=400, detail="Ungueltiger Pfad")
|
raise HTTPException(status_code=400, detail="Ungültiger Pfad")
|
||||||
if not name.startswith(f"{stem}_"):
|
if not name.startswith(f"{stem}_"):
|
||||||
raise HTTPException(status_code=400, detail="Ungueltiger Pfad")
|
raise HTTPException(status_code=400, detail="Ungültiger Pfad")
|
||||||
|
|
||||||
for directory in (config.VARIANTS_DIR, config.INTERMEDIATES_DIR):
|
for directory in (config.VARIANTS_DIR, config.INTERMEDIATES_DIR):
|
||||||
candidate = (directory / name).resolve()
|
candidate = (directory / name).resolve()
|
||||||
@@ -67,30 +60,17 @@ def _safe_job_file(job_id: str, filename: str) -> Path:
|
|||||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
||||||
|
|
||||||
|
|
||||||
def list_jobs() -> list[dict[str, Any]]:
|
|
||||||
jobs = []
|
|
||||||
for job_id in pipeline.list_job_ids():
|
|
||||||
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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request) -> HTMLResponse:
|
def index(request: Request) -> HTMLResponse:
|
||||||
|
job_list = jobs.list_jobs()
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"index.html",
|
"index.html",
|
||||||
{"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),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,11 +80,7 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse:
|
|||||||
paths = pipeline.job_paths(stem)
|
paths = pipeline.job_paths(stem)
|
||||||
status = pipeline.read_status(stem) or {}
|
status = pipeline.read_status(stem) or {}
|
||||||
manifest = pipeline.read_manifest(stem) or {"variants": []}
|
manifest = pipeline.read_manifest(stem) or {"variants": []}
|
||||||
intermediates = (
|
intermediates = jobs.list_intermediates(stem)
|
||||||
sorted(p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file())
|
|
||||||
if paths.intermediates.exists()
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -118,6 +94,7 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse:
|
|||||||
"rembg_name": paths.rembg.name if paths.rembg.exists() else None,
|
"rembg_name": paths.rembg.name if paths.rembg.exists() else None,
|
||||||
"has_rembg": paths.rembg.exists(),
|
"has_rembg": paths.rembg.exists(),
|
||||||
"intermediates": intermediates,
|
"intermediates": intermediates,
|
||||||
|
"live": (status.get("status") or "") in jobs.ACTIVE_STATUSES,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,7 +111,7 @@ def job_file(
|
|||||||
if w not in images.SRCSET_WIDTHS:
|
if w not in images.SRCSET_WIDTHS:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Ungueltige Breite (erlaubt: {', '.join(map(str, images.SRCSET_WIDTHS))})",
|
detail=f"Ungültige Breite (erlaubt: {', '.join(map(str, images.SRCSET_WIDTHS))})",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
cached = images.get_or_create_resized(path, w)
|
cached = images.get_or_create_resized(path, w)
|
||||||
@@ -154,7 +131,10 @@ def remix_form(
|
|||||||
stem = _job_exists(job_id)
|
stem = _job_exists(job_id)
|
||||||
paths = pipeline.job_paths(stem)
|
paths = pipeline.job_paths(stem)
|
||||||
if not paths.rembg.exists():
|
if not paths.rembg.exists():
|
||||||
raise HTTPException(status_code=409, detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht moeglich.")
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht möglich.",
|
||||||
|
)
|
||||||
|
|
||||||
assets = pipeline.load_assets()
|
assets = pipeline.load_assets()
|
||||||
options = remix.build_remix_options(assets)
|
options = remix.build_remix_options(assets)
|
||||||
|
|||||||
+33
-4
@@ -396,6 +396,8 @@ def read_status(job_id: str) -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"status": data.get("status", "unknown"),
|
"status": data.get("status", "unknown"),
|
||||||
|
"stage": data.get("stage"),
|
||||||
|
"progress": data.get("progress"),
|
||||||
"created_at": data.get("created_at"),
|
"created_at": data.get("created_at"),
|
||||||
"updated_at": data.get("updated_at"),
|
"updated_at": data.get("updated_at"),
|
||||||
"error": data.get("error"),
|
"error": data.get("error"),
|
||||||
@@ -628,7 +630,15 @@ def process_job(
|
|||||||
paths.intermediates.mkdir(parents=True, exist_ok=True)
|
paths.intermediates.mkdir(parents=True, exist_ok=True)
|
||||||
paths.meta.parent.mkdir(parents=True, exist_ok=True)
|
paths.meta.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
write_status(paths, "processing", source_file=str(source_path.name), source_stem=stem)
|
write_status(
|
||||||
|
paths,
|
||||||
|
"processing",
|
||||||
|
source_file=str(source_path.name),
|
||||||
|
source_stem=stem,
|
||||||
|
stage="preprocess",
|
||||||
|
progress={"current": 0, "total": config.OUTPUT_COUNT},
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
paths = preprocess_original(paths)
|
paths = preprocess_original(paths)
|
||||||
@@ -643,6 +653,12 @@ def process_job(
|
|||||||
}
|
}
|
||||||
write_manifest(paths, manifest)
|
write_manifest(paths, manifest)
|
||||||
|
|
||||||
|
write_status(
|
||||||
|
paths,
|
||||||
|
"processing",
|
||||||
|
stage="rembg",
|
||||||
|
progress={"current": 0, "total": config.OUTPUT_COUNT},
|
||||||
|
)
|
||||||
ok, err = run_rembg(paths.original, paths.rembg)
|
ok, err = run_rembg(paths.original, paths.rembg)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise PipelineError(f"rembg fehlgeschlagen: {err}")
|
raise PipelineError(f"rembg fehlgeschlagen: {err}")
|
||||||
@@ -654,6 +670,12 @@ def process_job(
|
|||||||
|
|
||||||
for i in range(1, config.OUTPUT_COUNT + 1):
|
for i in range(1, config.OUTPUT_COUNT + 1):
|
||||||
variant_id = f"v{i}"
|
variant_id = f"v{i}"
|
||||||
|
write_status(
|
||||||
|
paths,
|
||||||
|
"processing",
|
||||||
|
stage="variant",
|
||||||
|
progress={"current": i, "total": config.OUTPUT_COUNT},
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
entry = compose_variant(
|
entry = compose_variant(
|
||||||
paths,
|
paths,
|
||||||
@@ -700,10 +722,17 @@ def process_job(
|
|||||||
if not manifest["variants"]:
|
if not manifest["variants"]:
|
||||||
raise PipelineError("Keine Variante erfolgreich erzeugt: " + "; ".join(variant_errors))
|
raise PipelineError("Keine Variante erfolgreich erzeugt: " + "; ".join(variant_errors))
|
||||||
|
|
||||||
write_status(paths, "done", variant_errors=variant_errors)
|
done_count = len(manifest["variants"])
|
||||||
|
write_status(
|
||||||
|
paths,
|
||||||
|
"done",
|
||||||
|
stage="done",
|
||||||
|
progress={"current": done_count, "total": config.OUTPUT_COUNT},
|
||||||
|
variant_errors=variant_errors,
|
||||||
|
)
|
||||||
except PipelineError as exc:
|
except PipelineError as exc:
|
||||||
logger.error("[%s] job failed: %s", stem, exc)
|
logger.error("[%s] job failed: %s", stem, exc)
|
||||||
write_status(paths, "error", error=str(exc))
|
write_status(paths, "error", stage="error", error=str(exc))
|
||||||
except Exception as exc: # noqa: BLE001 - keep the worker loop alive
|
except Exception as exc: # noqa: BLE001 - keep the worker loop alive
|
||||||
logger.exception("[%s] unexpected error", stem)
|
logger.exception("[%s] unexpected error", stem)
|
||||||
write_status(paths, "error", error=f"Unerwarteter Fehler: {exc}")
|
write_status(paths, "error", stage="error", error=f"Unerwarteter Fehler: {exc}")
|
||||||
|
|||||||
@@ -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 {
|
.site-header {
|
||||||
padding: 1rem var(--gap);
|
padding: 1rem var(--gap);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-note {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -147,6 +158,33 @@ h2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-heading {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem 0.75rem;
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-heading h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: -0.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.job-card {
|
.job-card {
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -154,6 +192,27 @@ h2 {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-card.is-live {
|
||||||
|
border-color: var(--pending);
|
||||||
|
animation: live-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes live-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(217, 164, 65, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 3px rgba(217, 164, 65, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.job-card.is-live {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.job-card a {
|
.job-card a {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
display: block;
|
display: block;
|
||||||
@@ -390,4 +449,39 @@ body.lightbox-open {
|
|||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
min-height: 2.75rem;
|
min-height: 2.75rem;
|
||||||
min-width: 2.75rem;
|
min-width: 2.75rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 2.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
min-width: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-prev {
|
||||||
|
left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-next {
|
||||||
|
right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-caption {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-42
@@ -3,61 +3,30 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#14161a">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
<title>{% block title %}{{ site_title }}{% endblock %}</title>
|
<title>{% block title %}{{ site_title }}{% endblock %}</title>
|
||||||
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="apple-touch-icon" href="/static/favicon.svg">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body{% if live is defined and live %} data-live="1"{% endif %}{% if job_id is defined %} data-job-id="{{ job_id }}"{% endif %}{% if has_active is defined and has_active %} data-live="1"{% endif %}>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<a class="brand" href="/">{{ site_title }}</a>
|
<a class="brand" href="/">{{ site_title }}</a>
|
||||||
|
<span class="header-note">Event-Pep</span>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="lightbox" class="lightbox" hidden>
|
<div id="lightbox" class="lightbox" hidden>
|
||||||
<button type="button" class="lightbox-close" aria-label="Schliessen">×</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="">
|
<img id="lightbox-img" alt="">
|
||||||
|
<button type="button" class="lightbox-nav lightbox-next" aria-label="Nächstes Bild">›</button>
|
||||||
|
<p id="lightbox-caption" class="lightbox-caption"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="/static/app.js" defer></script>
|
||||||
(function () {
|
|
||||||
var box = document.getElementById("lightbox");
|
|
||||||
var img = document.getElementById("lightbox-img");
|
|
||||||
if (!box || !img) return;
|
|
||||||
|
|
||||||
function openLightbox(src, alt) {
|
|
||||||
img.src = src;
|
|
||||||
img.alt = alt || "";
|
|
||||||
box.hidden = false;
|
|
||||||
document.body.classList.add("lightbox-open");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLightbox() {
|
|
||||||
box.hidden = true;
|
|
||||||
img.removeAttribute("src");
|
|
||||||
document.body.classList.remove("lightbox-open");
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("click", function (e) {
|
|
||||||
var target = e.target;
|
|
||||||
if (!(target instanceof HTMLImageElement)) return;
|
|
||||||
if (!target.classList.contains("lightboxable")) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
var full = target.getAttribute("data-full-src");
|
|
||||||
openLightbox(full || target.currentSrc || target.src, target.alt);
|
|
||||||
});
|
|
||||||
|
|
||||||
box.addEventListener("click", function (e) {
|
|
||||||
if (e.target === box || (e.target && e.target.classList.contains("lightbox-close"))) {
|
|
||||||
closeLightbox();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("keydown", function (e) {
|
|
||||||
if (e.key === "Escape" && !box.hidden) closeLightbox();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,28 +2,34 @@
|
|||||||
{% block title %}{{ site_title }}{% endblock %}
|
{% block title %}{{ site_title }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if not jobs %}
|
{% if not jobs %}
|
||||||
<p class="empty-state">Noch keine Jobs. Bild in den Sync-Ordner legen, dann kurz warten.</p>
|
<p class="empty-state">Noch keine Jobs. Foto in der App teilen, dann kurz warten.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<ul class="job-grid">
|
<ul class="job-grid">
|
||||||
{% for job in jobs %}
|
{% for job in jobs %}
|
||||||
<li class="job-card">
|
<li class="job-card{% if job.status in ACTIVE_STATUSES %} is-live{% endif %}">
|
||||||
<a href="/jobs/{{ job.job_id }}">
|
<a href="/jobs/{{ job.job_id }}">
|
||||||
{% if job.thumbnail %}
|
{% if job.thumbnail %}
|
||||||
{% set img = img_attrs(job.job_id, job.thumbnail) %}
|
{% set img = img_attrs(job.job_id, job.thumbnail) %}
|
||||||
<img class="thumb lightboxable"
|
<img class="thumb"
|
||||||
src="{{ img.src }}"
|
src="{{ img.src }}"
|
||||||
srcset="{{ img.srcset }}"
|
srcset="{{ img.srcset }}"
|
||||||
sizes="{{ img.sizes }}"
|
sizes="{{ img.sizes }}"
|
||||||
data-full-src="{{ img.full_src }}"
|
|
||||||
alt="Variante von Job {{ job.job_id }}"
|
alt="Variante von Job {{ job.job_id }}"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async">
|
decoding="async">
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status }}</span>
|
<span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status_label }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="job-meta">
|
<span class="job-meta">
|
||||||
<span class="job-id">{{ job.job_id }}</span>
|
<span class="job-id">{{ job.job_id }}</span>
|
||||||
<span class="job-status status-{{ job.status }}">{{ job.status }} · {{ 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>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+15
-1
@@ -3,8 +3,20 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="back-link"><a href="/">← Alle Jobs</a></p>
|
<p class="back-link"><a href="/">← Alle Jobs</a></p>
|
||||||
|
|
||||||
|
<div class="job-heading">
|
||||||
<h1>Job {{ job_id }}</h1>
|
<h1>Job {{ job_id }}</h1>
|
||||||
<p class="job-status status-{{ status.status }}">Status: {{ status.status or "unbekannt" }}</p>
|
<p class="job-status status-{{ status.status }}">
|
||||||
|
{{ status_label(status.status) }}
|
||||||
|
{% if status.status in ACTIVE_STATUSES %}
|
||||||
|
· {{ stage_label(status.status, status.stage, status.progress) }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% if status.status in ACTIVE_STATUSES and status.progress and status.progress.total %}
|
||||||
|
<div class="progress" aria-hidden="true">
|
||||||
|
<span style="width: {{ (status.progress.current / status.progress.total * 100) | int }}%"></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if status.error %}
|
{% if status.error %}
|
||||||
<p class="error-box">Fehler: {{ status.error }}</p>
|
<p class="error-box">Fehler: {{ status.error }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -28,6 +40,8 @@
|
|||||||
<a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a>
|
<a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">Original kommt, sobald der Worker greift.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if has_rembg and rembg_name %}
|
{% if has_rembg and rembg_name %}
|
||||||
{% set img = img_attrs(job_id, rembg_name, sizes=PAIR_SIZES) %}
|
{% set img = img_attrs(job_id, rembg_name, sizes=PAIR_SIZES) %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Remix {{ job_id }} — {{ site_title }}{% endblock %}
|
{% block title %}Remix {{ job_id }} — {{ site_title }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="back-link"><a href="/jobs/{{ job_id }}">← 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>
|
<h1>Remix · Job {{ job_id }}</h1>
|
||||||
{% if from_variant %}
|
{% if from_variant %}
|
||||||
|
|||||||
+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
|
# ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12} -> /data
|
||||||
# worker watches /data/incoming, writes /data/variants + /data/intermediates
|
# worker watches /data/incoming, writes /data/variants + /data/intermediates
|
||||||
# web reads meta/ + image dirs, writes remix variants
|
# web serves HTML + /api (POST /api/jobs needs UPLOAD_TOKEN)
|
||||||
#
|
#
|
||||||
# Syncthing itself runs on the host (not in this compose). Copy .env.example
|
# Copy .env.example to .env for local overrides (DATA_HOST_DIR=./data,
|
||||||
# to .env for local overrides (e.g. DATA_HOST_DIR=./data). Never commit .env.
|
# UPLOAD_TOKEN=...). Never commit .env.
|
||||||
|
|
||||||
x-app-env: &app-env
|
x-app-env: &app-env
|
||||||
DATA_DIR: /data
|
DATA_DIR: /data
|
||||||
@@ -53,6 +53,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app-env
|
<<: *app-env
|
||||||
SITE_TITLE: live.f12.rocks
|
SITE_TITLE: live.f12.rocks
|
||||||
|
UPLOAD_TOKEN: ${UPLOAD_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data
|
- ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data
|
||||||
- ./assets:/data/assets:ro
|
- ./assets:/data/assets:ro
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "livef12rocks"
|
name = "livef12rocks"
|
||||||
version = "0.1.0"
|
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"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11,<3.12"
|
requires-python = ">=3.11,<3.12"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
+4
-1
@@ -93,7 +93,10 @@ def test_job_file_srcset_resize(client: TestClient, data_dir: Path) -> None:
|
|||||||
assert index.status_code == 200
|
assert index.status_code == 200
|
||||||
assert f"/jobs/{stem}/files/{png.name}?w=640" in index.text
|
assert f"/jobs/{stem}/files/{png.name}?w=640" in index.text
|
||||||
assert "srcset=" in index.text
|
assert "srcset=" in index.text
|
||||||
assert "data-full-src=" 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:
|
def test_img_attrs_builds_srcset() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user