Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dec74a46d7 | |||
| b09f90bfcc | |||
| 9a9b143f08 | |||
| fd024d31e9 | |||
| c29186a227 | |||
| 1cf4ea0f07 | |||
| 86989c1a4c | |||
| 5a3118f590 | |||
| b63a796907 | |||
| 0963297815 | |||
| e9f494ed9d | |||
| 90094cf8f8 |
+3
-1
@@ -2,7 +2,8 @@
|
||||
|
||||
# --- Host data path (Syncthing share root) ------------------------------
|
||||
# Prod default (compose.yml): /home/frank/sync.schwenk.online/data/livef12
|
||||
# Layout under that path: incoming/ variants/ intermediates/ meta/
|
||||
# Layout under that path: incoming/ variants/ intermediates/ meta/ webcache/
|
||||
# (webcache = resized WebP for srcset; safe to wipe / add to phone .stignore)
|
||||
# Local override when the absolute path does not exist:
|
||||
# DATA_HOST_DIR=./data
|
||||
# DATA_HOST_DIR=/home/frank/sync.schwenk.online/data/livef12
|
||||
@@ -17,6 +18,7 @@ FILTER_TIMEOUT=120
|
||||
FILTER_TIMEOUT_LONG=300
|
||||
MAX_FILTER_ATTEMPTS=8
|
||||
MAX_EDGE_PX=2000
|
||||
PREPROCESS_TIMEOUT=120
|
||||
# birefnet-general OOMs on the ~8GB box; u2net + alpha ≈ `rembg i -a`
|
||||
REMBG_MODEL=u2net
|
||||
REMBG_ALPHA=1
|
||||
|
||||
@@ -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
@@ -4,21 +4,20 @@
|
||||
# Runtime data (bind-mounted / Syncthing share — never in git)
|
||||
/data/
|
||||
|
||||
# Local rembg/withoutbg comparison scratch
|
||||
/compare-bg/inputs/
|
||||
/compare-bg/outputs/
|
||||
/compare-bg/model-cache/
|
||||
/compare-bg/compare.log
|
||||
.venv-compare/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
.venv-*/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
|
||||
# OS / editor noise
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.11
|
||||
@@ -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.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Cleanup Plan — live.f12.rocks
|
||||
|
||||
Phase 0 analysis (2026-07-18). Working branch: `cleanup/code-quality`.
|
||||
|
||||
## Project snapshot
|
||||
|
||||
| Area | Detail |
|
||||
|------|--------|
|
||||
| Stack | Python 3.11 (Docker), FastAPI, gmic, rembg, Docker Compose |
|
||||
| Size | ~7 Python modules under `app/`, ~1.3k LOC |
|
||||
| Tests | None (pre-cleanup) |
|
||||
| Lint/format | None (pre-cleanup) |
|
||||
| CI | None |
|
||||
| Lockfile | Soft pins in `requirements.txt` only |
|
||||
|
||||
## Findings (prioritized)
|
||||
|
||||
1. **No test/CI/lint baseline** — high risk for refactors
|
||||
2. **`pipeline.py` god module** (642 LOC) — medium risk; defer split until tests exist
|
||||
3. **Meta JSON race** (worker + web concurrent writes) — documented, not fixed (behavior change)
|
||||
4. **Dead code**: `blend_layers()` unused
|
||||
5. **Duplication**: atomic JSON write ×3, `basicConfig` ×2, bool-env parsing ×2, compose env block ×2
|
||||
6. **Double meta read** on index page (`read_status` + `read_manifest`)
|
||||
7. ~~`compare-bg/`~~ — removed (was a standalone experiment)
|
||||
8. **`make_random.py`** — external reference only, document in ARCHITECTURE
|
||||
|
||||
## Risk matrix
|
||||
|
||||
| Area | Risk | Action |
|
||||
|------|------|--------|
|
||||
| Formatter/linter setup | Low | Phase 1 |
|
||||
| Dead code removal | Low | Phase 1 |
|
||||
| `.gitignore` / `.python-version` | Low | Phase 1 |
|
||||
| Extract `io_utils`, `env_utils` | Low | Phase 2 |
|
||||
| Compose env DRY (YAML anchor) | Low | Phase 2 |
|
||||
| Meta read dedup in `main.py` | Low | Phase 3 |
|
||||
| Magic numbers → config constants | Low | Phase 3 |
|
||||
| Characterization tests | Low | Phase 5 (before deeper refactors) |
|
||||
| `pipeline.py` split | Medium | **Deferred** — tests first, split later |
|
||||
| Meta file locking | Medium–High | **Deferred** — behavior change |
|
||||
| Dependency major bumps | Medium | List only, no auto-bump |
|
||||
|
||||
## Roadmap & commits
|
||||
|
||||
| Phase | Step | Commit theme |
|
||||
|-------|------|--------------|
|
||||
| 0 | This file | `docs: add cleanup plan from phase-0 analysis` |
|
||||
| 1 | pyproject.toml + ruff | `chore: add pyproject.toml with ruff and pytest config` |
|
||||
| 1 | ruff format | `style: apply ruff format to Python sources` |
|
||||
| 1 | dead code + gitignore | `chore: remove dead code and extend gitignore` |
|
||||
| 2 | io_utils extraction | `refactor: extract atomic JSON helpers to io_utils` |
|
||||
| 2 | env_utils + logging | `refactor: share env-bool parsing and logging setup` |
|
||||
| 2 | compose DRY | `chore: deduplicate compose environment blocks` |
|
||||
| 3 | config constants | `refactor: replace magic numbers with named constants` |
|
||||
| 3 | meta read dedup | `refactor: read job meta once in list_jobs` |
|
||||
| 4 | dev requirements | `chore: add dev requirements and python version pin` |
|
||||
| 4 | Gitea CI | `ci: add lint and test workflow` |
|
||||
| 5 | tests | `test: add characterization tests for core modules` |
|
||||
| 5 | docs | `docs: update README and add ARCHITECTURE` |
|
||||
|
||||
## Conscious non-goals
|
||||
|
||||
- No `pipeline.py` module split in this pass
|
||||
- No meta file locking / concurrency fix
|
||||
- No dependency major version bumps
|
||||
- ~~No changes to `compare-bg/` experiment~~ (later removed entirely)
|
||||
- No deploy CI (manual `docker compose` on boka stays)
|
||||
- No behavior changes to gmic/rembg processing logic
|
||||
@@ -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
|
||||
@@ -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
|
||||
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`)
|
||||
|
||||
| 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 |
|
||||
| `FILTER_TIMEOUT` | `120` | Seconds before a single gmic call is killed |
|
||||
| `MAX_FILTER_ATTEMPTS` | `8` | Random-filter retry budget per bg/fg pick |
|
||||
| `MAX_EDGE_PX` | `2000` | Longest edge after ImageMagick preprocess |
|
||||
| `PREPROCESS_TIMEOUT` | `120` | Seconds for preprocess step |
|
||||
| `NICE_LEVEL` | `18` | `nice -n` level for gmic/rembg subprocesses |
|
||||
|
||||
## Deploy notes / caveats
|
||||
|
||||
+7
-1
@@ -9,6 +9,8 @@ from __future__ import annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .env_utils import env_bool
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
try:
|
||||
@@ -32,6 +34,8 @@ INTERMEDIATES_DIR = DATA_DIR / "intermediates"
|
||||
# Web bookkeeping (status + manifest). Not needed on the phone — ignore via
|
||||
# Syncthing .stignore if desired.
|
||||
META_DIR = DATA_DIR / "meta"
|
||||
# Resized WebP cache for srcset (web container only; safe to wipe / .stignore).
|
||||
WEB_CACHE_DIR = DATA_DIR / "webcache"
|
||||
ASSETS_DIR = DATA_DIR / "assets"
|
||||
PROCESSED_FILE = DATA_DIR / "processed.json"
|
||||
|
||||
@@ -55,13 +59,15 @@ MAX_FILTER_ATTEMPTS = _int_env("MAX_FILTER_ATTEMPTS", 8)
|
||||
# Longest edge after preprocess (ImageMagick). Keeps rembg/gmic sane on
|
||||
# large camera JPEGs.
|
||||
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,
|
||||
# which can take a while depending on the link.
|
||||
REMBG_TIMEOUT = _int_env("REMBG_TIMEOUT", 600)
|
||||
# birefnet-general OOMs on the ~8GB event box; u2net + alpha matting fits
|
||||
# and is the practical equivalent of `rembg i -a`.
|
||||
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)
|
||||
|
||||
# gmic CLI binary, override for local dev if not on PATH. rembg has no
|
||||
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -13,15 +13,19 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
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")
|
||||
|
||||
app = FastAPI(title=config.SITE_TITLE)
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
templates.env.globals["img_attrs"] = images.img_attrs
|
||||
templates.env.globals["PAIR_SIZES"] = images.PAIR_SIZES
|
||||
templates.env.globals["GRID_SIZES"] = images.GRID_SIZES
|
||||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||
|
||||
# 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:
|
||||
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):
|
||||
raise HTTPException(status_code=400, detail="Ungueltige Job-ID")
|
||||
return stem
|
||||
@@ -63,15 +70,15 @@ def _safe_job_file(job_id: str, filename: str) -> Path:
|
||||
def list_jobs() -> list[dict[str, Any]]:
|
||||
jobs = []
|
||||
for job_id in pipeline.list_job_ids():
|
||||
status = pipeline.read_status(job_id) or {}
|
||||
manifest = pipeline.read_manifest(job_id) or {}
|
||||
data = pipeline.read_meta(job_id) or {}
|
||||
variants = data.get("variants") or []
|
||||
jobs.append(
|
||||
{
|
||||
"job_id": job_id,
|
||||
"status": status.get("status", "unknown"),
|
||||
"created_at": status.get("created_at") or manifest.get("created_at") or "",
|
||||
"variant_count": len(manifest.get("variants", [])),
|
||||
"thumbnail": (manifest.get("variants") or [{}])[-1].get("file") if manifest.get("variants") else None,
|
||||
"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)
|
||||
@@ -81,8 +88,9 @@ def list_jobs() -> list[dict[str, Any]]:
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"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)
|
||||
status = pipeline.read_status(stem) or {}
|
||||
manifest = pipeline.read_manifest(stem) or {"variants": []}
|
||||
intermediates = sorted(
|
||||
p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file()
|
||||
) if paths.intermediates.exists() else []
|
||||
intermediates = (
|
||||
sorted(p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file())
|
||||
if paths.intermediates.exists()
|
||||
else []
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"job.html",
|
||||
{
|
||||
"request": request,
|
||||
"site_title": config.SITE_TITLE,
|
||||
"job_id": stem,
|
||||
"status": status,
|
||||
@@ -113,9 +123,25 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse:
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/files/{filename:path}")
|
||||
def job_file(job_id: str, filename: str) -> FileResponse:
|
||||
def job_file(
|
||||
job_id: str,
|
||||
filename: str,
|
||||
w: int | None = Query(None, description="Longest-edge width for srcset WebP"),
|
||||
) -> FileResponse:
|
||||
path = _safe_job_file(job_id, filename)
|
||||
if w is None:
|
||||
return FileResponse(path)
|
||||
if w not in images.SRCSET_WIDTHS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"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)
|
||||
@@ -163,9 +189,9 @@ def remix_form(
|
||||
}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"remix.html",
|
||||
{
|
||||
"request": request,
|
||||
"site_title": config.SITE_TITLE,
|
||||
"job_id": stem,
|
||||
"options": options,
|
||||
|
||||
+112
-45
@@ -15,22 +15,25 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
from typing import Any
|
||||
|
||||
from . import config
|
||||
from .io_utils import file_lock, read_json, write_json_atomic
|
||||
|
||||
logger = logging.getLogger("livef12.pipeline")
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
# Optional override for run_gmic timeout (used by the long-retry pass).
|
||||
_filter_timeout_override: int | None = None
|
||||
# Per-thread override for run_gmic timeout (used by the long-retry pass).
|
||||
_filter_timeout_state = threading.local()
|
||||
|
||||
|
||||
class PipelineError(RuntimeError):
|
||||
@@ -67,19 +70,19 @@ def _nice(cmd: list[str]) -> list[str]:
|
||||
|
||||
|
||||
def _active_filter_timeout() -> int:
|
||||
return _filter_timeout_override if _filter_timeout_override is not None else config.FILTER_TIMEOUT
|
||||
override = getattr(_filter_timeout_state, "override", None)
|
||||
return override if override is not None else config.FILTER_TIMEOUT
|
||||
|
||||
|
||||
@contextmanager
|
||||
def filter_timeout(seconds: int) -> Iterator[None]:
|
||||
"""Temporarily override FILTER_TIMEOUT for run_gmic (long-retry pass)."""
|
||||
global _filter_timeout_override
|
||||
previous = _filter_timeout_override
|
||||
_filter_timeout_override = seconds
|
||||
previous = getattr(_filter_timeout_state, "override", None)
|
||||
_filter_timeout_state.override = seconds
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_filter_timeout_override = previous
|
||||
_filter_timeout_state.override = previous
|
||||
|
||||
|
||||
def _magick_bin() -> str:
|
||||
@@ -125,7 +128,9 @@ def load_assets() -> FilterAssets:
|
||||
if not bg_names or not fg_names:
|
||||
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]:
|
||||
@@ -167,7 +172,9 @@ def apply_filter(image: Path, full_command: str, output_image: Path) -> tuple[bo
|
||||
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
|
||||
for i, command in enumerate(commands):
|
||||
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, ""
|
||||
|
||||
|
||||
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]:
|
||||
ok, err, _ = run_gmic([str(base), str(overlay), "blend", f"{mode},{opacity}"], output_image)
|
||||
return ok, err
|
||||
@@ -236,10 +238,10 @@ def preprocess_original(paths: JobPaths) -> JobPaths:
|
||||
]
|
||||
)
|
||||
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:
|
||||
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:
|
||||
tmp.unlink(missing_ok=True)
|
||||
err = strip_ansi((proc.stderr or proc.stdout or "").strip())
|
||||
@@ -297,7 +299,7 @@ def pick_working_filter(
|
||||
|
||||
|
||||
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)
|
||||
@@ -305,8 +307,10 @@ _UNSAFE_STEM_RE = re.compile(r"[^\w.\-]+", re.UNICODE)
|
||||
|
||||
def sanitize_stem(name: str) -> str:
|
||||
"""Safe basename stem for flat files (no path separators / junk)."""
|
||||
stem = Path(name).stem if name else ""
|
||||
stem = stem.replace("/", "_").replace("\\", "_").strip().strip(".")
|
||||
# Replace separators before Path.stem so "a/b" → "a_b", not "b".
|
||||
stem = (name or "").replace("/", "_").replace("\\", "_")
|
||||
stem = Path(stem).stem
|
||||
stem = stem.strip().strip(".")
|
||||
stem = _UNSAFE_STEM_RE.sub("_", stem).strip("._")
|
||||
return stem or "photo"
|
||||
|
||||
@@ -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)
|
||||
if not paths.meta.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(paths.meta.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
data = read_json(paths.meta)
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _write_meta(paths: JobPaths, data: dict[str, Any]) -> None:
|
||||
paths.meta.parent.mkdir(parents=True, exist_ok=True)
|
||||
def _meta_lock_path(stem: str) -> Path:
|
||||
return config.META_DIR / f"{sanitize_stem(stem)}.lock"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def meta_lock(stem: str) -> Iterator[None]:
|
||||
"""Exclusive lock for meta/{stem}.json read-modify-write."""
|
||||
with file_lock(_meta_lock_path(stem)):
|
||||
yield
|
||||
|
||||
|
||||
def _write_meta_unlocked(paths: JobPaths, data: dict[str, Any]) -> None:
|
||||
data["updated_at"] = now_iso()
|
||||
tmp = paths.meta.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(paths.meta)
|
||||
write_json_atomic(paths.meta, data)
|
||||
|
||||
|
||||
def _merge_variants(existing: list[Any], incoming: list[Any]) -> list[dict[str, Any]]:
|
||||
"""Union variants by id; incoming wins on conflict; preserve discovery order."""
|
||||
by_id: dict[str, dict[str, Any]] = {}
|
||||
order: list[str] = []
|
||||
for group in (existing, incoming):
|
||||
for item in group:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
vid = item.get("id")
|
||||
if not isinstance(vid, str) or not vid:
|
||||
continue
|
||||
if vid not in by_id:
|
||||
order.append(vid)
|
||||
by_id[vid] = item
|
||||
return [by_id[vid] for vid in order]
|
||||
|
||||
|
||||
def read_status(job_id: str) -> dict[str, Any] | None:
|
||||
data = _read_meta(job_id)
|
||||
data = read_meta(job_id)
|
||||
if not data:
|
||||
return None
|
||||
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:
|
||||
data = _read_meta(paths.stem) or {}
|
||||
with meta_lock(paths.stem):
|
||||
data = read_meta(paths.stem) or {}
|
||||
data["job_id"] = paths.stem
|
||||
data["source_stem"] = paths.stem
|
||||
data["status"] = status
|
||||
data.setdefault("created_at", now_iso())
|
||||
data.update(extra)
|
||||
_write_meta(paths, data)
|
||||
_write_meta_unlocked(paths, data)
|
||||
|
||||
|
||||
def read_manifest(job_id: str) -> dict[str, Any] | None:
|
||||
data = _read_meta(job_id)
|
||||
data = read_meta(job_id)
|
||||
if not data:
|
||||
return None
|
||||
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:
|
||||
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["source_stem"] = manifest.get("source_stem") or paths.stem
|
||||
data["original_file"] = manifest.get("original_file")
|
||||
data["rembg_file"] = manifest.get("rembg_file")
|
||||
data["variants"] = manifest.get("variants") or []
|
||||
if manifest.get("source_stem"):
|
||||
data["source_stem"] = manifest["source_stem"]
|
||||
else:
|
||||
data.setdefault("source_stem", paths.stem)
|
||||
if "original_file" in manifest and manifest["original_file"] is not None:
|
||||
data["original_file"] = manifest["original_file"]
|
||||
if "rembg_file" in manifest and manifest["rembg_file"] is not None:
|
||||
data["rembg_file"] = manifest["rembg_file"]
|
||||
data["variants"] = _merge_variants(data.get("variants") or [], manifest.get("variants") or [])
|
||||
if "created_at" in manifest:
|
||||
data.setdefault("created_at", manifest["created_at"])
|
||||
data.setdefault("status", data.get("status", "processing"))
|
||||
_write_meta(paths, data)
|
||||
_write_meta_unlocked(paths, data)
|
||||
|
||||
|
||||
def append_manifest_variant(
|
||||
paths: JobPaths,
|
||||
entry: dict[str, Any],
|
||||
*,
|
||||
original_file: str | None = None,
|
||||
rembg_file: str | None = None,
|
||||
source_stem: str | None = None,
|
||||
created_at: str | None = None,
|
||||
) -> None:
|
||||
"""Append one variant under meta lock (re-reads disk so concurrent updates survive)."""
|
||||
with meta_lock(paths.stem):
|
||||
data = read_meta(paths.stem) or {}
|
||||
data["job_id"] = paths.stem
|
||||
if source_stem:
|
||||
data["source_stem"] = source_stem
|
||||
else:
|
||||
data.setdefault("source_stem", paths.stem)
|
||||
if original_file:
|
||||
data["original_file"] = original_file
|
||||
if rembg_file:
|
||||
data["rembg_file"] = rembg_file
|
||||
if created_at:
|
||||
data.setdefault("created_at", created_at)
|
||||
data.setdefault("created_at", now_iso())
|
||||
data.setdefault("status", data.get("status", "done"))
|
||||
data["variants"] = _merge_variants(data.get("variants") or [], [entry])
|
||||
_write_meta_unlocked(paths, data)
|
||||
|
||||
|
||||
def list_job_ids() -> list[str]:
|
||||
@@ -483,14 +546,18 @@ def compose_variant(
|
||||
if not bg_command:
|
||||
raise PipelineError(f"Unbekannter Background-Filter: {bg_name}")
|
||||
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:
|
||||
fg_command = assets.commands.get(fg_name)
|
||||
if not fg_command:
|
||||
raise PipelineError(f"Unbekannter Foreground-Filter: {fg_name}")
|
||||
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 = {
|
||||
"bg_filtered": tmp_dir / f"{prefix}_bg_filtered.png",
|
||||
|
||||
+2
-7
@@ -18,12 +18,7 @@ import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
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"}
|
||||
from .env_utils import env_bool
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -42,7 +37,7 @@ def main() -> int:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
model = os.environ.get("REMBG_MODEL", "u2net")
|
||||
alpha = _env_bool("REMBG_ALPHA", True)
|
||||
alpha = env_bool("REMBG_ALPHA", True)
|
||||
session = new_session(model)
|
||||
output_path.write_bytes(
|
||||
remove(
|
||||
|
||||
+11
-4
@@ -3,6 +3,7 @@ cached original + rembg output and user-chosen filters/blends/opacity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import pipeline
|
||||
@@ -52,8 +53,8 @@ def create_remix_variant(job_id: str, choice: RemixChoice) -> dict:
|
||||
"variants": [],
|
||||
}
|
||||
variant_stem = pipeline.resolve_source_stem(manifest, paths)
|
||||
manifest.setdefault("source_stem", variant_stem)
|
||||
variant_id = pipeline.next_variant_id(manifest)
|
||||
# Unique id avoids collisions when two remixes run in parallel.
|
||||
variant_id = f"v{uuid.uuid4().hex[:8]}"
|
||||
|
||||
entry = pipeline.compose_variant(
|
||||
paths,
|
||||
@@ -67,6 +68,12 @@ def create_remix_variant(job_id: str, choice: RemixChoice) -> dict:
|
||||
fg_mode=choice.fg_blend,
|
||||
opacity=choice.opacity,
|
||||
)
|
||||
manifest["variants"].append(entry)
|
||||
pipeline.write_manifest(paths, manifest)
|
||||
pipeline.append_manifest_variant(
|
||||
paths,
|
||||
entry,
|
||||
original_file=paths.original.name,
|
||||
rembg_file=paths.rembg.name,
|
||||
source_stem=variant_stem,
|
||||
created_at=manifest.get("created_at") if isinstance(manifest.get("created_at"), str) else None,
|
||||
)
|
||||
return entry
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
if (!target.classList.contains("lightboxable")) return;
|
||||
e.preventDefault();
|
||||
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) {
|
||||
|
||||
@@ -9,7 +9,15 @@
|
||||
<li class="job-card">
|
||||
<a href="/jobs/{{ job.job_id }}">
|
||||
{% if job.thumbnail %}
|
||||
<img class="thumb lightboxable" src="/jobs/{{ job.job_id }}/files/{{ job.thumbnail }}" alt="Variante von Job {{ job.job_id }}" loading="lazy">
|
||||
{% set img = img_attrs(job.job_id, job.thumbnail) %}
|
||||
<img class="thumb 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 %}
|
||||
<span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status }}</span>
|
||||
{% endif %}
|
||||
|
||||
+36
-4
@@ -13,8 +13,16 @@
|
||||
<h2>Original & Rembg</h2>
|
||||
<div class="pair-grid">
|
||||
{% if original_name %}
|
||||
{% set img = img_attrs(job_id, original_name, sizes=PAIR_SIZES) %}
|
||||
<figure>
|
||||
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ original_name }}" alt="Original" loading="lazy">
|
||||
<img class="lightboxable"
|
||||
src="{{ img.src }}"
|
||||
srcset="{{ img.srcset }}"
|
||||
sizes="{{ img.sizes }}"
|
||||
data-full-src="{{ img.full_src }}"
|
||||
alt="Original"
|
||||
loading="lazy"
|
||||
decoding="async">
|
||||
<figcaption>
|
||||
Original ·
|
||||
<a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a>
|
||||
@@ -22,8 +30,16 @@
|
||||
</figure>
|
||||
{% endif %}
|
||||
{% if has_rembg and rembg_name %}
|
||||
{% set img = img_attrs(job_id, rembg_name, sizes=PAIR_SIZES) %}
|
||||
<figure>
|
||||
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ rembg_name }}" alt="Freigestellt (rembg)" loading="lazy">
|
||||
<img class="lightboxable"
|
||||
src="{{ img.src }}"
|
||||
srcset="{{ img.srcset }}"
|
||||
sizes="{{ img.sizes }}"
|
||||
data-full-src="{{ img.full_src }}"
|
||||
alt="Freigestellt (rembg)"
|
||||
loading="lazy"
|
||||
decoding="async">
|
||||
<figcaption>
|
||||
Rembg ·
|
||||
<a href="/jobs/{{ job_id }}/files/{{ rembg_name }}" download>Download</a>
|
||||
@@ -45,8 +61,16 @@
|
||||
{% else %}
|
||||
<ul class="variant-grid">
|
||||
{% for v in manifest.variants %}
|
||||
{% set img = img_attrs(job_id, v.file) %}
|
||||
<li class="variant-card">
|
||||
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ v.file }}" alt="Variante {{ v.id }}" loading="lazy">
|
||||
<img class="lightboxable"
|
||||
src="{{ img.src }}"
|
||||
srcset="{{ img.srcset }}"
|
||||
sizes="{{ img.sizes }}"
|
||||
data-full-src="{{ img.full_src }}"
|
||||
alt="Variante {{ v.id }}"
|
||||
loading="lazy"
|
||||
decoding="async">
|
||||
<div class="variant-meta">
|
||||
<strong>{{ v.id }}</strong> <span class="tag">{{ v.source }}</span>
|
||||
<dl>
|
||||
@@ -72,8 +96,16 @@
|
||||
<h2>Zwischenschritte</h2>
|
||||
<ul class="intermediate-grid">
|
||||
{% for name in intermediates %}
|
||||
{% set img = img_attrs(job_id, name) %}
|
||||
<li>
|
||||
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ name }}" alt="{{ name }}" loading="lazy">
|
||||
<img class="lightboxable"
|
||||
src="{{ img.src }}"
|
||||
srcset="{{ img.srcset }}"
|
||||
sizes="{{ img.sizes }}"
|
||||
data-full-src="{{ img.full_src }}"
|
||||
alt="{{ name }}"
|
||||
loading="lazy"
|
||||
decoding="async">
|
||||
<span>{{ name }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
+8
-14
@@ -13,7 +13,6 @@ pipeline is CPU heavy (gmic/rembg) and the worker container is capped at
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
@@ -21,11 +20,10 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import config, pipeline
|
||||
from .io_utils import read_json, write_json_atomic
|
||||
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.worker")
|
||||
|
||||
|
||||
@@ -42,17 +40,15 @@ def ensure_dirs() -> None:
|
||||
def load_processed() -> dict[str, Any]:
|
||||
if not config.PROCESSED_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(config.PROCESSED_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
data = read_json(config.PROCESSED_FILE)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
logger.warning("processed.json unreadable, starting fresh")
|
||||
return {}
|
||||
|
||||
|
||||
def save_processed(processed: dict[str, Any]) -> None:
|
||||
tmp = config.PROCESSED_FILE.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(processed, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(config.PROCESSED_FILE)
|
||||
write_json_atomic(config.PROCESSED_FILE, processed)
|
||||
|
||||
|
||||
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."""
|
||||
if name.startswith("."):
|
||||
return True
|
||||
if name.startswith(".syncthing.") or ".syncthing." in name:
|
||||
return True
|
||||
return False
|
||||
return bool(name.startswith(".syncthing.") or ".syncthing." in name)
|
||||
|
||||
|
||||
def _is_stable(path: Path) -> bool:
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Background-removal comparison
|
||||
|
||||
Inbox originals from `live.f12.rocks` vs. backends.
|
||||
|
||||
## Layout
|
||||
|
||||
- `inputs/` — originals from boka inbox
|
||||
- `outputs/` — results, naming:
|
||||
|
||||
```
|
||||
<stem>__rembg-u2net__no-alpha.png
|
||||
<stem>__rembg-u2net__alpha.png
|
||||
<stem>__rembg-default__no-alpha.png
|
||||
<stem>__rembg-default__alpha.png
|
||||
<stem>__rembg-birefnet-general__no-alpha.png
|
||||
<stem>__rembg-birefnet-general__alpha.png
|
||||
<stem>__withoutbg-open-weights.png
|
||||
```
|
||||
|
||||
- `rembg-default` = `rembg.remove()` without session (library default = u2net)
|
||||
- `rembg-u2net` = explicit `new_session("u2net")` — expect identical to default
|
||||
- `withoutbg` open-weights includes matting in-graph (~2GB RAM claimed)
|
||||
|
||||
## Re-run
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Skips existing non-empty outputs. Models cached in `model-cache/`.
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run rembg/withoutbg comparison in Python 3.11 (host is 3.14-only).
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
IMG="livef12-bg-compare:py311"
|
||||
LOG="$ROOT/compare.log"
|
||||
|
||||
docker build -t "$IMG" - <<'EOF'
|
||||
FROM python:3.11-slim-bookworm
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN pip install --no-cache-dir 'rembg>=2.0.59,<2.1' 'pillow>=10.4,<11' \
|
||||
'onnxruntime>=1.19,<1.20' withoutbg
|
||||
WORKDIR /work
|
||||
EOF
|
||||
|
||||
echo "Starting comparison; log: $LOG"
|
||||
docker run --rm \
|
||||
--name livef12-bg-compare \
|
||||
-e TQDM_DISABLE=1 \
|
||||
-e COMPARE_INPUT=/data/inputs \
|
||||
-e COMPARE_OUTPUT=/data/outputs \
|
||||
-e HOME=/cache \
|
||||
-v "$ROOT/inputs:/data/inputs:ro" \
|
||||
-v "$ROOT/outputs:/data/outputs" \
|
||||
-v "$ROOT/run_compare.py:/work/run_compare.py:ro" \
|
||||
-v "$ROOT/model-cache:/cache" \
|
||||
"$IMG" python -u /work/run_compare.py 2>&1 | tee "$LOG"
|
||||
@@ -1,115 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare background-removal backends on the live.f12.rocks inbox uploads.
|
||||
|
||||
Outputs land next to each other with explicit names so Fränky can pick a winner:
|
||||
|
||||
<stem>__rembg-u2net__no-alpha.png
|
||||
<stem>__rembg-u2net__alpha.png
|
||||
<stem>__rembg-default__no-alpha.png
|
||||
<stem>__rembg-default__alpha.png
|
||||
<stem>__rembg-birefnet-general__no-alpha.png
|
||||
<stem>__rembg-birefnet-general__alpha.png
|
||||
<stem>__withoutbg-open-weights.png
|
||||
|
||||
`rembg-default` = rembg.remove() with no session (library default model).
|
||||
`rembg-u2net` = rembg.new_session("u2net") explicitly.
|
||||
withoutbg embeds matting in its open-weights graph — one output only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("TQDM_DISABLE", "1")
|
||||
|
||||
INPUT_DIR = Path(os.environ.get("COMPARE_INPUT", "/data/inputs"))
|
||||
OUTPUT_DIR = Path(os.environ.get("COMPARE_OUTPUT", "/data/outputs"))
|
||||
EXTS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def run_rembg_named(data: bytes, label: str, model: str | None, alpha: bool, out: Path) -> None:
|
||||
from rembg import new_session, remove
|
||||
|
||||
t0 = time.monotonic()
|
||||
kwargs: dict = {"alpha_matting": alpha}
|
||||
if model is None:
|
||||
result = remove(data, **kwargs)
|
||||
model_note = "default"
|
||||
else:
|
||||
session = new_session(model)
|
||||
result = remove(data, session=session, **kwargs)
|
||||
model_note = model
|
||||
out.write_bytes(result)
|
||||
log(f" OK {label} model={model_note} alpha={alpha} {time.monotonic()-t0:.1f}s -> {out.name} ({out.stat().st_size} bytes)")
|
||||
|
||||
|
||||
def run_withoutbg(path: Path, out: Path) -> None:
|
||||
from withoutbg import WithoutBG
|
||||
|
||||
t0 = time.monotonic()
|
||||
model = WithoutBG.open_weights()
|
||||
result = model.remove_background(str(path))
|
||||
result.save(out)
|
||||
log(f" OK withoutbg-open-weights {time.monotonic()-t0:.1f}s -> {out.name} ({out.stat().st_size} bytes)")
|
||||
|
||||
|
||||
def process_one(path: Path) -> None:
|
||||
stem = path.stem
|
||||
log(f"=== {path.name} ===")
|
||||
data = path.read_bytes()
|
||||
jobs = [
|
||||
("rembg-u2net", "u2net", False),
|
||||
("rembg-u2net", "u2net", True),
|
||||
("rembg-default", None, False),
|
||||
("rembg-default", None, True),
|
||||
("rembg-birefnet-general", "birefnet-general", False),
|
||||
("rembg-birefnet-general", "birefnet-general", True),
|
||||
]
|
||||
for label, model, alpha in jobs:
|
||||
tag = "alpha" if alpha else "no-alpha"
|
||||
out = OUTPUT_DIR / f"{stem}__{label}__{tag}.png"
|
||||
if out.exists() and out.stat().st_size > 0:
|
||||
log(f" SKIP {out.name} (exists)")
|
||||
continue
|
||||
try:
|
||||
run_rembg_named(data, label, model, alpha, out)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
err = OUTPUT_DIR / f"{stem}__{label}__{tag}.ERROR.txt"
|
||||
err.write_text(f"{type(exc).__name__}: {exc}\n\n{traceback.format_exc()}", encoding="utf-8")
|
||||
log(f" FAIL {label} alpha={alpha}: {type(exc).__name__}: {exc}")
|
||||
|
||||
out_w = OUTPUT_DIR / f"{stem}__withoutbg-open-weights.png"
|
||||
if out_w.exists() and out_w.stat().st_size > 0:
|
||||
log(f" SKIP {out_w.name} (exists)")
|
||||
else:
|
||||
try:
|
||||
run_withoutbg(path, out_w)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
err = OUTPUT_DIR / f"{stem}__withoutbg-open-weights.ERROR.txt"
|
||||
err.write_text(f"{type(exc).__name__}: {exc}\n\n{traceback.format_exc()}", encoding="utf-8")
|
||||
log(f" FAIL withoutbg: {type(exc).__name__}: {exc}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
inputs = sorted(p for p in INPUT_DIR.iterdir() if p.is_file() and p.suffix.lower() in EXTS)
|
||||
if not inputs:
|
||||
log(f"No images in {INPUT_DIR}")
|
||||
return 1
|
||||
log(f"Comparing {len(inputs)} image(s); outputs -> {OUTPUT_DIR}")
|
||||
for path in inputs:
|
||||
process_one(path)
|
||||
log("DONE")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+12
-20
@@ -8,14 +8,7 @@
|
||||
# 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.
|
||||
|
||||
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:
|
||||
x-app-env: &app-env
|
||||
DATA_DIR: /data
|
||||
OUTPUT_COUNT: ${OUTPUT_COUNT:-3}
|
||||
BLEND_OPACITY: ${BLEND_OPACITY:-30%}
|
||||
@@ -28,6 +21,16 @@ services:
|
||||
REMBG_MODEL: ${REMBG_MODEL:-u2net}
|
||||
REMBG_ALPHA: ${REMBG_ALPHA:-1}
|
||||
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:
|
||||
- ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data
|
||||
- ./assets:/data/assets:ro
|
||||
@@ -48,18 +51,7 @@ services:
|
||||
depends_on:
|
||||
- worker
|
||||
environment:
|
||||
DATA_DIR: /data
|
||||
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}
|
||||
<<: *app-env
|
||||
SITE_TITLE: live.f12.rocks
|
||||
volumes:
|
||||
- ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.3,<9
|
||||
httpx>=0.27,<0.29
|
||||
ruff>=0.8,<0.10
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user