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:
Frank Schwenk
2026-07-16 23:35:06 +02:00
parent 8ef123393a
commit 83d4468b69
16 changed files with 594 additions and 50 deletions
+18 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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__":
+65
View File
@@ -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;
}
+45
View File
@@ -13,5 +13,50 @@
<main>
{% block content %}{% endblock %}
</main>
<div id="lightbox" class="lightbox" hidden>
<button type="button" class="lightbox-close" aria-label="Schliessen">&times;</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>
+1 -1
View File
@@ -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
View File
@@ -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 &middot;
<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 &middot;
<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 %}
+8 -5
View File
@@ -4,6 +4,9 @@
<p class="back-link"><a href="/jobs/{{ job_id }}">&larr; Zurueck zum Job</a></p>
<h1>Remix &middot; 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>