Compare commits

13 Commits

Author SHA1 Message Date
Frank Schwenk 5dcc3717c7 Merge branch 'fix/concurrency-and-cleanup'
CI / lint-and-test (push) Failing after 11s
Meta lock hardening plus responsive WebP srcsets for the web UI.
2026-08-01 13:04:41 +02:00
Frank Schwenk dec74a46d7 feat: serve responsive WebP srcsets for faster mobile browsing
On-demand ?w= resize with a disk cache under webcache/; downloads stay full-resolution.

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:09:53 +02:00
34 changed files with 1164 additions and 310 deletions
+3 -1
View File
@@ -2,7 +2,8 @@
# --- Host data path (Syncthing share root) ------------------------------ # --- Host data path (Syncthing share root) ------------------------------
# Prod default (compose.yml): /home/frank/sync.schwenk.online/data/livef12 # Prod default (compose.yml): /home/frank/sync.schwenk.online/data/livef12
# Layout under that path: incoming/ variants/ intermediates/ meta/ # Layout under that path: incoming/ variants/ intermediates/ meta/ webcache/
# (webcache = resized WebP for srcset; safe to wipe / add to phone .stignore)
# Local override when the absolute path does not exist: # Local override when the absolute path does not exist:
# DATA_HOST_DIR=./data # DATA_HOST_DIR=./data
# DATA_HOST_DIR=/home/frank/sync.schwenk.online/data/livef12 # DATA_HOST_DIR=/home/frank/sync.schwenk.online/data/livef12
@@ -17,6 +18,7 @@ FILTER_TIMEOUT=120
FILTER_TIMEOUT_LONG=300 FILTER_TIMEOUT_LONG=300
MAX_FILTER_ATTEMPTS=8 MAX_FILTER_ATTEMPTS=8
MAX_EDGE_PX=2000 MAX_EDGE_PX=2000
PREPROCESS_TIMEOUT=120
# birefnet-general OOMs on the ~8GB box; u2net + alpha ≈ `rembg i -a` # birefnet-general OOMs on the ~8GB box; u2net + alpha ≈ `rembg i -a`
REMBG_MODEL=u2net REMBG_MODEL=u2net
REMBG_ALPHA=1 REMBG_ALPHA=1
+34
View File
@@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Ruff lint
run: ruff check app/ tests/
- name: Ruff format check
run: ruff format --check app/ tests/
- name: Pytest
run: pytest -q
+6 -7
View File
@@ -4,21 +4,20 @@
# Runtime data (bind-mounted / Syncthing share — never in git) # Runtime data (bind-mounted / Syncthing share — never in git)
/data/ /data/
# Local rembg/withoutbg comparison scratch
/compare-bg/inputs/
/compare-bg/outputs/
/compare-bg/model-cache/
/compare-bg/compare.log
.venv-compare/
# Python # Python
__pycache__/ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
.venv/ .venv/
.venv-*/
*.egg-info/
dist/
build/
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
.ruff_cache/ .ruff_cache/
htmlcov/
.coverage
# OS / editor noise # OS / editor noise
.DS_Store .DS_Store
+1
View File
@@ -0,0 +1 @@
3.11
+79
View File
@@ -0,0 +1,79 @@
# Architecture — live.f12.rocks
## Overview
```
Phone/Syncthing Docker host
─────────────── ───────────
incoming/ ──watch──► worker ──► variants/ + intermediates/
│ │
└── rembg + gmic pipeline
meta/{stem}.json ◄──────────────────────┘
web (FastAPI) ──► browser UI + remix POST
```
Two containers share one image (`Dockerfile`) and one bind-mounted data
directory (`DATA_HOST_DIR``/data`). Syncthing runs on the host, not in
compose.
## Python modules
| Module | Role |
|--------|------|
| `app/worker.py` | Poll `incoming/`, copy to `variants/`, run `process_job()` |
| `app/pipeline.py` | gmic/rembg orchestration, meta/manifest I/O, variant compose |
| `app/remix.py` | User-selected remix variant (one-shot compose + manifest append) |
| `app/main.py` | FastAPI routes, Jinja templates, static files |
| `app/config.py` | Environment → constants (paths, timeouts, tuning) |
| `app/rembg_cli.py` | Subprocess entry for rembg (avoids heavy `rembg[cli]` extra) |
| `app/io_utils.py` | Atomic JSON read/write |
| `app/env_utils.py` | Shared bool env parsing |
| `app/logging_config.py` | Entry-point logging setup |
## Data model
One **job** = one sanitized stem (from incoming filename). Flat files:
- `variants/{stem}_original.jpg` — preprocessed copy
- `variants/{stem}_rembg.png` — background removal
- `variants/{stem}_vN.png` — final composed images
- `intermediates/{stem}_vN_*.png` — step images (kept for debugging)
- `meta/{stem}.json` — status + manifest (variants list, filter metadata)
- `processed.json` — worker bookkeeping (incoming path → handled)
## Pipeline origin
The gmic filter/blend chain is ported from an external reference script
`make_random.py` (not in this repo). `config.POST_FILTERS` and asset lists
under `assets/` mirror that script's behaviour.
## Background removal
Production uses `u2net` + alpha matting via `app/rembg_cli.py`
(see `config.REMBG_MODEL`).
## Known limitations (intentional)
- **No web auth** — anyone with the link sees all jobs (event tool).
- **Sequential worker** — one photo at a time; burst uploads queue.
- **Meta JSON** — worker and web share `meta/*.json` via locked
read-modify-write (`fcntl.flock`); variants are merged by id so
concurrent remix during processing does not clobber entries.
- **Incoming is append-only** — worker never deletes from `incoming/`.
## Dev / test
```bash
python3.11 -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
ruff check app/ tests/
pytest -q
```
Full stack (gmic/rembg): `docker compose up --build` with `DATA_HOST_DIR=./data`.
Python **3.11** matches the Docker image; host 3.14+ cannot install pinned
`onnxruntime` — use Docker or a 3.11 venv for parity.
+68
View File
@@ -0,0 +1,68 @@
# Cleanup Plan — live.f12.rocks
Phase 0 analysis (2026-07-18). Working branch: `cleanup/code-quality`.
## Project snapshot
| Area | Detail |
|------|--------|
| Stack | Python 3.11 (Docker), FastAPI, gmic, rembg, Docker Compose |
| Size | ~7 Python modules under `app/`, ~1.3k LOC |
| Tests | None (pre-cleanup) |
| Lint/format | None (pre-cleanup) |
| CI | None |
| Lockfile | Soft pins in `requirements.txt` only |
## Findings (prioritized)
1. **No test/CI/lint baseline** — high risk for refactors
2. **`pipeline.py` god module** (642 LOC) — medium risk; defer split until tests exist
3. **Meta JSON race** (worker + web concurrent writes) — documented, not fixed (behavior change)
4. **Dead code**: `blend_layers()` unused
5. **Duplication**: atomic JSON write ×3, `basicConfig` ×2, bool-env parsing ×2, compose env block ×2
6. **Double meta read** on index page (`read_status` + `read_manifest`)
7. ~~`compare-bg/`~~ — removed (was a standalone experiment)
8. **`make_random.py`** — external reference only, document in ARCHITECTURE
## Risk matrix
| Area | Risk | Action |
|------|------|--------|
| Formatter/linter setup | Low | Phase 1 |
| Dead code removal | Low | Phase 1 |
| `.gitignore` / `.python-version` | Low | Phase 1 |
| Extract `io_utils`, `env_utils` | Low | Phase 2 |
| Compose env DRY (YAML anchor) | Low | Phase 2 |
| Meta read dedup in `main.py` | Low | Phase 3 |
| Magic numbers → config constants | Low | Phase 3 |
| Characterization tests | Low | Phase 5 (before deeper refactors) |
| `pipeline.py` split | Medium | **Deferred** — tests first, split later |
| Meta file locking | MediumHigh | **Deferred** — behavior change |
| Dependency major bumps | Medium | List only, no auto-bump |
## Roadmap & commits
| Phase | Step | Commit theme |
|-------|------|--------------|
| 0 | This file | `docs: add cleanup plan from phase-0 analysis` |
| 1 | pyproject.toml + ruff | `chore: add pyproject.toml with ruff and pytest config` |
| 1 | ruff format | `style: apply ruff format to Python sources` |
| 1 | dead code + gitignore | `chore: remove dead code and extend gitignore` |
| 2 | io_utils extraction | `refactor: extract atomic JSON helpers to io_utils` |
| 2 | env_utils + logging | `refactor: share env-bool parsing and logging setup` |
| 2 | compose DRY | `chore: deduplicate compose environment blocks` |
| 3 | config constants | `refactor: replace magic numbers with named constants` |
| 3 | meta read dedup | `refactor: read job meta once in list_jobs` |
| 4 | dev requirements | `chore: add dev requirements and python version pin` |
| 4 | Gitea CI | `ci: add lint and test workflow` |
| 5 | tests | `test: add characterization tests for core modules` |
| 5 | docs | `docs: update README and add ARCHITECTURE` |
## Conscious non-goals
- No `pipeline.py` module split in this pass
- No meta file locking / concurrency fix
- No dependency major version bumps
- ~~No changes to `compare-bg/` experiment~~ (later removed entirely)
- No deploy CI (manual `docker compose` on boka stays)
- No behavior changes to gmic/rembg processing logic
+90
View File
@@ -0,0 +1,90 @@
# Cleanup Report — live.f12.rocks
Branch: `cleanup/code-quality` (local only, not pushed).
Completed: 2026-07-18.
## Summary
Incremental code-quality cleanup across 10 commits. **No intentional
behavior changes** to the photo pipeline, worker loop, or web UX.
## What changed
| Step | What | Why | Risk | Verification |
|------|------|-----|------|--------------|
| Phase 0 | `CLEANUP_PLAN.md` | Analysis + roadmap | Low | — |
| Tooling | `pyproject.toml`, `requirements-dev.txt`, `.python-version` | Ruff + pytest baseline, pin 3.11 | Low | `ruff check`, `pytest` |
| Style | Ruff format entire `app/` | Consistent style | Low | `ruff format --check` |
| Dead code | Removed unused `blend_layers()` | Less noise | Low | grep + pytest |
| `.gitignore` | Build/coverage/venv patterns | Keep repo clean | Low | — |
| `io_utils` | Atomic JSON helpers | DRY meta/processed I/O | Low | `test_io_utils`, pipeline/worker tests |
| `env_utils` / `logging_config` | Shared bool env + logging | DRY | Low | `test_env_utils`, pytest |
| `compose.yml` | YAML anchor for env | DRY, fewer drift bugs | Low | `docker compose config` (manual) |
| Config | `PREPROCESS_TIMEOUT` constant | Named magic number | Low | default still 120s |
| `read_meta()` | Public meta accessor; `list_jobs` reads once | Perf + clarity | Low | `test_pipeline`, `test_main` |
| Tests | 33 characterization tests | Safe refactor baseline | Low | `pytest -q` |
| CI | `.gitea/workflows/ci.yml` | Lint + test on push/PR | Low | workflow syntax |
| Docs | `README.md`, `ARCHITECTURE.md` | Onboarding | Low | — |
## What was NOT changed (and why)
| Item | Reason |
|------|--------|
| `pipeline.py` module split | Medium risk without broader integration tests; deferred |
| Dependency major bumps | Per plan — list only, no auto-bump |
| Deploy CI | Manual deploy on boka stays; STANDARDS says ask first |
## Follow-ups (later branch)
Meta locking, timeout thread-local, TemplateResponse migration, stem/validate
hardening, and removal of `compare-bg/` were done on a subsequent branch
(`fix/concurrency-and-cleanup`).
## Bugs noticed (historical — fixed later)
1. **Meta JSON race** — fixed with `fcntl.flock` + variant merge-by-id.
2. **`_filter_timeout_override` global** — fixed with `threading.local`.
3. **`sanitize_stem("a/b")``"b"`** — fixed (separators replaced before `Path.stem`).
4. **`_validate_job_id`** — rejection path restored for path-like raw IDs.
## Dependency notes (suggestions only)
| Package | Current pin | Note |
|---------|-------------|------|
| `fastapi` | `<0.117` | 0.117+ available; test before bump |
| `onnxruntime` | `<1.20` | Very old pin; rembg may allow newer in future |
| `pillow` | `<11` | Pillow 11 exists; verify rembg compatibility |
No lockfile added — soft pins kept to avoid changing Docker resolve behavior.
## How to verify locally
```bash
git checkout cleanup/code-quality
python3.11 -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
ruff check app/ tests/
pytest -q
# Full stack:
cp .env.example .env # set DATA_HOST_DIR=./data
docker compose up --build
```
## Commits on branch
```
docs: add cleanup plan from phase-0 analysis
chore: add pyproject.toml with ruff and pytest config
style: apply ruff format and extend gitignore
refactor: extract atomic JSON helpers to io_utils
refactor: share env-bool parsing and logging setup
chore: deduplicate compose environment blocks
refactor: named preprocess timeout and single meta read
test: add characterization tests for core modules (+ CI workflow)
docs: update README and add ARCHITECTURE
```
## External references
- `make_random.py` — external reference script, not vendored
- Submodule/symlink: none
+20
View File
@@ -55,6 +55,24 @@ Drop a photo into `incoming/` (via Syncthing or locally). After a few
seconds (poll interval + processing time) it shows up on the web UI; flat seconds (poll interval + processing time) it shows up on the web UI; flat
files land in `variants/` and `intermediates/` for the phone. files land in `variants/` and `intermediates/` for the phone.
## Development
Python **3.11** (same as `Dockerfile`). On a host with newer Python, use
Docker or a 3.11 venv — pinned `onnxruntime` does not publish wheels for 3.14+.
```bash
python3.11 -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
ruff check app/ tests/ # lint
ruff format app/ tests/ # format
pytest -q # unit tests (no gmic/rembg)
```
CI (`.gitea/workflows/ci.yml`): ruff + pytest on push/PR.
Architecture notes: `ARCHITECTURE.md`. Cleanup log: `CLEANUP_PLAN.md`.
## Configuration (`.env`, see `.env.example`) ## Configuration (`.env`, see `.env.example`)
| Var | Default | Notes | | Var | Default | Notes |
@@ -64,6 +82,8 @@ files land in `variants/` and `intermediates/` for the phone.
| `BLEND_OPACITY` | `30%` | Default blend opacity for auto-generated variants | | `BLEND_OPACITY` | `30%` | Default blend opacity for auto-generated variants |
| `FILTER_TIMEOUT` | `120` | Seconds before a single gmic call is killed | | `FILTER_TIMEOUT` | `120` | Seconds before a single gmic call is killed |
| `MAX_FILTER_ATTEMPTS` | `8` | Random-filter retry budget per bg/fg pick | | `MAX_FILTER_ATTEMPTS` | `8` | Random-filter retry budget per bg/fg pick |
| `MAX_EDGE_PX` | `2000` | Longest edge after ImageMagick preprocess |
| `PREPROCESS_TIMEOUT` | `120` | Seconds for preprocess step |
| `NICE_LEVEL` | `18` | `nice -n` level for gmic/rembg subprocesses | | `NICE_LEVEL` | `18` | `nice -n` level for gmic/rembg subprocesses |
## Deploy notes / caveats ## Deploy notes / caveats
+7 -1
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from .env_utils import env_bool
def _int_env(name: str, default: int) -> int: def _int_env(name: str, default: int) -> int:
try: try:
@@ -32,6 +34,8 @@ INTERMEDIATES_DIR = DATA_DIR / "intermediates"
# Web bookkeeping (status + manifest). Not needed on the phone — ignore via # Web bookkeeping (status + manifest). Not needed on the phone — ignore via
# Syncthing .stignore if desired. # Syncthing .stignore if desired.
META_DIR = DATA_DIR / "meta" META_DIR = DATA_DIR / "meta"
# Resized WebP cache for srcset (web container only; safe to wipe / .stignore).
WEB_CACHE_DIR = DATA_DIR / "webcache"
ASSETS_DIR = DATA_DIR / "assets" ASSETS_DIR = DATA_DIR / "assets"
PROCESSED_FILE = DATA_DIR / "processed.json" PROCESSED_FILE = DATA_DIR / "processed.json"
@@ -55,13 +59,15 @@ MAX_FILTER_ATTEMPTS = _int_env("MAX_FILTER_ATTEMPTS", 8)
# Longest edge after preprocess (ImageMagick). Keeps rembg/gmic sane on # Longest edge after preprocess (ImageMagick). Keeps rembg/gmic sane on
# large camera JPEGs. # large camera JPEGs.
MAX_EDGE_PX = _int_env("MAX_EDGE_PX", 2000) MAX_EDGE_PX = _int_env("MAX_EDGE_PX", 2000)
# ImageMagick preprocess step timeout (resize + orient).
PREPROCESS_TIMEOUT = _int_env("PREPROCESS_TIMEOUT", 120)
# Generous on purpose: the very first rembg call also downloads the model, # Generous on purpose: the very first rembg call also downloads the model,
# which can take a while depending on the link. # which can take a while depending on the link.
REMBG_TIMEOUT = _int_env("REMBG_TIMEOUT", 600) REMBG_TIMEOUT = _int_env("REMBG_TIMEOUT", 600)
# birefnet-general OOMs on the ~8GB event box; u2net + alpha matting fits # birefnet-general OOMs on the ~8GB event box; u2net + alpha matting fits
# and is the practical equivalent of `rembg i -a`. # and is the practical equivalent of `rembg i -a`.
REMBG_MODEL = os.environ.get("REMBG_MODEL", "u2net") REMBG_MODEL = os.environ.get("REMBG_MODEL", "u2net")
REMBG_ALPHA = os.environ.get("REMBG_ALPHA", "1").strip().lower() in {"1", "true", "yes", "on"} REMBG_ALPHA = env_bool("REMBG_ALPHA", True)
NICE_LEVEL = _int_env("NICE_LEVEL", 18) NICE_LEVEL = _int_env("NICE_LEVEL", 18)
# gmic CLI binary, override for local dev if not on PATH. rembg has no # gmic CLI binary, override for local dev if not on PATH. rembg has no
+12
View File
@@ -0,0 +1,12 @@
"""Shared environment-variable parsing."""
from __future__ import annotations
import os
def env_bool(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
+89
View File
@@ -0,0 +1,89 @@
"""On-demand web image resizing with disk cache (srcset helpers)."""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from urllib.parse import quote
from PIL import Image
from . import config
logger = logging.getLogger("livef12.images")
# Allowed ?w= values for /jobs/.../files/... — keep in sync with templates.
SRCSET_WIDTHS: tuple[int, ...] = (320, 640, 960, 1280)
# Default sizes for the 1/2/4-column job + variant grids.
GRID_SIZES = "(min-width: 960px) 25vw, (min-width: 640px) 50vw, 100vw"
# Original/rembg pair is always two columns.
PAIR_SIZES = "(min-width: 640px) 50vw, 50vw"
def file_url(job_id: str, filename: str, width: int | None = None) -> str:
base = f"/jobs/{quote(job_id, safe='')}/files/{quote(filename, safe='')}"
if width is None:
return base
return f"{base}?w={width}"
def srcset_for(job_id: str, filename: str) -> str:
return ", ".join(f"{file_url(job_id, filename, w)} {w}w" for w in SRCSET_WIDTHS)
def img_attrs(job_id: str, filename: str, *, sizes: str = GRID_SIZES) -> dict[str, str]:
"""Attrs for responsive <img>: src, srcset, sizes, full_src (lightbox)."""
return {
"src": file_url(job_id, filename, 640),
"srcset": srcset_for(job_id, filename),
"sizes": sizes,
# Lightbox: large webp, not the multi-MB master PNG/JPEG.
"full_src": file_url(job_id, filename, SRCSET_WIDTHS[-1]),
}
def get_or_create_resized(source: Path, width: int) -> Path:
"""Return a cached WebP whose longest edge is at most `width` (no upscale)."""
if width not in SRCSET_WIDTHS:
raise ValueError(f"unsupported width: {width}")
if not source.is_file():
raise FileNotFoundError(source)
config.WEB_CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_path = config.WEB_CACHE_DIR / f"{source.name}.w{width}.webp"
try:
if cache_path.is_file() and cache_path.stat().st_mtime >= source.stat().st_mtime:
return cache_path
except OSError:
pass
_write_resized(source, cache_path, width)
return cache_path
def _write_resized(source: Path, dest: Path, width: int) -> None:
with Image.open(source) as img:
img.load()
if img.mode == "P":
img = img.convert("RGBA" if "transparency" in img.info else "RGB")
elif img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
if img.width > width:
new_h = max(1, round(img.height * (width / img.width)))
img = img.resize((width, new_h), Image.Resampling.LANCZOS)
dest.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
dir=dest.parent, suffix=".webp", delete=False
) as tmp:
tmp_path = Path(tmp.name)
try:
img.save(tmp_path, format="WEBP", quality=78, method=4)
tmp_path.replace(dest)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
logger.debug("cached %s -> %s (%dx%d)", source.name, dest.name, img.width, img.height)
+42
View File
@@ -0,0 +1,42 @@
"""Small shared I/O helpers."""
from __future__ import annotations
import fcntl
import json
import os
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
def read_json(path: Path, *, default: Any = None) -> Any:
"""Read JSON from *path*, returning *default* when missing or corrupt."""
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return default
def write_json_atomic(path: Path, data: Any) -> None:
"""Write JSON via a temp file and atomic replace."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
@contextmanager
def file_lock(lock_path: Path) -> Iterator[None]:
"""Exclusive advisory lock via ``fcntl.flock`` (cross-process)."""
lock_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
+11
View File
@@ -0,0 +1,11 @@
"""Shared logging setup for entry-point modules."""
from __future__ import annotations
import logging
_LOG_FORMAT = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
def configure_logging(level: int = logging.INFO) -> None:
logging.basicConfig(level=level, format=_LOG_FORMAT)
+42 -16
View File
@@ -13,15 +13,19 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from . import config, pipeline, remix from . import config, images, pipeline, remix
from .logging_config import configure_logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s") configure_logging()
logger = logging.getLogger("livef12.web") logger = logging.getLogger("livef12.web")
app = FastAPI(title=config.SITE_TITLE) app = FastAPI(title=config.SITE_TITLE)
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
templates.env.globals["img_attrs"] = images.img_attrs
templates.env.globals["PAIR_SIZES"] = images.PAIR_SIZES
templates.env.globals["GRID_SIZES"] = images.GRID_SIZES
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 = sanitized source stem (may include dots).
@@ -29,7 +33,10 @@ _JOB_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
def _validate_job_id(job_id: str) -> str: def _validate_job_id(job_id: str) -> str:
stem = pipeline.sanitize_stem(job_id) raw = job_id or ""
if not raw.strip() or "/" in raw or "\\" in raw or ".." in raw:
raise HTTPException(status_code=400, detail="Ungueltige Job-ID")
stem = pipeline.sanitize_stem(raw)
if not _JOB_ID_RE.match(stem): if not _JOB_ID_RE.match(stem):
raise HTTPException(status_code=400, detail="Ungueltige Job-ID") raise HTTPException(status_code=400, detail="Ungueltige Job-ID")
return stem return stem
@@ -63,15 +70,15 @@ def _safe_job_file(job_id: str, filename: str) -> Path:
def list_jobs() -> list[dict[str, Any]]: def list_jobs() -> list[dict[str, Any]]:
jobs = [] jobs = []
for job_id in pipeline.list_job_ids(): for job_id in pipeline.list_job_ids():
status = pipeline.read_status(job_id) or {} data = pipeline.read_meta(job_id) or {}
manifest = pipeline.read_manifest(job_id) or {} variants = data.get("variants") or []
jobs.append( jobs.append(
{ {
"job_id": job_id, "job_id": job_id,
"status": status.get("status", "unknown"), "status": data.get("status", "unknown"),
"created_at": status.get("created_at") or manifest.get("created_at") or "", "created_at": data.get("created_at") or "",
"variant_count": len(manifest.get("variants", [])), "variant_count": len(variants),
"thumbnail": (manifest.get("variants") or [{}])[-1].get("file") if manifest.get("variants") else None, "thumbnail": variants[-1].get("file") if variants else None,
} }
) )
jobs.sort(key=lambda j: j["created_at"] or j["job_id"], reverse=True) jobs.sort(key=lambda j: j["created_at"] or j["job_id"], reverse=True)
@@ -81,8 +88,9 @@ def list_jobs() -> list[dict[str, Any]]:
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse: def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"index.html", "index.html",
{"request": request, "jobs": list_jobs(), "site_title": config.SITE_TITLE}, {"jobs": list_jobs(), "site_title": config.SITE_TITLE},
) )
@@ -92,14 +100,16 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse:
paths = pipeline.job_paths(stem) paths = pipeline.job_paths(stem)
status = pipeline.read_status(stem) or {} status = pipeline.read_status(stem) or {}
manifest = pipeline.read_manifest(stem) or {"variants": []} manifest = pipeline.read_manifest(stem) or {"variants": []}
intermediates = sorted( intermediates = (
p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file() sorted(p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file())
) if paths.intermediates.exists() else [] if paths.intermediates.exists()
else []
)
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"job.html", "job.html",
{ {
"request": request,
"site_title": config.SITE_TITLE, "site_title": config.SITE_TITLE,
"job_id": stem, "job_id": stem,
"status": status, "status": status,
@@ -113,9 +123,25 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse:
@app.get("/jobs/{job_id}/files/{filename:path}") @app.get("/jobs/{job_id}/files/{filename:path}")
def job_file(job_id: str, filename: str) -> FileResponse: def job_file(
job_id: str,
filename: str,
w: int | None = Query(None, description="Longest-edge width for srcset WebP"),
) -> FileResponse:
path = _safe_job_file(job_id, filename) path = _safe_job_file(job_id, filename)
if w is None:
return FileResponse(path) return FileResponse(path)
if w not in images.SRCSET_WIDTHS:
raise HTTPException(
status_code=400,
detail=f"Ungueltige Breite (erlaubt: {', '.join(map(str, images.SRCSET_WIDTHS))})",
)
try:
cached = images.get_or_create_resized(path, w)
except OSError as exc:
logger.warning("resize failed for %s w=%s: %s", path.name, w, exc)
raise HTTPException(status_code=500, detail="Bild konnte nicht skaliert werden") from exc
return FileResponse(cached, media_type="image/webp", filename=cached.name)
@app.get("/jobs/{job_id}/remix", response_class=HTMLResponse) @app.get("/jobs/{job_id}/remix", response_class=HTMLResponse)
@@ -163,9 +189,9 @@ def remix_form(
} }
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"remix.html", "remix.html",
{ {
"request": request,
"site_title": config.SITE_TITLE, "site_title": config.SITE_TITLE,
"job_id": stem, "job_id": stem,
"options": options, "options": options,
+112 -45
View File
@@ -15,22 +15,25 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import threading
import time import time
import uuid import uuid
from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any, Iterator from typing import Any
from . import config from . import config
from .io_utils import file_lock, read_json, write_json_atomic
logger = logging.getLogger("livef12.pipeline") logger = logging.getLogger("livef12.pipeline")
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
# Optional override for run_gmic timeout (used by the long-retry pass). # Per-thread override for run_gmic timeout (used by the long-retry pass).
_filter_timeout_override: int | None = None _filter_timeout_state = threading.local()
class PipelineError(RuntimeError): class PipelineError(RuntimeError):
@@ -67,19 +70,19 @@ def _nice(cmd: list[str]) -> list[str]:
def _active_filter_timeout() -> int: def _active_filter_timeout() -> int:
return _filter_timeout_override if _filter_timeout_override is not None else config.FILTER_TIMEOUT override = getattr(_filter_timeout_state, "override", None)
return override if override is not None else config.FILTER_TIMEOUT
@contextmanager @contextmanager
def filter_timeout(seconds: int) -> Iterator[None]: def filter_timeout(seconds: int) -> Iterator[None]:
"""Temporarily override FILTER_TIMEOUT for run_gmic (long-retry pass).""" """Temporarily override FILTER_TIMEOUT for run_gmic (long-retry pass)."""
global _filter_timeout_override previous = getattr(_filter_timeout_state, "override", None)
previous = _filter_timeout_override _filter_timeout_state.override = seconds
_filter_timeout_override = seconds
try: try:
yield yield
finally: finally:
_filter_timeout_override = previous _filter_timeout_state.override = previous
def _magick_bin() -> str: def _magick_bin() -> str:
@@ -125,7 +128,9 @@ def load_assets() -> FilterAssets:
if not bg_names or not fg_names: if not bg_names or not fg_names:
raise PipelineError("Keine gueltigen Filter in background/foreground Listen") raise PipelineError("Keine gueltigen Filter in background/foreground Listen")
return FilterAssets(background_names=bg_names, foreground_names=fg_names, blend_modes=blend_modes, commands=commands) return FilterAssets(
background_names=bg_names, foreground_names=fg_names, blend_modes=blend_modes, commands=commands
)
def run_gmic(args: list[str], output_image: Path) -> tuple[bool, str, float]: def run_gmic(args: list[str], output_image: Path) -> tuple[bool, str, float]:
@@ -167,7 +172,9 @@ def apply_filter(image: Path, full_command: str, output_image: Path) -> tuple[bo
return ok, err return ok, err
def apply_filter_chain(image: Path, commands: tuple[str, ...], output_image: Path, tmp_dir: Path, prefix: str) -> tuple[bool, str]: def apply_filter_chain(
image: Path, commands: tuple[str, ...], output_image: Path, tmp_dir: Path, prefix: str
) -> tuple[bool, str]:
current = image current = image
for i, command in enumerate(commands): for i, command in enumerate(commands):
target = output_image if i == len(commands) - 1 else tmp_dir / f"{prefix}_post_{i}.png" target = output_image if i == len(commands) - 1 else tmp_dir / f"{prefix}_post_{i}.png"
@@ -178,11 +185,6 @@ def apply_filter_chain(image: Path, commands: tuple[str, ...], output_image: Pat
return True, "" return True, ""
def blend_layers(base: Path, overlay: Path, mode: str, output_image: Path) -> tuple[bool, str]:
ok, err, _ = run_gmic([str(base), str(overlay), "blend", f"{mode},{config.BLEND_OPACITY}"], output_image)
return ok, err
def blend_layers_opacity(base: Path, overlay: Path, mode: str, opacity: str, output_image: Path) -> tuple[bool, str]: def blend_layers_opacity(base: Path, overlay: Path, mode: str, opacity: str, output_image: Path) -> tuple[bool, str]:
ok, err, _ = run_gmic([str(base), str(overlay), "blend", f"{mode},{opacity}"], output_image) ok, err, _ = run_gmic([str(base), str(overlay), "blend", f"{mode},{opacity}"], output_image)
return ok, err return ok, err
@@ -236,10 +238,10 @@ def preprocess_original(paths: JobPaths) -> JobPaths:
] ]
) )
try: try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120) proc = subprocess.run(cmd, capture_output=True, text=True, timeout=config.PREPROCESS_TIMEOUT)
except subprocess.TimeoutExpired as exc: except subprocess.TimeoutExpired as exc:
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)
raise PipelineError("Preprocess Timeout nach 120s") from exc raise PipelineError(f"Preprocess Timeout nach {config.PREPROCESS_TIMEOUT}s") from exc
if proc.returncode != 0 or not tmp.exists() or tmp.stat().st_size == 0: if proc.returncode != 0 or not tmp.exists() or tmp.stat().st_size == 0:
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)
err = strip_ansi((proc.stderr or proc.stdout or "").strip()) err = strip_ansi((proc.stderr or proc.stdout or "").strip())
@@ -297,7 +299,7 @@ def pick_working_filter(
def now_iso() -> str: def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds") return datetime.now(UTC).isoformat(timespec="seconds")
_UNSAFE_STEM_RE = re.compile(r"[^\w.\-]+", re.UNICODE) _UNSAFE_STEM_RE = re.compile(r"[^\w.\-]+", re.UNICODE)
@@ -305,8 +307,10 @@ _UNSAFE_STEM_RE = re.compile(r"[^\w.\-]+", re.UNICODE)
def sanitize_stem(name: str) -> str: def sanitize_stem(name: str) -> str:
"""Safe basename stem for flat files (no path separators / junk).""" """Safe basename stem for flat files (no path separators / junk)."""
stem = Path(name).stem if name else "" # Replace separators before Path.stem so "a/b" → "a_b", not "b".
stem = stem.replace("/", "_").replace("\\", "_").strip().strip(".") stem = (name or "").replace("/", "_").replace("\\", "_")
stem = Path(stem).stem
stem = stem.strip().strip(".")
stem = _UNSAFE_STEM_RE.sub("_", stem).strip("._") stem = _UNSAFE_STEM_RE.sub("_", stem).strip("._")
return stem or "photo" return stem or "photo"
@@ -346,26 +350,48 @@ def job_paths(stem: str, original_suffix: str = ".jpg") -> JobPaths:
) )
def _read_meta(stem: str) -> dict[str, Any] | None: def read_meta(stem: str) -> dict[str, Any] | None:
"""Return the full meta record for a job, or None if missing/corrupt."""
paths = job_paths(stem) paths = job_paths(stem)
if not paths.meta.exists(): data = read_json(paths.meta)
return None return data if isinstance(data, dict) else None
try:
return json.loads(paths.meta.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def _write_meta(paths: JobPaths, data: dict[str, Any]) -> None: def _meta_lock_path(stem: str) -> Path:
paths.meta.parent.mkdir(parents=True, exist_ok=True) return config.META_DIR / f"{sanitize_stem(stem)}.lock"
@contextmanager
def meta_lock(stem: str) -> Iterator[None]:
"""Exclusive lock for meta/{stem}.json read-modify-write."""
with file_lock(_meta_lock_path(stem)):
yield
def _write_meta_unlocked(paths: JobPaths, data: dict[str, Any]) -> None:
data["updated_at"] = now_iso() data["updated_at"] = now_iso()
tmp = paths.meta.with_suffix(".json.tmp") write_json_atomic(paths.meta, data)
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(paths.meta)
def _merge_variants(existing: list[Any], incoming: list[Any]) -> list[dict[str, Any]]:
"""Union variants by id; incoming wins on conflict; preserve discovery order."""
by_id: dict[str, dict[str, Any]] = {}
order: list[str] = []
for group in (existing, incoming):
for item in group:
if not isinstance(item, dict):
continue
vid = item.get("id")
if not isinstance(vid, str) or not vid:
continue
if vid not in by_id:
order.append(vid)
by_id[vid] = item
return [by_id[vid] for vid in order]
def read_status(job_id: str) -> dict[str, Any] | None: def read_status(job_id: str) -> dict[str, Any] | None:
data = _read_meta(job_id) data = read_meta(job_id)
if not data: if not data:
return None return None
return { return {
@@ -380,17 +406,18 @@ def read_status(job_id: str) -> dict[str, Any] | None:
def write_status(paths: JobPaths, status: str, **extra: Any) -> None: def write_status(paths: JobPaths, status: str, **extra: Any) -> None:
data = _read_meta(paths.stem) or {} with meta_lock(paths.stem):
data = read_meta(paths.stem) or {}
data["job_id"] = paths.stem data["job_id"] = paths.stem
data["source_stem"] = paths.stem data["source_stem"] = paths.stem
data["status"] = status data["status"] = status
data.setdefault("created_at", now_iso()) data.setdefault("created_at", now_iso())
data.update(extra) data.update(extra)
_write_meta(paths, data) _write_meta_unlocked(paths, data)
def read_manifest(job_id: str) -> dict[str, Any] | None: def read_manifest(job_id: str) -> dict[str, Any] | None:
data = _read_meta(job_id) data = read_meta(job_id)
if not data: if not data:
return None return None
return { return {
@@ -405,16 +432,52 @@ def read_manifest(job_id: str) -> dict[str, Any] | None:
def write_manifest(paths: JobPaths, manifest: dict[str, Any]) -> None: def write_manifest(paths: JobPaths, manifest: dict[str, Any]) -> None:
data = _read_meta(paths.stem) or {} """Upsert manifest fields; merge variants by id so concurrent writers do not clobber."""
with meta_lock(paths.stem):
data = read_meta(paths.stem) or {}
data["job_id"] = paths.stem data["job_id"] = paths.stem
data["source_stem"] = manifest.get("source_stem") or paths.stem if manifest.get("source_stem"):
data["original_file"] = manifest.get("original_file") data["source_stem"] = manifest["source_stem"]
data["rembg_file"] = manifest.get("rembg_file") else:
data["variants"] = manifest.get("variants") or [] data.setdefault("source_stem", paths.stem)
if "original_file" in manifest and manifest["original_file"] is not None:
data["original_file"] = manifest["original_file"]
if "rembg_file" in manifest and manifest["rembg_file"] is not None:
data["rembg_file"] = manifest["rembg_file"]
data["variants"] = _merge_variants(data.get("variants") or [], manifest.get("variants") or [])
if "created_at" in manifest: if "created_at" in manifest:
data.setdefault("created_at", manifest["created_at"]) data.setdefault("created_at", manifest["created_at"])
data.setdefault("status", data.get("status", "processing")) data.setdefault("status", data.get("status", "processing"))
_write_meta(paths, data) _write_meta_unlocked(paths, data)
def append_manifest_variant(
paths: JobPaths,
entry: dict[str, Any],
*,
original_file: str | None = None,
rembg_file: str | None = None,
source_stem: str | None = None,
created_at: str | None = None,
) -> None:
"""Append one variant under meta lock (re-reads disk so concurrent updates survive)."""
with meta_lock(paths.stem):
data = read_meta(paths.stem) or {}
data["job_id"] = paths.stem
if source_stem:
data["source_stem"] = source_stem
else:
data.setdefault("source_stem", paths.stem)
if original_file:
data["original_file"] = original_file
if rembg_file:
data["rembg_file"] = rembg_file
if created_at:
data.setdefault("created_at", created_at)
data.setdefault("created_at", now_iso())
data.setdefault("status", data.get("status", "done"))
data["variants"] = _merge_variants(data.get("variants") or [], [entry])
_write_meta_unlocked(paths, data)
def list_job_ids() -> list[str]: def list_job_ids() -> list[str]:
@@ -483,14 +546,18 @@ def compose_variant(
if not bg_command: if not bg_command:
raise PipelineError(f"Unbekannter Background-Filter: {bg_name}") raise PipelineError(f"Unbekannter Background-Filter: {bg_name}")
else: else:
bg_name, bg_command = pick_working_filter(assets.background_names, assets.commands, paths.original, tmp_dir, "bg", rng) bg_name, bg_command = pick_working_filter(
assets.background_names, assets.commands, paths.original, tmp_dir, "bg", rng
)
if fg_name: if fg_name:
fg_command = assets.commands.get(fg_name) fg_command = assets.commands.get(fg_name)
if not fg_command: if not fg_command:
raise PipelineError(f"Unbekannter Foreground-Filter: {fg_name}") raise PipelineError(f"Unbekannter Foreground-Filter: {fg_name}")
else: else:
fg_name, fg_command = pick_working_filter(assets.foreground_names, assets.commands, paths.rembg, tmp_dir, "fg", rng) fg_name, fg_command = pick_working_filter(
assets.foreground_names, assets.commands, paths.rembg, tmp_dir, "fg", rng
)
p = { p = {
"bg_filtered": tmp_dir / f"{prefix}_bg_filtered.png", "bg_filtered": tmp_dir / f"{prefix}_bg_filtered.png",
+2 -7
View File
@@ -18,12 +18,7 @@ import sys
import traceback import traceback
from pathlib import Path from pathlib import Path
from .env_utils import env_bool
def _env_bool(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def main() -> int: def main() -> int:
@@ -42,7 +37,7 @@ def main() -> int:
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
model = os.environ.get("REMBG_MODEL", "u2net") model = os.environ.get("REMBG_MODEL", "u2net")
alpha = _env_bool("REMBG_ALPHA", True) alpha = env_bool("REMBG_ALPHA", True)
session = new_session(model) session = new_session(model)
output_path.write_bytes( output_path.write_bytes(
remove( remove(
+11 -4
View File
@@ -3,6 +3,7 @@ cached original + rembg output and user-chosen filters/blends/opacity."""
from __future__ import annotations from __future__ import annotations
import uuid
from dataclasses import dataclass from dataclasses import dataclass
from . import pipeline from . import pipeline
@@ -52,8 +53,8 @@ def create_remix_variant(job_id: str, choice: RemixChoice) -> dict:
"variants": [], "variants": [],
} }
variant_stem = pipeline.resolve_source_stem(manifest, paths) variant_stem = pipeline.resolve_source_stem(manifest, paths)
manifest.setdefault("source_stem", variant_stem) # Unique id avoids collisions when two remixes run in parallel.
variant_id = pipeline.next_variant_id(manifest) variant_id = f"v{uuid.uuid4().hex[:8]}"
entry = pipeline.compose_variant( entry = pipeline.compose_variant(
paths, paths,
@@ -67,6 +68,12 @@ def create_remix_variant(job_id: str, choice: RemixChoice) -> dict:
fg_mode=choice.fg_blend, fg_mode=choice.fg_blend,
opacity=choice.opacity, opacity=choice.opacity,
) )
manifest["variants"].append(entry) pipeline.append_manifest_variant(
pipeline.write_manifest(paths, manifest) paths,
entry,
original_file=paths.original.name,
rembg_file=paths.rembg.name,
source_stem=variant_stem,
created_at=manifest.get("created_at") if isinstance(manifest.get("created_at"), str) else None,
)
return entry return entry
+2 -1
View File
@@ -44,7 +44,8 @@
if (!target.classList.contains("lightboxable")) return; if (!target.classList.contains("lightboxable")) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
openLightbox(target.currentSrc || target.src, target.alt); var full = target.getAttribute("data-full-src");
openLightbox(full || target.currentSrc || target.src, target.alt);
}); });
box.addEventListener("click", function (e) { box.addEventListener("click", function (e) {
+9 -1
View File
@@ -9,7 +9,15 @@
<li class="job-card"> <li class="job-card">
<a href="/jobs/{{ job.job_id }}"> <a href="/jobs/{{ job.job_id }}">
{% if job.thumbnail %} {% if job.thumbnail %}
<img class="thumb lightboxable" src="/jobs/{{ job.job_id }}/files/{{ job.thumbnail }}" alt="Variante von Job {{ job.job_id }}" loading="lazy"> {% set img = img_attrs(job.job_id, job.thumbnail) %}
<img class="thumb lightboxable"
src="{{ img.src }}"
srcset="{{ img.srcset }}"
sizes="{{ img.sizes }}"
data-full-src="{{ img.full_src }}"
alt="Variante von Job {{ job.job_id }}"
loading="lazy"
decoding="async">
{% else %} {% else %}
<span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status }}</span> <span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status }}</span>
{% endif %} {% endif %}
+36 -4
View File
@@ -13,8 +13,16 @@
<h2>Original &amp; Rembg</h2> <h2>Original &amp; Rembg</h2>
<div class="pair-grid"> <div class="pair-grid">
{% if original_name %} {% if original_name %}
{% set img = img_attrs(job_id, original_name, sizes=PAIR_SIZES) %}
<figure> <figure>
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ original_name }}" alt="Original" loading="lazy"> <img class="lightboxable"
src="{{ img.src }}"
srcset="{{ img.srcset }}"
sizes="{{ img.sizes }}"
data-full-src="{{ img.full_src }}"
alt="Original"
loading="lazy"
decoding="async">
<figcaption> <figcaption>
Original &middot; Original &middot;
<a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a> <a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a>
@@ -22,8 +30,16 @@
</figure> </figure>
{% endif %} {% endif %}
{% if has_rembg and rembg_name %} {% if has_rembg and rembg_name %}
{% set img = img_attrs(job_id, rembg_name, sizes=PAIR_SIZES) %}
<figure> <figure>
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ rembg_name }}" alt="Freigestellt (rembg)" loading="lazy"> <img class="lightboxable"
src="{{ img.src }}"
srcset="{{ img.srcset }}"
sizes="{{ img.sizes }}"
data-full-src="{{ img.full_src }}"
alt="Freigestellt (rembg)"
loading="lazy"
decoding="async">
<figcaption> <figcaption>
Rembg &middot; Rembg &middot;
<a href="/jobs/{{ job_id }}/files/{{ rembg_name }}" download>Download</a> <a href="/jobs/{{ job_id }}/files/{{ rembg_name }}" download>Download</a>
@@ -45,8 +61,16 @@
{% else %} {% else %}
<ul class="variant-grid"> <ul class="variant-grid">
{% for v in manifest.variants %} {% for v in manifest.variants %}
{% set img = img_attrs(job_id, v.file) %}
<li class="variant-card"> <li class="variant-card">
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ v.file }}" alt="Variante {{ v.id }}" loading="lazy"> <img class="lightboxable"
src="{{ img.src }}"
srcset="{{ img.srcset }}"
sizes="{{ img.sizes }}"
data-full-src="{{ img.full_src }}"
alt="Variante {{ v.id }}"
loading="lazy"
decoding="async">
<div class="variant-meta"> <div class="variant-meta">
<strong>{{ v.id }}</strong> <span class="tag">{{ v.source }}</span> <strong>{{ v.id }}</strong> <span class="tag">{{ v.source }}</span>
<dl> <dl>
@@ -72,8 +96,16 @@
<h2>Zwischenschritte</h2> <h2>Zwischenschritte</h2>
<ul class="intermediate-grid"> <ul class="intermediate-grid">
{% for name in intermediates %} {% for name in intermediates %}
{% set img = img_attrs(job_id, name) %}
<li> <li>
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ name }}" alt="{{ name }}" loading="lazy"> <img class="lightboxable"
src="{{ img.src }}"
srcset="{{ img.srcset }}"
sizes="{{ img.sizes }}"
data-full-src="{{ img.full_src }}"
alt="{{ name }}"
loading="lazy"
decoding="async">
<span>{{ name }}</span> <span>{{ name }}</span>
</li> </li>
{% endfor %} {% endfor %}
+8 -14
View File
@@ -13,7 +13,6 @@ pipeline is CPU heavy (gmic/rembg) and the worker container is capped at
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import shutil import shutil
import time import time
@@ -21,11 +20,10 @@ from pathlib import Path
from typing import Any from typing import Any
from . import config, pipeline from . import config, pipeline
from .io_utils import read_json, write_json_atomic
from .logging_config import configure_logging
logging.basicConfig( configure_logging()
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logger = logging.getLogger("livef12.worker") logger = logging.getLogger("livef12.worker")
@@ -42,17 +40,15 @@ def ensure_dirs() -> None:
def load_processed() -> dict[str, Any]: def load_processed() -> dict[str, Any]:
if not config.PROCESSED_FILE.exists(): if not config.PROCESSED_FILE.exists():
return {} return {}
try: data = read_json(config.PROCESSED_FILE)
return json.loads(config.PROCESSED_FILE.read_text(encoding="utf-8")) if isinstance(data, dict):
except (OSError, ValueError): return data
logger.warning("processed.json unreadable, starting fresh") logger.warning("processed.json unreadable, starting fresh")
return {} return {}
def save_processed(processed: dict[str, Any]) -> None: def save_processed(processed: dict[str, Any]) -> None:
tmp = config.PROCESSED_FILE.with_suffix(".json.tmp") write_json_atomic(config.PROCESSED_FILE, processed)
tmp.write_text(json.dumps(processed, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(config.PROCESSED_FILE)
def _file_key(path: Path) -> str: def _file_key(path: Path) -> str:
@@ -70,9 +66,7 @@ def _is_ignored_name(name: str) -> bool:
"""Skip Syncthing metadata/temp files and other dotfiles.""" """Skip Syncthing metadata/temp files and other dotfiles."""
if name.startswith("."): if name.startswith("."):
return True return True
if name.startswith(".syncthing.") or ".syncthing." in name: return bool(name.startswith(".syncthing.") or ".syncthing." in name)
return True
return False
def _is_stable(path: Path) -> bool: def _is_stable(path: Path) -> bool:
-30
View File
@@ -1,30 +0,0 @@
# Background-removal comparison
Inbox originals from `live.f12.rocks` vs. backends.
## Layout
- `inputs/` — originals from boka inbox
- `outputs/` — results, naming:
```
<stem>__rembg-u2net__no-alpha.png
<stem>__rembg-u2net__alpha.png
<stem>__rembg-default__no-alpha.png
<stem>__rembg-default__alpha.png
<stem>__rembg-birefnet-general__no-alpha.png
<stem>__rembg-birefnet-general__alpha.png
<stem>__withoutbg-open-weights.png
```
- `rembg-default` = `rembg.remove()` without session (library default = u2net)
- `rembg-u2net` = explicit `new_session("u2net")` — expect identical to default
- `withoutbg` open-weights includes matting in-graph (~2GB RAM claimed)
## Re-run
```bash
./run.sh
```
Skips existing non-empty outputs. Models cached in `model-cache/`.
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
# Run rembg/withoutbg comparison in Python 3.11 (host is 3.14-only).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
IMG="livef12-bg-compare:py311"
LOG="$ROOT/compare.log"
docker build -t "$IMG" - <<'EOF'
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir 'rembg>=2.0.59,<2.1' 'pillow>=10.4,<11' \
'onnxruntime>=1.19,<1.20' withoutbg
WORKDIR /work
EOF
echo "Starting comparison; log: $LOG"
docker run --rm \
--name livef12-bg-compare \
-e TQDM_DISABLE=1 \
-e COMPARE_INPUT=/data/inputs \
-e COMPARE_OUTPUT=/data/outputs \
-e HOME=/cache \
-v "$ROOT/inputs:/data/inputs:ro" \
-v "$ROOT/outputs:/data/outputs" \
-v "$ROOT/run_compare.py:/work/run_compare.py:ro" \
-v "$ROOT/model-cache:/cache" \
"$IMG" python -u /work/run_compare.py 2>&1 | tee "$LOG"
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""Compare background-removal backends on the live.f12.rocks inbox uploads.
Outputs land next to each other with explicit names so Fränky can pick a winner:
<stem>__rembg-u2net__no-alpha.png
<stem>__rembg-u2net__alpha.png
<stem>__rembg-default__no-alpha.png
<stem>__rembg-default__alpha.png
<stem>__rembg-birefnet-general__no-alpha.png
<stem>__rembg-birefnet-general__alpha.png
<stem>__withoutbg-open-weights.png
`rembg-default` = rembg.remove() with no session (library default model).
`rembg-u2net` = rembg.new_session("u2net") explicitly.
withoutbg embeds matting in its open-weights graph — one output only.
"""
from __future__ import annotations
import os
import sys
import time
import traceback
from pathlib import Path
os.environ.setdefault("TQDM_DISABLE", "1")
INPUT_DIR = Path(os.environ.get("COMPARE_INPUT", "/data/inputs"))
OUTPUT_DIR = Path(os.environ.get("COMPARE_OUTPUT", "/data/outputs"))
EXTS = {".jpg", ".jpeg", ".png", ".webp"}
def log(msg: str) -> None:
print(msg, flush=True)
def run_rembg_named(data: bytes, label: str, model: str | None, alpha: bool, out: Path) -> None:
from rembg import new_session, remove
t0 = time.monotonic()
kwargs: dict = {"alpha_matting": alpha}
if model is None:
result = remove(data, **kwargs)
model_note = "default"
else:
session = new_session(model)
result = remove(data, session=session, **kwargs)
model_note = model
out.write_bytes(result)
log(f" OK {label} model={model_note} alpha={alpha} {time.monotonic()-t0:.1f}s -> {out.name} ({out.stat().st_size} bytes)")
def run_withoutbg(path: Path, out: Path) -> None:
from withoutbg import WithoutBG
t0 = time.monotonic()
model = WithoutBG.open_weights()
result = model.remove_background(str(path))
result.save(out)
log(f" OK withoutbg-open-weights {time.monotonic()-t0:.1f}s -> {out.name} ({out.stat().st_size} bytes)")
def process_one(path: Path) -> None:
stem = path.stem
log(f"=== {path.name} ===")
data = path.read_bytes()
jobs = [
("rembg-u2net", "u2net", False),
("rembg-u2net", "u2net", True),
("rembg-default", None, False),
("rembg-default", None, True),
("rembg-birefnet-general", "birefnet-general", False),
("rembg-birefnet-general", "birefnet-general", True),
]
for label, model, alpha in jobs:
tag = "alpha" if alpha else "no-alpha"
out = OUTPUT_DIR / f"{stem}__{label}__{tag}.png"
if out.exists() and out.stat().st_size > 0:
log(f" SKIP {out.name} (exists)")
continue
try:
run_rembg_named(data, label, model, alpha, out)
except Exception as exc: # noqa: BLE001
err = OUTPUT_DIR / f"{stem}__{label}__{tag}.ERROR.txt"
err.write_text(f"{type(exc).__name__}: {exc}\n\n{traceback.format_exc()}", encoding="utf-8")
log(f" FAIL {label} alpha={alpha}: {type(exc).__name__}: {exc}")
out_w = OUTPUT_DIR / f"{stem}__withoutbg-open-weights.png"
if out_w.exists() and out_w.stat().st_size > 0:
log(f" SKIP {out_w.name} (exists)")
else:
try:
run_withoutbg(path, out_w)
except Exception as exc: # noqa: BLE001
err = OUTPUT_DIR / f"{stem}__withoutbg-open-weights.ERROR.txt"
err.write_text(f"{type(exc).__name__}: {exc}\n\n{traceback.format_exc()}", encoding="utf-8")
log(f" FAIL withoutbg: {type(exc).__name__}: {exc}")
def main() -> int:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
inputs = sorted(p for p in INPUT_DIR.iterdir() if p.is_file() and p.suffix.lower() in EXTS)
if not inputs:
log(f"No images in {INPUT_DIR}")
return 1
log(f"Comparing {len(inputs)} image(s); outputs -> {OUTPUT_DIR}")
for path in inputs:
process_one(path)
log("DONE")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+12 -20
View File
@@ -8,14 +8,7 @@
# Syncthing itself runs on the host (not in this compose). Copy .env.example # Syncthing itself runs on the host (not in this compose). Copy .env.example
# to .env for local overrides (e.g. DATA_HOST_DIR=./data). Never commit .env. # to .env for local overrides (e.g. DATA_HOST_DIR=./data). Never commit .env.
services: x-app-env: &app-env
worker:
build: .
restart: unless-stopped
# Same UID as Syncthing (PUID=1002) so shared /data stays writable.
user: "1002:1002"
command: ["python3", "-m", "app.worker"]
environment:
DATA_DIR: /data DATA_DIR: /data
OUTPUT_COUNT: ${OUTPUT_COUNT:-3} OUTPUT_COUNT: ${OUTPUT_COUNT:-3}
BLEND_OPACITY: ${BLEND_OPACITY:-30%} BLEND_OPACITY: ${BLEND_OPACITY:-30%}
@@ -28,6 +21,16 @@ services:
REMBG_MODEL: ${REMBG_MODEL:-u2net} REMBG_MODEL: ${REMBG_MODEL:-u2net}
REMBG_ALPHA: ${REMBG_ALPHA:-1} REMBG_ALPHA: ${REMBG_ALPHA:-1}
NICE_LEVEL: ${NICE_LEVEL:-18} NICE_LEVEL: ${NICE_LEVEL:-18}
services:
worker:
build: .
restart: unless-stopped
# Same UID as Syncthing (PUID=1002) so shared /data stays writable.
user: "1002:1002"
command: ["python3", "-m", "app.worker"]
environment:
<<: *app-env
volumes: volumes:
- ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data - ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data
- ./assets:/data/assets:ro - ./assets:/data/assets:ro
@@ -48,18 +51,7 @@ services:
depends_on: depends_on:
- worker - worker
environment: environment:
DATA_DIR: /data <<: *app-env
OUTPUT_COUNT: ${OUTPUT_COUNT:-3}
BLEND_OPACITY: ${BLEND_OPACITY:-30%}
BLEND_OPACITY_MIN: ${BLEND_OPACITY_MIN:-10}
BLEND_OPACITY_MAX: ${BLEND_OPACITY_MAX:-50}
FILTER_TIMEOUT: ${FILTER_TIMEOUT:-120}
FILTER_TIMEOUT_LONG: ${FILTER_TIMEOUT_LONG:-300}
MAX_FILTER_ATTEMPTS: ${MAX_FILTER_ATTEMPTS:-8}
MAX_EDGE_PX: ${MAX_EDGE_PX:-2000}
REMBG_MODEL: ${REMBG_MODEL:-u2net}
REMBG_ALPHA: ${REMBG_ALPHA:-1}
NICE_LEVEL: ${NICE_LEVEL:-18}
SITE_TITLE: live.f12.rocks SITE_TITLE: live.f12.rocks
volumes: volumes:
- ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data - ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data
+41
View File
@@ -0,0 +1,41 @@
[project]
name = "livef12rocks"
version = "0.1.0"
description = "Event photo pep pipeline: Syncthing drop -> gmic/rembg -> web"
readme = "README.md"
requires-python = ">=3.11,<3.12"
[project.optional-dependencies]
dev = [
"pytest>=8.3,<9",
"httpx>=0.27,<0.29",
"ruff>=0.8,<0.10",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
[tool.ruff]
target-version = "py311"
line-length = 120
src = ["app", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
]
ignore = [
"B008", # FastAPI Depends/Form in defaults is intentional
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"] # assert in tests
[tool.ruff.format]
quote-style = "double"
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
pytest>=8.3,<9
httpx>=0.27,<0.29
ruff>=0.8,<0.10
+28
View File
@@ -0,0 +1,28 @@
"""Shared test fixtures."""
from __future__ import annotations
from pathlib import Path
import pytest
@pytest.fixture
def data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point config path constants at a temporary data directory."""
variants = tmp_path / "variants"
intermediates = tmp_path / "intermediates"
meta = tmp_path / "meta"
inbox = tmp_path / "incoming"
webcache = tmp_path / "webcache"
for directory in (variants, intermediates, meta, inbox, webcache):
directory.mkdir()
monkeypatch.setattr("app.config.DATA_DIR", tmp_path)
monkeypatch.setattr("app.config.VARIANTS_DIR", variants)
monkeypatch.setattr("app.config.INTERMEDIATES_DIR", intermediates)
monkeypatch.setattr("app.config.META_DIR", meta)
monkeypatch.setattr("app.config.INBOX_DIR", inbox)
monkeypatch.setattr("app.config.WEB_CACHE_DIR", webcache)
monkeypatch.setattr("app.config.PROCESSED_FILE", tmp_path / "processed.json")
return tmp_path
+30
View File
@@ -0,0 +1,30 @@
"""Tests for config helpers."""
from __future__ import annotations
from app import config
from app.config import _float_env, _int_env
def test_int_env_valid(monkeypatch) -> None:
monkeypatch.setenv("TEST_INT", "42")
assert _int_env("TEST_INT", 0) == 42
def test_int_env_invalid_falls_back(monkeypatch) -> None:
monkeypatch.setenv("TEST_INT", "nope")
assert _int_env("TEST_INT", 7) == 7
def test_float_env_valid(monkeypatch) -> None:
monkeypatch.setenv("TEST_FLOAT", "2.5")
assert _float_env("TEST_FLOAT", 0.0) == 2.5
def test_supported_extensions() -> None:
assert ".jpg" in config.SUPPORTED_EXTENSIONS
assert ".jpeg" in config.SUPPORTED_EXTENSIONS
def test_opacity_choices_percentages() -> None:
assert all(choice.endswith("%") for choice in config.OPACITY_CHOICES)
+22
View File
@@ -0,0 +1,22 @@
"""Tests for env_utils."""
from __future__ import annotations
from app.env_utils import env_bool
def test_env_bool_default(monkeypatch) -> None:
monkeypatch.delenv("TEST_FLAG", raising=False)
assert env_bool("TEST_FLAG", True) is True
assert env_bool("TEST_FLAG", False) is False
def test_env_bool_truthy(monkeypatch) -> None:
for value in ("1", "true", "TRUE", "yes", "on"):
monkeypatch.setenv("TEST_FLAG", value)
assert env_bool("TEST_FLAG", False) is True
def test_env_bool_falsy(monkeypatch) -> None:
monkeypatch.setenv("TEST_FLAG", "0")
assert env_bool("TEST_FLAG", True) is False
+44
View File
@@ -0,0 +1,44 @@
"""Tests for io_utils."""
from __future__ import annotations
import json
from pathlib import Path
from app.io_utils import read_json, write_json_atomic
def test_read_json_missing_returns_default(tmp_path: Path) -> None:
assert read_json(tmp_path / "missing.json", default={}) == {}
def test_read_json_corrupt_returns_default(tmp_path: Path) -> None:
path = tmp_path / "bad.json"
path.write_text("not json", encoding="utf-8")
assert read_json(path, default=None) is None
def test_write_json_atomic_roundtrip(tmp_path: Path) -> None:
path = tmp_path / "data.json"
payload = {"job_id": "photo", "variants": []}
write_json_atomic(path, payload)
assert json.loads(path.read_text(encoding="utf-8")) == payload
assert not path.with_suffix(path.suffix + ".tmp").exists()
def test_file_lock_exclusive(tmp_path: Path) -> None:
from concurrent.futures import ThreadPoolExecutor
from app.io_utils import file_lock
lock_path = tmp_path / "job.lock"
counter = {"n": 0}
def bump() -> None:
with file_lock(lock_path):
current = counter["n"]
counter["n"] = current + 1
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(lambda _: bump(), range(40)))
assert counter["n"] == 40
+103
View File
@@ -0,0 +1,103 @@
"""Tests for FastAPI routes and validation."""
from __future__ import annotations
from pathlib import Path
import pytest
from app import config, images, pipeline
from app.main import _safe_job_file, _validate_job_id, app
from fastapi import HTTPException
from fastapi.testclient import TestClient
from PIL import Image
@pytest.fixture
def client(data_dir: Path) -> TestClient:
return TestClient(app)
def _write_test_png(path: Path, size: tuple[int, int] = (1200, 800)) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", size, color=(40, 80, 120)).save(path, format="PNG")
def test_validate_job_id_sanitizes_input() -> None:
assert _validate_job_id("foo@bar") == "foo_bar"
assert _validate_job_id("photo01") == "photo01"
def test_validate_job_id_rejects_path_like() -> None:
with pytest.raises(HTTPException) as exc:
_validate_job_id("a/b")
assert exc.value.status_code == 400
with pytest.raises(HTTPException):
_validate_job_id("../evil")
with pytest.raises(HTTPException):
_validate_job_id("")
def test_safe_job_file_rejects_wrong_prefix(data_dir: Path) -> None:
stem = "photo01"
paths = pipeline.job_paths(stem)
paths.variants.mkdir(parents=True, exist_ok=True)
target = paths.variants / f"{stem}_v1.png"
target.write_bytes(b"x")
with pytest.raises(HTTPException):
_safe_job_file(stem, "other_v1.png")
def test_index_empty(client: TestClient) -> None:
response = client.get("/")
assert response.status_code == 200
assert config.SITE_TITLE in response.text
def test_job_not_found(client: TestClient) -> None:
response = client.get("/jobs/does-not-exist")
assert response.status_code == 404
def test_job_file_srcset_resize(client: TestClient, data_dir: Path) -> None:
stem = "photo01"
paths = pipeline.job_paths(stem)
png = paths.variants / f"{stem}_v1.png"
_write_test_png(png)
pipeline.write_status(paths, "done", created_at="2026-01-01T00:00:00")
pipeline.write_manifest(
paths,
{
"variants": [{"id": "v1", "file": png.name}],
"created_at": "2026-01-01T00:00:00",
},
)
full = client.get(f"/jobs/{stem}/files/{png.name}")
assert full.status_code == 200
assert full.headers["content-type"].startswith("image/")
bad = client.get(f"/jobs/{stem}/files/{png.name}?w=999")
assert bad.status_code == 400
resized = client.get(f"/jobs/{stem}/files/{png.name}?w=320")
assert resized.status_code == 200
assert resized.headers["content-type"] == "image/webp"
assert len(resized.content) < len(full.content)
cached = config.WEB_CACHE_DIR / f"{png.name}.w320.webp"
assert cached.is_file()
with Image.open(cached) as img:
assert img.width == 320
index = client.get("/")
assert index.status_code == 200
assert f"/jobs/{stem}/files/{png.name}?w=640" in index.text
assert "srcset=" in index.text
assert "data-full-src=" in index.text
def test_img_attrs_builds_srcset() -> None:
attrs = images.img_attrs("job1", "job1_v1.png")
assert attrs["src"].endswith("?w=640")
assert "320w" in attrs["srcset"]
assert attrs["full_src"].endswith("?w=1280")
+134
View File
@@ -0,0 +1,134 @@
"""Tests for pipeline pure helpers and meta I/O."""
from __future__ import annotations
from pathlib import Path
import pytest
from app import pipeline
@pytest.mark.parametrize(
("raw", "expected"),
[
("photo.jpg", "photo"),
("../evil", "evil"),
("a/b", "a_b"),
("", "photo"),
(" spaced ", "spaced"),
("foo@bar", "foo_bar"),
],
)
def test_sanitize_stem(raw: str, expected: str) -> None:
assert pipeline.sanitize_stem(raw) == expected
def test_variant_filename() -> None:
assert pipeline.variant_filename("photo", "v1") == "photo_v1.png"
@pytest.mark.parametrize(
("command", "expected_tail"),
[
("fx_blur 3", ["fx_blur", "3"]),
("normalize", ["normalize"]),
],
)
def test_gmic_filter_args(tmp_path: Path, command: str, expected_tail: list[str]) -> None:
image = tmp_path / "in.png"
image.touch()
args = pipeline.gmic_filter_args(image, command)
assert args[0] == str(image)
assert args[1:] == expected_tail
def test_next_variant_id() -> None:
manifest = {"variants": [{"id": "v1"}, {"id": "v2"}]}
assert pipeline.next_variant_id(manifest) == "v3"
def test_next_variant_id_after_gap() -> None:
manifest = {"variants": [{"id": "v1"}, {"id": "v3"}]}
assert pipeline.next_variant_id(manifest) == "v4"
def test_strip_ansi() -> None:
assert pipeline.strip_ansi("\x1b[31merror\x1b[0m") == "error"
def test_read_write_meta_roundtrip(data_dir: Path) -> None:
paths = pipeline.job_paths("event01")
pipeline.write_status(paths, "processing", source_file="event01.jpg")
meta = pipeline.read_meta("event01")
assert meta is not None
assert meta["status"] == "processing"
assert meta["source_file"] == "event01.jpg"
status = pipeline.read_status("event01")
assert status is not None
assert status["status"] == "processing"
manifest = pipeline.read_manifest("event01")
assert manifest is not None
assert manifest["variants"] == []
def test_list_job_ids(data_dir: Path) -> None:
paths = pipeline.job_paths("a")
pipeline.write_status(paths, "done")
paths = pipeline.job_paths("b")
pipeline.write_status(paths, "processing")
assert sorted(pipeline.list_job_ids()) == ["a", "b"]
def test_parallel_manifest_merge_keeps_all_variants(data_dir: Path) -> None:
"""Concurrent write_manifest callers must not clobber each other's variants."""
from concurrent.futures import ThreadPoolExecutor
paths = pipeline.job_paths("race")
pipeline.write_status(paths, "processing")
pipeline.write_manifest(
paths,
{
"source_stem": "race",
"original_file": "race_original.jpg",
"rembg_file": "race_rembg.png",
"created_at": pipeline.now_iso(),
"variants": [],
},
)
def write_one(i: int) -> None:
pipeline.write_manifest(
paths,
{
"variants": [{"id": f"v{i}", "file": f"race_v{i}.png"}],
},
)
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(write_one, range(20)))
meta = pipeline.read_meta("race")
assert meta is not None
ids = {v["id"] for v in meta["variants"]}
assert ids == {f"v{i}" for i in range(20)}
assert meta["original_file"] == "race_original.jpg"
def test_filter_timeout_is_thread_local() -> None:
import threading
results: dict[str, int] = {}
def worker(name: str, value: int) -> None:
with pipeline.filter_timeout(value):
results[name] = pipeline._active_filter_timeout()
t1 = threading.Thread(target=worker, args=("a", 11))
t2 = threading.Thread(target=worker, args=("b", 22))
t1.start()
t2.start()
t1.join()
t2.join()
assert results == {"a": 11, "b": 22}
+46
View File
@@ -0,0 +1,46 @@
"""Tests for worker inbox helpers."""
from __future__ import annotations
from pathlib import Path
from app import config
from app.worker import _file_key, _is_ignored_name, find_new_files, load_processed, save_processed
def test_is_ignored_name() -> None:
assert _is_ignored_name(".stfolder") is True
assert _is_ignored_name(".syncthing.tmp") is True
assert _is_ignored_name("photo.jpg") is False
def test_find_new_files_skips_processed(data_dir: Path) -> None:
photo = config.INBOX_DIR / "party.jpg"
photo.write_bytes(b"fake")
stat = photo.stat()
processed = {
_file_key(photo): {
"size": stat.st_size,
"mtime": stat.st_mtime,
}
}
assert find_new_files(processed) == []
def test_find_new_files_picks_supported(data_dir: Path) -> None:
(config.INBOX_DIR / "party.jpg").write_bytes(b"fake")
(config.INBOX_DIR / "notes.txt").write_text("nope", encoding="utf-8")
assert len(find_new_files({})) == 1
def test_load_processed_corrupt_warns(data_dir: Path, caplog) -> None:
config.PROCESSED_FILE.write_text("{bad", encoding="utf-8")
with caplog.at_level("WARNING"):
assert load_processed() == {}
assert "processed.json unreadable" in caplog.text
def test_save_processed_roundtrip(data_dir: Path) -> None:
payload = {"incoming/party.jpg": {"size": 1, "mtime": 2}}
save_processed(payload)
assert load_processed() == payload