feat: harden pipeline UX — preprocess, rembg alpha, remix/lightbox
Downscale to 2000px JPEG before rembg, random blend opacity, timeout retries, per-variant remix prefill, lightbox, and longer SFTP idle. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,9 +13,21 @@ DATA_HOST_DIR=./data
|
||||
# Spec asked for 121212 which exceeds TCP max (65535). Use 12121.
|
||||
SFTP_HOST_PORT=12121
|
||||
|
||||
# Minutes until SFTPGo closes an idle connection. Android/SSHJ clients
|
||||
# often sit idle after upload; the default 15m shows up as a spurious EOF.
|
||||
SFTPGO_IDLE_TIMEOUT=120
|
||||
|
||||
# --- Pipeline tuning (see make_random.py for background) -----------------
|
||||
OUTPUT_COUNT=3
|
||||
# Remix-form default / legacy; auto variants use the min/max range below.
|
||||
BLEND_OPACITY=30%
|
||||
BLEND_OPACITY_MIN=10
|
||||
BLEND_OPACITY_MAX=50
|
||||
FILTER_TIMEOUT=120
|
||||
FILTER_TIMEOUT_LONG=300
|
||||
MAX_FILTER_ATTEMPTS=8
|
||||
MAX_EDGE_PX=2000
|
||||
# birefnet-general OOMs on the ~8GB box; u2net + alpha ≈ `rembg i -a`
|
||||
REMBG_MODEL=u2net
|
||||
REMBG_ALPHA=1
|
||||
NICE_LEVEL=18
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
# Runtime data (bind-mounted, never belongs 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
|
||||
|
||||
+4
-2
@@ -25,6 +25,7 @@ ARG GMIC_DEB_URL=https://gmic.eu/get_file.php?file=linux/gmic_4.0.2_debian12_boo
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
imagemagick \
|
||||
python3 \
|
||||
python3-pip \
|
||||
&& curl -fsSL "$GMIC_DEB_URL" -o /tmp/gmic.deb \
|
||||
@@ -49,9 +50,10 @@ RUN pip install --break-system-packages -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
# rembg downloads its ONNX model (u2net, ~176MB) from GitHub on first use
|
||||
# rembg downloads its ONNX model (default u2net) from GitHub on first use
|
||||
# and caches it under $HOME/.u2net — persisted via the `rembg_cache`
|
||||
# volume in compose.yml so it survives container restarts.
|
||||
# volume in compose.yml so it survives container restarts. Alpha matting
|
||||
# is enabled by default (REMBG_ALPHA=1).
|
||||
|
||||
# Overridden per-service in compose.yml (`worker` -> python -m app.worker,
|
||||
# `web` -> uvicorn app.main:app).
|
||||
|
||||
+18
-2
@@ -38,18 +38,34 @@ FILTERS_JSON = ASSETS_DIR / "filters.json"
|
||||
|
||||
# --- Processing ----------------------------------------------------------
|
||||
OUTPUT_COUNT = _int_env("OUTPUT_COUNT", 3)
|
||||
# Fallback / remix default. Auto variants pick randomly in
|
||||
# [BLEND_OPACITY_MIN, BLEND_OPACITY_MAX] instead.
|
||||
BLEND_OPACITY = os.environ.get("BLEND_OPACITY", "30%")
|
||||
BLEND_OPACITY_MIN = _int_env("BLEND_OPACITY_MIN", 10)
|
||||
BLEND_OPACITY_MAX = _int_env("BLEND_OPACITY_MAX", 50)
|
||||
FILTER_TIMEOUT = _int_env("FILTER_TIMEOUT", 120)
|
||||
# Second-chance timeout for variants that hit FILTER_TIMEOUT during the
|
||||
# normal pass — retried once at the end of the job (not during filter probes).
|
||||
FILTER_TIMEOUT_LONG = _int_env("FILTER_TIMEOUT_LONG", 300)
|
||||
MAX_FILTER_ATTEMPTS = _int_env("MAX_FILTER_ATTEMPTS", 8)
|
||||
# Generous on purpose: the very first rembg call also downloads the ~176MB
|
||||
# u2net model, which can take a while depending on the link.
|
||||
# Longest edge after preprocess (ImageMagick). Keeps rembg/gmic sane on
|
||||
# large camera JPEGs.
|
||||
MAX_EDGE_PX = _int_env("MAX_EDGE_PX", 2000)
|
||||
# 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"}
|
||||
NICE_LEVEL = _int_env("NICE_LEVEL", 18)
|
||||
|
||||
# gmic CLI binary, override for local dev if not on PATH. rembg has no
|
||||
# equivalent here — it runs via `python -m app.rembg_cli` (see pipeline.py
|
||||
# run_rembg / app/rembg_cli.py), not a standalone binary.
|
||||
GMIC_BIN = os.environ.get("GMIC_BIN", "gmic")
|
||||
# ImageMagick binary (`magick` on IM7, `convert` on IM6). Empty = auto-detect.
|
||||
MAGICK_BIN = os.environ.get("MAGICK_BIN", "")
|
||||
|
||||
# Fixed post-processing chain applied to every composed variant, ported
|
||||
# verbatim from make_random.py.
|
||||
|
||||
+40
-2
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import FastAPI, Form, HTTPException, Request
|
||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -119,13 +119,49 @@ def job_file(job_id: str, rel_path: str) -> FileResponse:
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/remix", response_class=HTMLResponse)
|
||||
def remix_form(request: Request, job_id: str, error: str | None = None) -> HTMLResponse:
|
||||
def remix_form(
|
||||
request: Request,
|
||||
job_id: str,
|
||||
error: str | None = None,
|
||||
from_variant: str | None = Query(None, alias="from"),
|
||||
) -> HTMLResponse:
|
||||
job_root = _job_root(job_id)
|
||||
if not (job_root / "rembg.png").exists():
|
||||
raise HTTPException(status_code=409, detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht moeglich.")
|
||||
|
||||
assets = pipeline.load_assets()
|
||||
options = remix.build_remix_options(assets)
|
||||
prefill: dict[str, str] = {
|
||||
"bg_filter": "",
|
||||
"bg_blend": "",
|
||||
"fg_filter": "",
|
||||
"fg_blend": "",
|
||||
"opacity": config.BLEND_OPACITY if config.BLEND_OPACITY in config.OPACITY_CHOICES else "30%",
|
||||
}
|
||||
if from_variant:
|
||||
manifest = pipeline.read_manifest(job_id) or {}
|
||||
match = next((v for v in manifest.get("variants", []) if v.get("id") == from_variant), None)
|
||||
if match:
|
||||
opacity = match.get("blend_opacity") or prefill["opacity"]
|
||||
if opacity not in config.OPACITY_CHOICES:
|
||||
# Snap odd random values (e.g. 37%) to nearest offered choice.
|
||||
try:
|
||||
pct = int(str(opacity).rstrip("%"))
|
||||
nearest = min(
|
||||
config.OPACITY_CHOICES,
|
||||
key=lambda c: abs(int(c.rstrip("%")) - pct),
|
||||
)
|
||||
opacity = nearest
|
||||
except ValueError:
|
||||
opacity = prefill["opacity"]
|
||||
prefill = {
|
||||
"bg_filter": match.get("background_filter") or "",
|
||||
"bg_blend": match.get("background_blend") or "",
|
||||
"fg_filter": match.get("foreground_filter") or "",
|
||||
"fg_blend": match.get("foreground_blend") or "",
|
||||
"opacity": opacity,
|
||||
}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"remix.html",
|
||||
{
|
||||
@@ -134,6 +170,8 @@ def remix_form(request: Request, job_id: str, error: str | None = None) -> HTMLR
|
||||
"job_id": job_id,
|
||||
"options": options,
|
||||
"opacity_choices": config.OPACITY_CHOICES,
|
||||
"prefill": prefill,
|
||||
"from_variant": from_variant,
|
||||
"error": error,
|
||||
},
|
||||
)
|
||||
|
||||
+151
-20
@@ -12,14 +12,16 @@ import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Iterator
|
||||
|
||||
from . import config
|
||||
|
||||
@@ -27,6 +29,9 @@ 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
|
||||
|
||||
|
||||
class PipelineError(RuntimeError):
|
||||
"""Raised when a job (or a single variant) cannot be completed."""
|
||||
@@ -36,6 +41,10 @@ class FilterNotFoundError(PipelineError):
|
||||
"""Raised when no working filter could be picked after retries."""
|
||||
|
||||
|
||||
class FilterTimeoutError(PipelineError):
|
||||
"""Raised when a gmic step hits the active filter timeout."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterAssets:
|
||||
background_names: list[str]
|
||||
@@ -57,6 +66,31 @@ def _nice(cmd: list[str]) -> list[str]:
|
||||
return ["nice", "-n", str(config.NICE_LEVEL), *cmd]
|
||||
|
||||
|
||||
def _active_filter_timeout() -> int:
|
||||
return _filter_timeout_override if _filter_timeout_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
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_filter_timeout_override = previous
|
||||
|
||||
|
||||
def _magick_bin() -> str:
|
||||
if config.MAGICK_BIN:
|
||||
return config.MAGICK_BIN
|
||||
for name in ("magick", "convert"):
|
||||
if shutil.which(name):
|
||||
return name
|
||||
raise PipelineError("ImageMagick nicht installiert (magick/convert fehlt)")
|
||||
|
||||
|
||||
def load_lines(path: Path) -> list[str]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Datei nicht gefunden: {path}")
|
||||
@@ -96,12 +130,13 @@ def load_assets() -> FilterAssets:
|
||||
|
||||
def run_gmic(args: list[str], output_image: Path) -> tuple[bool, str, float]:
|
||||
output_image.parent.mkdir(parents=True, exist_ok=True)
|
||||
timeout = _active_filter_timeout()
|
||||
cmd = _nice([config.GMIC_BIN, *args, "-o", str(output_image)])
|
||||
start = time.monotonic()
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=config.FILTER_TIMEOUT)
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"Timeout nach {config.FILTER_TIMEOUT}s", time.monotonic() - start
|
||||
return False, f"Timeout nach {timeout}s", time.monotonic() - start
|
||||
elapsed = time.monotonic() - start
|
||||
if proc.returncode != 0:
|
||||
err = strip_ansi((proc.stderr or proc.stdout or "").strip())
|
||||
@@ -112,6 +147,14 @@ def run_gmic(args: list[str], output_image: Path) -> tuple[bool, str, float]:
|
||||
return True, "", elapsed
|
||||
|
||||
|
||||
def _raise_on_gmic_failure(ok: bool, err: str, label: str) -> None:
|
||||
if ok:
|
||||
return
|
||||
if err.startswith("Timeout nach"):
|
||||
raise FilterTimeoutError(f"{label}: {err}")
|
||||
raise PipelineError(f"{label}: {err}")
|
||||
|
||||
|
||||
def gmic_filter_args(image: Path, full_command: str) -> list[str]:
|
||||
if " " in full_command:
|
||||
name, args = full_command.split(" ", 1)
|
||||
@@ -163,6 +206,67 @@ def run_rembg(input_image: Path, output_image: Path) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
def preprocess_original(paths: JobPaths) -> JobPaths:
|
||||
"""Downscale to MAX_EDGE_PX and normalize to sRGB JPEG as `original.jpg`.
|
||||
|
||||
Replaces any non-jpg original in the job dir. Side-effect free for the
|
||||
inbox copy — only mutates jobs/<id>/.
|
||||
"""
|
||||
src = paths.original
|
||||
if not src.exists():
|
||||
raise PipelineError(f"Original fehlt: {src}")
|
||||
|
||||
dest = paths.root / "original.jpg"
|
||||
resize = f"{config.MAX_EDGE_PX}x{config.MAX_EDGE_PX}>"
|
||||
# Write to a temp name first so we can replace an existing original.jpg
|
||||
# in-place without reading/writing the same path.
|
||||
tmp = paths.root / f".original_pre_{uuid.uuid4().hex[:8]}.jpg"
|
||||
cmd = _nice(
|
||||
[
|
||||
_magick_bin(),
|
||||
str(src),
|
||||
"-auto-orient",
|
||||
"-resize",
|
||||
resize,
|
||||
"-colorspace",
|
||||
"sRGB",
|
||||
"-quality",
|
||||
"92",
|
||||
str(tmp),
|
||||
]
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise PipelineError("Preprocess Timeout nach 120s") 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())
|
||||
err = err.splitlines()[-1] if err else f"Exit code {proc.returncode}"
|
||||
raise PipelineError(f"Preprocess fehlgeschlagen: {err}")
|
||||
|
||||
tmp.replace(dest)
|
||||
if src.resolve() != dest.resolve():
|
||||
src.unlink(missing_ok=True)
|
||||
|
||||
logger.info(
|
||||
"preprocessed %s -> %s (max_edge=%d)",
|
||||
src.name,
|
||||
dest.name,
|
||||
config.MAX_EDGE_PX,
|
||||
)
|
||||
return JobPaths(
|
||||
root=paths.root,
|
||||
original=dest,
|
||||
rembg=paths.rembg,
|
||||
intermediates=paths.intermediates,
|
||||
variants=paths.variants,
|
||||
manifest=paths.manifest,
|
||||
status=paths.status,
|
||||
)
|
||||
|
||||
|
||||
def pick_working_filter(
|
||||
names: list[str],
|
||||
commands: dict[str, str],
|
||||
@@ -292,7 +396,10 @@ def compose_variant(
|
||||
|
||||
bg_mode = bg_mode or rng.choice(assets.blend_modes)
|
||||
fg_mode = fg_mode or rng.choice(assets.blend_modes)
|
||||
opacity = opacity or config.BLEND_OPACITY
|
||||
if opacity is None:
|
||||
lo = min(config.BLEND_OPACITY_MIN, config.BLEND_OPACITY_MAX)
|
||||
hi = max(config.BLEND_OPACITY_MIN, config.BLEND_OPACITY_MAX)
|
||||
opacity = f"{rng.randint(lo, hi)}%"
|
||||
|
||||
if bg_name:
|
||||
bg_command = assets.commands.get(bg_name)
|
||||
@@ -319,28 +426,22 @@ def compose_variant(
|
||||
paths.variants.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ok, err = apply_filter(paths.original, bg_command, p["bg_filtered"])
|
||||
if not ok:
|
||||
raise PipelineError(f"Background-Filter fehlgeschlagen: {err}")
|
||||
_raise_on_gmic_failure(ok, err, "Background-Filter fehlgeschlagen")
|
||||
|
||||
ok, err = blend_layers_opacity(paths.original, p["bg_filtered"], bg_mode, opacity, p["step1"])
|
||||
if not ok:
|
||||
raise PipelineError(f"Background-Blend fehlgeschlagen: {err}")
|
||||
_raise_on_gmic_failure(ok, err, "Background-Blend fehlgeschlagen")
|
||||
|
||||
ok, err = alpha_composite(p["step1"], paths.rembg, p["step2"])
|
||||
if not ok:
|
||||
raise PipelineError(f"Rembg-Alpha fehlgeschlagen: {err}")
|
||||
_raise_on_gmic_failure(ok, err, "Rembg-Alpha fehlgeschlagen")
|
||||
|
||||
ok, err = apply_filter(paths.rembg, fg_command, p["fg_filtered"])
|
||||
if not ok:
|
||||
raise PipelineError(f"Foreground-Filter fehlgeschlagen: {err}")
|
||||
_raise_on_gmic_failure(ok, err, "Foreground-Filter fehlgeschlagen")
|
||||
|
||||
ok, err = blend_layers_opacity(p["step2"], p["fg_filtered"], fg_mode, opacity, p["composed"])
|
||||
if not ok:
|
||||
raise PipelineError(f"Foreground-Blend fehlgeschlagen: {err}")
|
||||
_raise_on_gmic_failure(ok, err, "Foreground-Blend fehlgeschlagen")
|
||||
|
||||
ok, err = apply_filter_chain(p["composed"], config.POST_FILTERS, p["final"], tmp_dir, variant_id)
|
||||
if not ok:
|
||||
raise PipelineError(f"Post-Processing fehlgeschlagen: {err}")
|
||||
_raise_on_gmic_failure(ok, err, "Post-Processing fehlgeschlagen")
|
||||
|
||||
rel = lambda path: str(path.relative_to(paths.root))
|
||||
return {
|
||||
@@ -367,12 +468,16 @@ def compose_variant(
|
||||
|
||||
|
||||
def process_job(job_id: str, source_path: Path, original_suffix: str) -> None:
|
||||
"""Full pipeline for a freshly ingested inbox file: copy, rembg once,
|
||||
generate OUTPUT_COUNT variants, write manifest + status.
|
||||
"""Full pipeline for a freshly ingested inbox file: preprocess, rembg
|
||||
once, generate OUTPUT_COUNT variants, write manifest + status.
|
||||
|
||||
`source_path` must already be a private copy (jobs/<id>/original.*) —
|
||||
callers (worker.py) are responsible for copying out of inbox first, so
|
||||
the inbox file itself is never touched here.
|
||||
|
||||
Variants that hit FILTER_TIMEOUT during the normal pass are retried
|
||||
once at the end with FILTER_TIMEOUT_LONG. Filter probes in
|
||||
pick_working_filter stay on the short timeout (skip, don't escalate).
|
||||
"""
|
||||
paths = job_paths(job_id, original_suffix)
|
||||
paths.root.mkdir(parents=True, exist_ok=True)
|
||||
@@ -380,6 +485,10 @@ def process_job(job_id: str, source_path: Path, original_suffix: str) -> None:
|
||||
paths.variants.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
write_status(paths, "processing", source_file=str(source_path.name))
|
||||
|
||||
try:
|
||||
paths = preprocess_original(paths)
|
||||
|
||||
manifest: dict[str, Any] = {
|
||||
"job_id": job_id,
|
||||
"original_file": paths.original.name,
|
||||
@@ -388,7 +497,6 @@ def process_job(job_id: str, source_path: Path, original_suffix: str) -> None:
|
||||
"variants": [],
|
||||
}
|
||||
|
||||
try:
|
||||
ok, err = run_rembg(paths.original, paths.rembg)
|
||||
if not ok:
|
||||
raise PipelineError(f"rembg fehlgeschlagen: {err}")
|
||||
@@ -396,16 +504,39 @@ def process_job(job_id: str, source_path: Path, original_suffix: str) -> None:
|
||||
assets = load_assets()
|
||||
rng = random.Random()
|
||||
variant_errors: list[str] = []
|
||||
timeout_retries: list[str] = []
|
||||
|
||||
for i in range(1, config.OUTPUT_COUNT + 1):
|
||||
variant_id = f"v{i}"
|
||||
try:
|
||||
entry = compose_variant(paths, assets, variant_id=variant_id, source="auto", rng=rng)
|
||||
manifest["variants"].append(entry)
|
||||
write_manifest(paths, manifest)
|
||||
except FilterTimeoutError as exc:
|
||||
logger.warning("[%s] variant %s timed out (will retry long): %s", job_id, variant_id, exc)
|
||||
timeout_retries.append(variant_id)
|
||||
except PipelineError as exc:
|
||||
logger.error("[%s] variant %s failed: %s", job_id, variant_id, exc)
|
||||
variant_errors.append(f"{variant_id}: {exc}")
|
||||
|
||||
if timeout_retries:
|
||||
logger.info(
|
||||
"[%s] long-retry %d variant(s) with timeout=%ds: %s",
|
||||
job_id,
|
||||
len(timeout_retries),
|
||||
config.FILTER_TIMEOUT_LONG,
|
||||
", ".join(timeout_retries),
|
||||
)
|
||||
with filter_timeout(config.FILTER_TIMEOUT_LONG):
|
||||
for variant_id in timeout_retries:
|
||||
try:
|
||||
entry = compose_variant(paths, assets, variant_id=variant_id, source="auto", rng=rng)
|
||||
manifest["variants"].append(entry)
|
||||
write_manifest(paths, manifest)
|
||||
except PipelineError as exc:
|
||||
logger.error("[%s] variant %s long-retry failed: %s", job_id, variant_id, exc)
|
||||
variant_errors.append(f"{variant_id}: {exc}")
|
||||
|
||||
if not manifest["variants"]:
|
||||
raise PipelineError("Keine Variante erfolgreich erzeugt: " + "; ".join(variant_errors))
|
||||
|
||||
|
||||
+33
-2
@@ -5,26 +5,57 @@ the `rembg[cli]` extra (aiohttp, gradio, watchdog, ...) just to run a
|
||||
single background removal. Calling the `remove()` API directly from a
|
||||
tiny script keeps the image smaller while still giving pipeline.py a
|
||||
subprocess boundary to apply `nice` and a hard timeout to.
|
||||
|
||||
Defaults target the event box (≈8GB RAM): u2net + alpha matting.
|
||||
`birefnet-general` (~928MB weights) OOMs here; override via REMBG_MODEL
|
||||
/ REMBG_ALPHA if the host has more headroom.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
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"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 3:
|
||||
print("usage: python -m app.rembg_cli <input> <output>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
from rembg import remove
|
||||
# Keep model-download progress bars from polluting error parsing upstream.
|
||||
os.environ.setdefault("TQDM_DISABLE", "1")
|
||||
|
||||
try:
|
||||
from rembg import new_session, remove
|
||||
|
||||
input_path = Path(sys.argv[1])
|
||||
output_path = Path(sys.argv[2])
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(remove(input_path.read_bytes()))
|
||||
|
||||
model = os.environ.get("REMBG_MODEL", "u2net")
|
||||
alpha = _env_bool("REMBG_ALPHA", True)
|
||||
session = new_session(model)
|
||||
output_path.write_bytes(
|
||||
remove(
|
||||
input_path.read_bytes(),
|
||||
session=session,
|
||||
alpha_matting=alpha,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001 - surface real cause to parent process
|
||||
print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -326,3 +326,68 @@ h2 {
|
||||
margin-top: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.variant-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.variant-actions .button {
|
||||
padding: 0.35rem 0.75rem;
|
||||
min-height: 2.25rem;
|
||||
line-height: 1.4rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
img.lightboxable {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* --- lightbox --- */
|
||||
|
||||
body.lightbox-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
}
|
||||
|
||||
.lightbox[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lightbox img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
aspect-ratio: auto;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.75rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
min-height: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
|
||||
@@ -13,5 +13,50 @@
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<div id="lightbox" class="lightbox" hidden>
|
||||
<button type="button" class="lightbox-close" aria-label="Schliessen">×</button>
|
||||
<img id="lightbox-img" alt="">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var box = document.getElementById("lightbox");
|
||||
var img = document.getElementById("lightbox-img");
|
||||
if (!box || !img) return;
|
||||
|
||||
function openLightbox(src, alt) {
|
||||
img.src = src;
|
||||
img.alt = alt || "";
|
||||
box.hidden = false;
|
||||
document.body.classList.add("lightbox-open");
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
box.hidden = true;
|
||||
img.removeAttribute("src");
|
||||
document.body.classList.remove("lightbox-open");
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
var target = e.target;
|
||||
if (!(target instanceof HTMLImageElement)) return;
|
||||
if (!target.classList.contains("lightboxable")) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openLightbox(target.currentSrc || target.src, target.alt);
|
||||
});
|
||||
|
||||
box.addEventListener("click", function (e) {
|
||||
if (e.target === box || (e.target && e.target.classList.contains("lightbox-close"))) {
|
||||
closeLightbox();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape" && !box.hidden) closeLightbox();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<li class="job-card">
|
||||
<a href="/jobs/{{ job.job_id }}">
|
||||
{% if job.thumbnail %}
|
||||
<img class="thumb" src="/jobs/{{ job.job_id }}/files/{{ job.thumbnail }}" alt="Variante von Job {{ job.job_id }}" loading="lazy">
|
||||
<img class="thumb lightboxable" src="/jobs/{{ job.job_id }}/files/{{ job.thumbnail }}" alt="Variante von Job {{ job.job_id }}" loading="lazy">
|
||||
{% else %}
|
||||
<span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="pair-grid">
|
||||
{% if original_name %}
|
||||
<figure>
|
||||
<img src="/jobs/{{ job_id }}/files/{{ original_name }}" alt="Original" loading="lazy">
|
||||
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ original_name }}" alt="Original" loading="lazy">
|
||||
<figcaption>
|
||||
Original ·
|
||||
<a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a>
|
||||
@@ -23,7 +23,7 @@
|
||||
{% endif %}
|
||||
{% if has_rembg %}
|
||||
<figure>
|
||||
<img src="/jobs/{{ job_id }}/files/rembg.png" alt="Freigestellt (rembg)" loading="lazy">
|
||||
<img class="lightboxable" src="/jobs/{{ job_id }}/files/rembg.png" alt="Freigestellt (rembg)" loading="lazy">
|
||||
<figcaption>
|
||||
Rembg ·
|
||||
<a href="/jobs/{{ job_id }}/files/rembg.png" download>Download</a>
|
||||
@@ -46,7 +46,7 @@
|
||||
<ul class="variant-grid">
|
||||
{% for v in manifest.variants %}
|
||||
<li class="variant-card">
|
||||
<img src="/jobs/{{ job_id }}/files/{{ v.file }}" alt="Variante {{ v.id }}" loading="lazy">
|
||||
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ v.file }}" alt="Variante {{ v.id }}" loading="lazy">
|
||||
<div class="variant-meta">
|
||||
<strong>{{ v.id }}</strong> <span class="tag">{{ v.source }}</span>
|
||||
<dl>
|
||||
@@ -54,8 +54,13 @@
|
||||
<dt>FG</dt><dd>{{ v.foreground_filter }} + {{ v.foreground_blend }}</dd>
|
||||
<dt>Opacity</dt><dd>{{ v.blend_opacity }}</dd>
|
||||
</dl>
|
||||
<div class="variant-actions">
|
||||
{% if has_rembg %}
|
||||
<a class="button" href="/jobs/{{ job_id }}/remix?from={{ v.id }}">Remix</a>
|
||||
{% endif %}
|
||||
<a href="/jobs/{{ job_id }}/files/{{ v.file }}" download>Download</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -68,7 +73,7 @@
|
||||
<ul class="intermediate-grid">
|
||||
{% for name in intermediates %}
|
||||
<li>
|
||||
<img src="/jobs/{{ job_id }}/files/intermediates/{{ name }}" alt="{{ name }}" loading="lazy">
|
||||
<img class="lightboxable" src="/jobs/{{ job_id }}/files/intermediates/{{ name }}" alt="{{ name }}" loading="lazy">
|
||||
<span>{{ name }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<p class="back-link"><a href="/jobs/{{ job_id }}">← Zurueck zum Job</a></p>
|
||||
|
||||
<h1>Remix · Job {{ job_id }}</h1>
|
||||
{% if from_variant %}
|
||||
<p class="job-status">Vorausgefüllt von {{ from_variant }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<p class="error-box">{{ error }}</p>
|
||||
@@ -13,35 +16,35 @@
|
||||
<label for="bg_filter">Background-Filter</label>
|
||||
<select name="bg_filter" id="bg_filter" required>
|
||||
{% for name in options.background_filters %}
|
||||
<option value="{{ name }}">{{ name }}</option>
|
||||
<option value="{{ name }}" {% if name == prefill.bg_filter %}selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="bg_blend">Background-Blend</label>
|
||||
<select name="bg_blend" id="bg_blend" required>
|
||||
{% for mode in options.blend_modes %}
|
||||
<option value="{{ mode }}">{{ mode }}</option>
|
||||
<option value="{{ mode }}" {% if mode == prefill.bg_blend %}selected{% endif %}>{{ mode }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="fg_filter">Foreground-Filter</label>
|
||||
<select name="fg_filter" id="fg_filter" required>
|
||||
{% for name in options.foreground_filters %}
|
||||
<option value="{{ name }}">{{ name }}</option>
|
||||
<option value="{{ name }}" {% if name == prefill.fg_filter %}selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="fg_blend">Foreground-Blend</label>
|
||||
<select name="fg_blend" id="fg_blend" required>
|
||||
{% for mode in options.blend_modes %}
|
||||
<option value="{{ mode }}">{{ mode }}</option>
|
||||
<option value="{{ mode }}" {% if mode == prefill.fg_blend %}selected{% endif %}>{{ mode }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="opacity">Blend-Opacity</label>
|
||||
<select name="opacity" id="opacity" required>
|
||||
{% for value in opacity_choices %}
|
||||
<option value="{{ value }}" {% if value == "30%" %}selected{% endif %}>{{ value }}</option>
|
||||
<option value="{{ value }}" {% if value == prefill.opacity %}selected{% endif %}>{{ value }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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/`.
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/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"
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/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())
|
||||
+17
-1
@@ -22,6 +22,9 @@ services:
|
||||
environment:
|
||||
SFTP_USER: ${SFTP_USER:-livef12}
|
||||
SFTP_PASSWORD: ${SFTP_PASSWORD:-changeme}
|
||||
# Minutes. Default SFTPGo is 15 — too aggressive for Android clients
|
||||
# that idle after a successful upload (shows up as EOF in the logs).
|
||||
SFTPGO_COMMON__IDLE_TIMEOUT: ${SFTPGO_IDLE_TIMEOUT:-120}
|
||||
volumes:
|
||||
- ${DATA_HOST_DIR:-./data}:/srv/sftpgo/data
|
||||
- sftpgo_state:/var/lib/sftpgo
|
||||
@@ -47,8 +50,14 @@ services:
|
||||
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}
|
||||
volumes:
|
||||
- ${DATA_HOST_DIR:-./data}:/data
|
||||
@@ -56,8 +65,9 @@ services:
|
||||
- rembg_cache:/app/.home
|
||||
# Sequential-only by design (no threads/async in worker.py); these
|
||||
# limits just make sure gmic/rembg can't starve the host too.
|
||||
# Keep headroom on the ~8GB host — birefnet-general needs more than we have.
|
||||
cpus: "1.0"
|
||||
mem_limit: 2g
|
||||
mem_limit: 4g
|
||||
networks:
|
||||
- internal
|
||||
|
||||
@@ -71,8 +81,14 @@ services:
|
||||
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}
|
||||
SITE_TITLE: live.f12.rocks
|
||||
volumes:
|
||||
|
||||
Reference in New Issue
Block a user