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:
+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,
|
||||
},
|
||||
)
|
||||
|
||||
+157
-26
@@ -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,15 +485,18 @@ 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))
|
||||
manifest: dict[str, Any] = {
|
||||
"job_id": job_id,
|
||||
"original_file": paths.original.name,
|
||||
"rembg_file": paths.rembg.name,
|
||||
"created_at": now_iso(),
|
||||
"variants": [],
|
||||
}
|
||||
|
||||
try:
|
||||
paths = preprocess_original(paths)
|
||||
|
||||
manifest: dict[str, Any] = {
|
||||
"job_id": job_id,
|
||||
"original_file": paths.original.name,
|
||||
"rembg_file": paths.rembg.name,
|
||||
"created_at": now_iso(),
|
||||
"variants": [],
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
|
||||
+37
-6
@@ -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")
|
||||
|
||||
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()))
|
||||
return 0
|
||||
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)
|
||||
|
||||
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 %}
|
||||
|
||||
+10
-5
@@ -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,7 +54,12 @@
|
||||
<dt>FG</dt><dd>{{ v.foreground_filter }} + {{ v.foreground_blend }}</dd>
|
||||
<dt>Opacity</dt><dd>{{ v.blend_opacity }}</dd>
|
||||
</dl>
|
||||
<a href="/jobs/{{ job_id }}/files/{{ v.file }}" download>Download</a>
|
||||
<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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user