#!/usr/bin/env python3
"""
produktionsstrasse.py — The Object: EIN Objekt von A bis App.
=============================================================
Deterministisch, LLM-frei (Generatoren ausgenommen). C33/C34-Zusammenbau.
Bauplan: /Volumes/Hot Disk/The Object/produktion/BAUPLAN.md (Watson-Review bestanden 2026-06-12)

Stationen:
  0 Mount-Wächter + Capture-Anlage
  1 Bilder B,C,D,E,F via jobs_mcp (NB2, bezahlt) — C+D parallel, E+F parallel
  2 Kompositions-Wächter nach jedem Bild (quer: transponiert)
  3 Hochkant-Staffel 9:16→9:21 (freqsplit → Anbau v3 → schwarzer Rand)
  4 Quer-Beschnitt 21:9 auf Seedance-Maß (aus T2-Messung)
  5 App-JPEGs (q93 + Kompressionsreihe für T3)
  6 Filme G–N via jobs_magnific (Seedance Draft, gratis, FCFS — Slot sobald Keyframes da)
  7 Audio v3 (loudnorm + 0,5s esin-Fades + Ticken bei H/J/M) + normalize + reverse
  8 Ablage (RUN_LEDGER für Ingest/App) + Objektarchiv-Spiegel
  9 LAUFKARTE.json (Zeitstempel pro Station)

Aufruf:
  python3 produktionsstrasse.py --input <A-Foto> --orientierung quer|hoch \
      --name <objektname> [--capture-id <id>] [--dry-run]
"""
from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import time
import uuid
import urllib.request
from pathlib import Path
from typing import Any

import numpy as np
from PIL import Image

# ── Pfade & Konstanten ───────────────────────────────────────────────────────

PROD_ROOT   = Path("/Volumes/Hot Disk/The Object/produktion")
CAPTURES    = PROD_ROOT / "captures"
DIAG        = PROD_ROOT / "diagnostics"
ASSETS      = PROD_ROOT / "assets"
PROMPTS_IMG = PROD_ROOT / "prompts" / "prompts_snapshot"
PROMPTS_VID = PROD_ROOT / "prompts" / "video_prompts_snapshot"
OBJEKTARCHIV = Path("/Volumes/Hot Disk/The Object/Objektarchiv")

KAMERAMOTOR     = Path("/Users/victorholland/Vibe Coding/dispatcher/kameramotor")
JOBS_MCP        = KAMERAMOTOR / "jobs_mcp"
OUTPUT_MOTOR    = KAMERAMOTOR / "output"          # C41: Abholstelle für alle Jobs
DONE_MCP        = KAMERAMOTOR / "done_mcp"        # C41: done-Signal MCP-Route
FAILED_MCP      = KAMERAMOTOR / "failed_mcp"
JOBS_MAGNIFIC   = KAMERAMOTOR / "jobs_magnific"
STATE_MAGNIFIC  = KAMERAMOTOR / "state_magnific"
DONE_MAGNIFIC   = KAMERAMOTOR / "done_magnific"
FAILED_MAGNIFIC = KAMERAMOTOR / "failed_magnific"

FORMATTESTS = Path("/Volumes/Hot Disk/The Object/Formattests_20260612")
sys.path.insert(0, str(FORMATTESTS))            # methodenfinale (eine Wahrheit)
sys.path.insert(0, str(FORMATTESTS / "anbau_v2"))

HERO_PROMPT_FILE = Path("/Users/victorholland/Vibe Coding/The Camera/Input/The Object/Prompt.txt")
TICK_SOURCE = Path("/Volumes/Hot Disk/The Object/Backup_vor_Neubau_20260612/TheObjectNativeApp/TheObjectApp/Resources/Sounds/theobject_orbit_motor.wav")
TICK_WAV    = ASSETS / "theobject_orbit_motor.wav"

FFMPEG  = "/opt/homebrew/bin/ffmpeg"
FFPROBE = "/opt/homebrew/bin/ffprobe"

POLL_S            = 5          # Tempo-Regel: kurze Polls
IMAGE_TIMEOUT_S   = 15 * 60
VIDEO_TIMEOUT_S   = 60 * 60
PIPELINE_VERSION  = "produktionsstrasse_v1_20260612"
AUDIO_VERSION     = 3          # 0,5s esin-Fades, Ticken vorgebacken

STRIPE_THRESHOLD  = 10.0       # Auftragsvorgabe Streifen-Metrik
MIN_STERILE_PX    = 200        # Kompositions-Wächter Mindest-Sterilzone
VERSCHAERFUNG_HOCH = " More empty background above and below, object smaller in frame."
VERSCHAERFUNG_QUER = " More empty background left and right, object smaller in frame."

# Anbau v3 Geometrie (aus anbau_v3_embed.py, Victor-validiert)
V3_CANVAS_W, V3_CANVAS_H = 3072, 5504
V3_TARGET_W, V3_TARGET_H = 3072, 7168
V3_F        = V3_CANVAS_H / V3_TARGET_H
V3_INNER_W  = round(V3_TARGET_W * V3_F)            # 2359
V3_INNER_X  = (V3_CANVAS_W - V3_INNER_W) // 2      # 356
V3_EMB_H    = round(5504 * V3_F)                   # 4227
V3_EMB_Y    = (V3_CANVAS_H - V3_EMB_H) // 2        # 638
V3_ADD      = (V3_TARGET_H - V3_CANVAS_H) // 2     # 832

NEGATIVE = (
    "melting, morphing, random anatomy, gore, blood, sexualized body, duplicate walls, "
    "impossible holes in background, generic treasure, diamond, ring, heart, tiny library, "
    "visible creature, face, person, animal, unrelated object, camera drift, zoom, pan, tilt, "
    "background change, harsh sound, music, melody, beat, voice, drone, silence"
)

IMAGE_PROMPT_FILES = {
    "C": "C_exploded_real.txt",
    "D": "D_exploded_secret.txt",
    "E": "E_neatify.txt",
    "F": "F_secret_detail.txt",
}
IMAGE_SPECS = [
    {"slot": "B", "label": "hero",            "source": "A", "filename": "B_hero.png"},
    {"slot": "C", "label": "exploded_real",   "source": "B", "filename": "C_exploded_real.png"},
    {"slot": "D", "label": "exploded_secret", "source": "B", "filename": "D_exploded_secret.png"},
    {"slot": "E", "label": "neatify",         "source": "C", "filename": "E_neatify.png"},
    {"slot": "F", "label": "secret_detail",   "source": "D", "filename": "F_secret_detail.png"},
]
VIDEO_SPECS = [
    {"slot": "G", "order": 1, "label": "original_to_hero",                 "start": "A", "end": "B", "filename": "01_G_original_to_hero.mp4",                 "tick": False},
    {"slot": "H", "order": 2, "label": "hero_360",                         "start": "B", "end": "B", "filename": "02_H_hero_360.mp4",                         "tick": True},
    {"slot": "I", "order": 3, "label": "hero_to_exploded_real",            "start": "B", "end": "C", "filename": "03_I_hero_to_exploded_real.mp4",            "tick": False},
    {"slot": "J", "order": 4, "label": "exploded_real_360",                "start": "C", "end": "C", "filename": "04_J_exploded_real_360.mp4",                "tick": True},
    {"slot": "K", "order": 5, "label": "exploded_real_to_neatify",         "start": "C", "end": "E", "filename": "05_K_exploded_real_to_neatify.mp4",         "tick": False},
    {"slot": "L", "order": 6, "label": "hero_to_exploded_secret",          "start": "B", "end": "D", "filename": "06_L_hero_to_exploded_secret.mp4",          "tick": False},
    {"slot": "M", "order": 7, "label": "exploded_secret_360",              "start": "D", "end": "D", "filename": "07_M_exploded_secret_360.mp4",              "tick": True},
    {"slot": "N", "order": 8, "label": "exploded_secret_to_secret_detail", "start": "D", "end": "F", "filename": "08_N_exploded_secret_to_secret_detail.mp4", "tick": False},
]
VIDEO_PROMPT_FILES = {
    "G": "G_original_to_hero.txt", "H": "H_hero_360.txt",
    "I": "I_hero_to_exploded_real.txt", "J": "J_exploded_real_360.txt",
    "K": "K_exploded_real_to_neatify.txt", "L": "L_hero_to_exploded_secret.txt",
    "M": "M_exploded_secret_360.txt", "N": "N_exploded_secret_to_secret_detail.txt",
}

# ── Atomic IO (vom Autopilot übernommen, gekürzt) ───────────────────────────

def write_json(path: Path, payload: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    suffix = f".{os.getpid()}.{uuid.uuid4().hex[:8]}.tmp"
    tmp = path.with_name((f".{path.name}")[:240] + suffix)
    tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
    os.replace(tmp, path)

def read_json(path: Path) -> dict | None:
    if not path.exists():
        return None
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return None

def safe_slug(value: str) -> str:
    value = value.lower()
    value = re.sub(r"[^a-z0-9_-]+", "_", value)
    return value.strip("_")[:48] or f"obj_{int(time.time())}"

def now_iso() -> str:
    return time.strftime("%Y-%m-%dT%H:%M:%S%z")

# ── LAUFKARTE ────────────────────────────────────────────────────────────────

class Laufkarte:
    def __init__(self, path: Path, meta: dict):
        self.path = path
        self.data = read_json(path) or {"meta": meta, "stationen": []}
        self.data["meta"].update(meta)
        self._lock = threading.Lock()
        self.flush()

    def stempel(self, station: str, status: str = "ok", dauer_s: float | None = None,
                flags: list | None = None, **metriken) -> None:
        with self._lock:
            self.data["stationen"].append({
                "station": station, "ts": now_iso(), "status": status,
                **({"dauer_s": round(dauer_s, 1)} if dauer_s is not None else {}),
                **({"flags": flags} if flags else {}),
                **({"metriken": metriken} if metriken else {}),
            })
            self.flush()

    def flush(self) -> None:
        self.data["meta"]["updated_at"] = now_iso()
        write_json(self.path, self.data)

    def finish(self, gesamt_s: float) -> None:
        with self._lock:
            self.data["gesamt_dauer_s"] = round(gesamt_s, 1)
            self.data["gesamt_dauer_min"] = round(gesamt_s / 60, 1)
            self.flush()

class Station:
    """Context-Manager: stempelt Beginn/Ende + Dauer."""
    def __init__(self, karte: Laufkarte, name: str, **extra):
        self.karte, self.name, self.extra = karte, name, extra
    def __enter__(self):
        self.t0 = time.time()
        print(f"▶ {self.name} …", flush=True)
        return self
    def __exit__(self, exc_type, exc, tb):
        dauer = time.time() - self.t0
        if exc:
            self.karte.stempel(self.name, status="fehler", dauer_s=dauer, fehler=str(exc))
            print(f"✗ {self.name} FEHLER nach {dauer:.0f}s: {exc}", flush=True)
        else:
            self.karte.stempel(self.name, status="ok", dauer_s=dauer, **self.extra)
            print(f"✓ {self.name} ({dauer:.0f}s)", flush=True)
        return False

# ── Station 0: Mount-Wächter ────────────────────────────────────────────────

def mount_check() -> None:
    if not PROD_ROOT.exists():
        sys.exit("ABBRUCH: Hot Disk nicht gemountet — /Volumes/Hot Disk/The Object/produktion fehlt.")
    probe = PROD_ROOT / f".mount_probe_{os.getpid()}"
    try:
        probe.write_text("ok")
        probe.unlink()
    except OSError as e:
        sys.exit(f"ABBRUCH: Produktions-ROOT nicht beschreibbar: {e}")

# ── Station 1: Bilder via jobs_mcp ──────────────────────────────────────────

def hero_prompt_text() -> str:
    return HERO_PROMPT_FILE.read_text(encoding="utf-8").strip()

PROMPT_API_LIMIT = 9900   # Magnific-MCP: "prompt must not be greater than 10000 characters"
PROMPT_KUERZUNGEN: dict[str, int] = {}   # slot → verlorene Zeichen (FLAG für Bericht)

def prompt_for_api(text: str, slot: str, limit: int = PROMPT_API_LIMIT) -> str:
    """Deterministische Kürzung an Absatz-/Zeilengrenze. KEIN LLM.
    FLAG-Pflicht: Kürzungen werden registriert — die kanonischen Prompts
    müssen redaktionell auf <= limit gebracht werden (Victor/Watson, C36-Folge)."""
    text = text.strip()
    if len(text) <= limit:
        return text
    cut = text.rfind("\n\n", 0, limit)
    if cut < int(limit * 0.6):
        cut = text.rfind("\n", 0, limit)
    if cut < int(limit * 0.6):
        cut = text.rfind(". ", 0, limit) + 1
    if cut <= 0:
        cut = limit
    PROMPT_KUERZUNGEN[slot] = len(text) - cut
    return text[:cut].strip()

def slot_prompt_text(slot: str) -> str:
    """Liefert den RAW-Prompt-Text für den Slot. KEINE Kürzung hier.
    Kürzung erfolgt ausschließlich in queue_image_job über prompt_for_api —
    das ist der einzige kanonische Ausgabepunkt (Capability statt Regel)."""
    if slot == "B":
        return hero_prompt_text()
    return (PROMPTS_IMG / IMAGE_PROMPT_FILES[slot]).read_text(encoding="utf-8").strip()

def upload_safe_image(src: Path, workdir: Path) -> Path:
    """Magnific-Upload-Limit 25 MB: große PNGs als JPEG q92 (Anbau-v3-Lehre)."""
    if src.stat().st_size <= 20 * 1024 * 1024:
        return src
    out = workdir / (src.stem + "_upload_q92.jpg")
    if not out.exists():
        Image.open(src).convert("RGB").save(out, "JPEG", quality=92, optimize=True)
    return out

def queue_image_job(slug: str, slot: str, label: str, source_img: Path, ratio: str,
                    out_dir: Path, prompt_suffix: str = "", attempt: int = 1) -> str:
    jid = f"{int(time.time()*1000)}_objekt_{slug}_{slot}{'' if attempt == 1 else f'_r{attempt}'}"
    suffix = ("\n" + prompt_suffix.strip()) if prompt_suffix else ""
    basis = prompt_for_api(slot_prompt_text(slot), slot,
                           limit=PROMPT_API_LIMIT - len(suffix))
    prompt = basis + suffix
    # HARD GUARD — technisch unmöglich >PROMPT_API_LIMIT in ein Job-JSON zu schreiben.
    # Zweite Sicherheitslinie falls suffix-Arithmetik oder künftige Refactorings abweichen.
    if len(prompt) > PROMPT_API_LIMIT:
        prompt = prompt_for_api(prompt, slot, limit=PROMPT_API_LIMIT)
    job = {
        "id": jid,
        "image": str(source_img),
        "prompt": prompt,
        "ratio": ratio, "aspectRatio": ratio,
        "resolution": "4k", "num_images": 1,
        "mode": "imagen-nano-banana-2-flash", "generator": "nano",
        "provider": "magnific", "provider_route": "magnific_mcp",
        "thinking_level": "high",
        # C41: output_dir VERBOTEN — Ergebnis liegt in OUTPUT_MOTOR / jid /
        "stem": f"The Object {slug} {slot} {label}",
        "filter": f"image_{slot}_{label}",
        "source": "THE OBJECT produktionsstrasse v1",
        "submitted_at": int(time.time() * 1000),
    }
    f = JOBS_MCP / f"{jid}.json"
    tmp = f.with_suffix(".tmp")
    tmp.write_text(json.dumps(job, indent=2, ensure_ascii=False))
    tmp.replace(f)
    return jid

def download_url(url: str, dest: Path) -> Path:
    dest.parent.mkdir(parents=True, exist_ok=True)
    tmp = dest.with_suffix(dest.suffix + ".part")
    req = urllib.request.Request(url, headers={"User-Agent": "produktionsstrasse/1.0"})
    with urllib.request.urlopen(req, timeout=120) as r, open(tmp, "wb") as fh:
        shutil.copyfileobj(r, fh)
    tmp.replace(dest)
    return dest

def wait_image_result(jid: str, _out_dir_unused: Path, dest_png: Path,
                      timeout_s: int = IMAGE_TIMEOUT_S) -> Path:
    """C41-Vertrag: Pollt done_mcp/<jid>.json + failed_mcp/<jid>.json.
    Ergebnis kommt aus OUTPUT_MOTOR/<jid>/ (wegnehmen = abgeholt via shutil.move).
    _out_dir_unused bleibt als Argument damit Aufrufer nicht angepasst werden müssen."""
    t0 = time.time()
    output_dir = OUTPUT_MOTOR / jid
    while True:
        # Fehlschlag-Pfad (schema_violation oder Motor-Fehler)
        failed_f = FAILED_MCP / f"{jid}.json"
        if failed_f.exists():
            err = read_json(FAILED_MCP / f"{jid}_error.json") or read_json(failed_f) or {}
            raise RuntimeError(f"MCP-Job {jid} failed: {err.get('error') or err.get('reason') or 'unbekannt'}")
        # done-Signal (empfohlene Polling-Methode laut Vertrag)
        if (DONE_MCP / f"{jid}.json").exists():
            cands = sorted(output_dir.glob("*.png")) + sorted(output_dir.glob("*.jpg")) + sorted(output_dir.glob("*.jpeg"))
            cands = [c for c in cands if c.exists() and c.stat().st_size > 50_000]
            if not cands:
                raise RuntimeError(f"MCP-Job {jid}: done-Signal aber keine Bilddatei in {output_dir}")
            src = max(cands, key=lambda p: p.stat().st_size)
            dest_png.parent.mkdir(parents=True, exist_ok=True)
            # Wegnehmen = abgeholt (Vertrag: move, nicht copy)
            if src.suffix.lower() == ".png":
                shutil.move(str(src), dest_png)
            else:
                Image.open(src).convert("RGB").save(dest_png, "PNG")
                src.unlink(missing_ok=True)
            return dest_png
        # Fallback: output-Ordner ohne done-Signal (z.B. wenn Motor done-Datei nicht schreibt)
        if output_dir.exists():
            cands = sorted(output_dir.glob("*.png")) + sorted(output_dir.glob("*.jpg")) + sorted(output_dir.glob("*.jpeg"))
            cands = [c for c in cands if c.stat().st_size > 50_000]
            if cands:
                src = max(cands, key=lambda p: p.stat().st_size)
                dest_png.parent.mkdir(parents=True, exist_ok=True)
                if src.suffix.lower() == ".png":
                    shutil.move(str(src), dest_png)
                else:
                    Image.open(src).convert("RGB").save(dest_png, "PNG")
                    src.unlink(missing_ok=True)
                return dest_png
        if time.time() - t0 > timeout_s:
            raise TimeoutError(f"MCP-Job {jid}: Timeout nach {timeout_s}s")
        time.sleep(POLL_S)

# ── Station 2: Kompositions-Wächter ─────────────────────────────────────────

def komposition_pruefen(png: Path, orientierung: str) -> dict:
    """get_zones aus methodenfinale; quer transponiert. OK wenn beide Sterilzonen ≥ MIN_STERILE_PX."""
    from methodenfinale import get_zones
    arr = np.array(Image.open(png).convert("RGB"), dtype=np.float32)
    if orientierung == "quer":
        arr = np.transpose(arr, (1, 0, 2))
    zones = get_zones(arr)
    a, b = zones["rand_top_n"], zones["rand_bot_n"]
    return {
        "steril_a": int(a), "steril_b": int(b),
        "randstaendig": bool(zones["detektor_top"] or zones["detektor_bot"]),
        "ok": (a >= MIN_STERILE_PX and b >= MIN_STERILE_PX),
        "threshold_used": float(zones["threshold"]),
    }

# ── Station 3: Hochkant-Staffel ─────────────────────────────────────────────

def stufe1_freqsplit(src_png: Path, dest_png: Path) -> dict:
    from methodenfinale import freqsplit, get_zones, stripe_metric_result, TARGET_H
    arr = np.array(Image.open(src_png).convert("RGB"), dtype=np.float32)
    zones = get_zones(arr)
    zones["add"] = (TARGET_H - arr.shape[0]) // 2
    if zones["add"] <= 0:
        raise RuntimeError(f"freqsplit: Quelle {arr.shape[0]}px bereits >= {TARGET_H}")
    result = freqsplit(arr, zones)
    metric = float(stripe_metric_result(result, zones))
    randstaendig = bool(zones["detektor_top"] or zones["detektor_bot"])
    Image.fromarray(np.clip(np.round(result), 0, 255).astype(np.uint8)).save(dest_png, "PNG")
    return {"metrik": round(metric, 3), "ok": metric <= STRIPE_THRESHOLD,
            "randstaendig": randstaendig}

def stufe2_anbau_v3(src_png: Path, dest_png: Path, slug: str, slot: str,
                    workdir: Path, attempt: int = 1) -> dict:
    """Embed-und-Füllen: Original verkleinert auf 9:16-Leinwand, NB2 füllt Ränder
    (via jobs_mcp), Composite kopiert Originalzone pixelgenau zurück.
    Ausrichtungs-Wächter PFLICHT (Template-Matching, NB2 verschiebt ~30px)."""
    import cv2
    from anbau_v2_pipeline import naht_metrik, seam_step_metric, NAHT_ROT

    orig_img = Image.open(src_png).convert("RGB")
    if orig_img.size != (V3_CANVAS_W, V3_CANVAS_H):
        raise RuntimeError(f"Anbau v3 erwartet 3072x5504, bekam {orig_img.size}")
    arr = np.array(orig_img, dtype=np.float32)

    # 1. Embed-Leinwand (Logik aus anbau_v3_embed.py)
    top_color = arr[:24].mean(axis=(0, 1)); bot_color = arr[-24:].mean(axis=(0, 1))
    left_color = arr[:, :24].mean(axis=(0, 1)); right_color = arr[:, -24:].mean(axis=(0, 1))
    grain_sigma = float(np.std(arr[:24].reshape(24, -1), axis=1).mean()) * 0.5
    t = np.linspace(0, 1, V3_CANVAS_H, dtype=np.float32)[:, None, None]
    canvas = (1 - t) * top_color[None, None, :] + t * bot_color[None, None, :]
    canvas = np.broadcast_to(canvas, (V3_CANVAS_H, V3_CANVAS_W, 3)).copy()
    canvas[:, :V3_INNER_X] = 0.5 * canvas[:, :V3_INNER_X] + 0.5 * left_color[None, None, :]
    canvas[:, V3_INNER_X + V3_INNER_W:] = (0.5 * canvas[:, V3_INNER_X + V3_INNER_W:]
                                           + 0.5 * right_color[None, None, :])
    rng = np.random.default_rng(42)
    canvas += rng.normal(0, max(grain_sigma, 0.5), canvas.shape).astype(np.float32)
    emb = orig_img.resize((V3_INNER_W, V3_EMB_H), Image.LANCZOS)
    canvas_img = Image.fromarray(np.clip(np.round(canvas), 0, 255).astype(np.uint8))
    canvas_img.paste(emb, (V3_INNER_X, V3_EMB_Y))
    embed_png = workdir / f"v3_embed_{slot}.png"
    canvas_img.save(embed_png, "PNG")
    embed_jpg = workdir / f"v3_embed_{slot}_q92.jpg"   # 25-MB-Limit: immer JPEG q92
    canvas_img.save(embed_jpg, "JPEG", quality=92, optimize=True)

    # 2. NB2-Füll-Job über die Brücke
    fill_prompt = (
        "Use the supplied image as the only truth source. The central area contains a finished "
        "studio object photograph that must remain pixel-identical. Seamlessly extend ONLY the "
        "empty background zones above, below, left and right of the central photograph so the "
        "entire frame becomes one continuous studio backdrop with the same color, gradient, light "
        "falloff and grain. Do not move, rescale, redraw, restyle or touch the central photograph. "
        "No new objects, no text, no logos, no frames, no borders."
    )
    out_dir = workdir / f"v3_fill_{slot}_r{attempt}"
    jid = f"{int(time.time()*1000)}_objekt_{slug}_{slot}_v3fill_r{attempt}"
    job = {
        "id": jid, "image": str(embed_jpg), "prompt": fill_prompt,
        "ratio": "9:16", "aspectRatio": "9:16", "resolution": "4k", "num_images": 1,
        "mode": "imagen-nano-banana-2-flash", "generator": "nano",
        "provider": "magnific", "provider_route": "magnific_mcp", "thinking_level": "high",
        # C41: output_dir VERBOTEN — Ergebnis in OUTPUT_MOTOR / jid /
        "stem": f"The Object {slug} {slot} v3 fill",
        "filter": f"anbau_v3_fill_{slot}", "source": "THE OBJECT produktionsstrasse v1 anbau_v3",
        "submitted_at": int(time.time() * 1000),
    }
    f = JOBS_MCP / f"{jid}.json"
    tmp = f.with_suffix(".tmp"); tmp.write_text(json.dumps(job, indent=2)); tmp.replace(f)
    result_png = workdir / f"v3_result_{slot}_r{attempt}.png"
    wait_image_result(jid, out_dir, result_png)  # out_dir ignoriert (C41)

    # 3. Composite (Logik aus anbau_v3_composite.py)
    result = np.array(Image.open(result_png).convert("RGB"), dtype=np.float32)
    if result.shape[:2] != (V3_CANVAS_H, V3_CANVAS_W):
        result = np.array(Image.open(result_png).convert("RGB")
                          .resize((V3_CANVAS_W, V3_CANVAS_H), Image.LANCZOS), dtype=np.float32)
    orig_small = np.array(orig_img.resize((V3_INNER_W, V3_EMB_H), Image.LANCZOS), dtype=np.float32)

    # Ausrichtungs-Wächter: Template-Matching ±96px
    res_gray = cv2.cvtColor(result.astype(np.uint8), cv2.COLOR_RGB2GRAY)
    tm_gray = cv2.cvtColor(orig_small.astype(np.uint8), cv2.COLOR_RGB2GRAY)
    th, tw = tm_gray.shape
    cy0, cy1, cx0, cx1 = th // 4, th * 3 // 4, tw // 4, tw * 3 // 4
    tmpl = tm_gray[cy0:cy1, cx0:cx1]
    search = 96
    sy0 = max(0, V3_EMB_Y + cy0 - search); sy1 = min(res_gray.shape[0], V3_EMB_Y + cy1 + search)
    sx0 = max(0, V3_INNER_X + cx0 - search); sx1 = min(res_gray.shape[1], V3_INNER_X + cx1 + search)
    r = cv2.matchTemplate(res_gray[sy0:sy1, sx0:sx1], tmpl, cv2.TM_CCOEFF_NORMED)
    _, tm_score, _, loc = cv2.minMaxLoc(r)
    fx = sx0 + loc[0] - cx0; fy = sy0 + loc[1] - cy0
    dx, dy = fx - V3_INNER_X, fy - V3_EMB_Y
    waechter_ok = tm_score >= 0.60 and abs(dx) <= 90 and abs(dy) <= 90

    # 9:21-Streifen ausschneiden (Delta-korrigiert), hochskalieren
    cut_x0 = int(np.clip(V3_INNER_X + dx, 0, V3_CANVAS_W - V3_INNER_W))
    strip = result[:, cut_x0:cut_x0 + V3_INNER_W]
    if dy != 0:
        strip = np.roll(strip, -dy, axis=0)
    big = np.array(Image.fromarray(np.clip(np.round(strip), 0, 255).astype(np.uint8))
                   .resize((V3_TARGET_W, V3_TARGET_H), Image.LANCZOS), dtype=np.float32)

    top_seam = V3_ADD; bot_seam = V3_ADD + V3_CANVAS_H
    # Farbangleich (spaltenweiser Offset, gefadet — aus anbau_v3_composite)
    band, k, fade_n = 48, 121, 600
    kernel = np.ones(k) / k
    def smooth_cols(diff):
        return np.stack([np.convolve(diff[:, c], kernel, mode="same") for c in range(3)], axis=1)
    diff_top = smooth_cols(arr[:band].mean(axis=0) - big[top_seam - band:top_seam].mean(axis=0))
    fade = np.full(top_seam, 0.35, dtype=np.float32)
    f0 = min(fade_n, top_seam); fade[top_seam - f0:] = np.linspace(0.35, 1.0, f0, dtype=np.float32)
    big[:top_seam] += fade[:, None, None] * diff_top[None, :, :]
    n_bot = big.shape[0] - bot_seam
    diff_bot = smooth_cols(arr[-band:].mean(axis=0) - big[bot_seam:bot_seam + band].mean(axis=0))
    fade = np.full(n_bot, 0.35, dtype=np.float32)
    f0 = min(fade_n, n_bot); fade[:f0] = np.linspace(1.0, 0.35, f0, dtype=np.float32)
    big[bot_seam:] += fade[:, None, None] * diff_bot[None, :, :]

    # Original pixelgenau zurück
    big[top_seam:bot_seam] = arr
    naht = float(naht_metrik(big, V3_ADD))
    seam = float(seam_step_metric(big, [top_seam, bot_seam]))
    ok = waechter_ok and naht <= NAHT_ROT and seam <= 25.0
    Image.fromarray(np.clip(np.round(big), 0, 255).astype(np.uint8)).save(dest_png, "PNG")
    return {"ok": ok, "tm_score": round(float(tm_score), 4), "delta": [int(dx), int(dy)],
            "naht": round(naht, 3), "seam": round(seam, 3), "waechter_ok": waechter_ok}

def stufe3_schwarzer_rand(src_png: Path, dest_png: Path) -> dict:
    """Victor wörtlich: schwarzer Rand, HARTE Kante an der 9:16-Grenze, null Feder."""
    img = Image.open(src_png).convert("RGB")
    w, h = img.size
    add = (V3_TARGET_H - h) // 2
    canvas = Image.new("RGB", (w, h + 2 * add), (0, 0, 0))
    canvas.paste(img, (0, add))
    if canvas.size != (V3_TARGET_W, V3_TARGET_H):
        canvas = canvas.resize((V3_TARGET_W, V3_TARGET_H), Image.LANCZOS)
    canvas.save(dest_png, "PNG")
    return {"ok": True, "add_px": add}

def hochkant_staffel(src_png: Path, dest_png: Path, slug: str, slot: str,
                     workdir: Path, karte: Laufkarte) -> dict:
    """Dreistufige Staffel (Victor-validiert 2026-06-12). Nie ein Stau."""
    s1 = stufe1_freqsplit(src_png, dest_png)
    karte.stempel(f"staffel_{slot}_stufe1_freqsplit", status="ok" if s1["ok"] else "rot", **s1)
    if s1["ok"]:
        return {"stufe": 1, **s1}
    if not s1["randstaendig"]:
        try:
            s2 = stufe2_anbau_v3(src_png, dest_png, slug, slot, workdir, attempt=1)
            karte.stempel(f"staffel_{slot}_stufe2_anbau_v3", status="ok" if s2["ok"] else "rot", **s2)
            if s2["ok"]:
                return {"stufe": 2, **s2}
            s2b = stufe2_anbau_v3(src_png, dest_png, slug, slot, workdir, attempt=2)
            karte.stempel(f"staffel_{slot}_stufe2_retry", status="ok" if s2b["ok"] else "rot", **s2b)
            if s2b["ok"]:
                return {"stufe": 2, "retry": True, **s2b}
        except Exception as e:
            karte.stempel(f"staffel_{slot}_stufe2_fehler", status="fehler", fehler=str(e))
    s3 = stufe3_schwarzer_rand(src_png, dest_png)
    karte.stempel(f"staffel_{slot}_stufe3_schwarzer_rand", status="ok", **s3)
    return {"stufe": 3, **s3}

# ── Station 4: Quer-Beschnitt auf Seedance-Maß ──────────────────────────────

SEEDANCE_MASSE_JSON = DIAG / "seedance_masse.json"

def video_dimensions(path: Path) -> tuple[int, int]:
    out = subprocess.run(
        [FFPROBE, "-v", "error", "-select_streams", "v:0",
         "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", str(path)],
        text=True, stdout=subprocess.PIPE, check=True).stdout.strip()
    w, h = out.split("x")
    return int(w), int(h)

def seedance_target_size(orientierung: str, wait: bool = True) -> tuple[int, int]:
    """Liest das T2-Messergebnis (Clip in diagnostics/t2_measure/). Cache in seedance_masse.json."""
    cache = read_json(SEEDANCE_MASSE_JSON) or {}
    key = "21:9" if orientierung == "quer" else "9:21"
    if key in cache:
        return tuple(cache[key])
    clip_dir = DIAG / "t2_measure" / ("clip_21zu9" if orientierung == "quer" else "clip_9zu21")
    t0 = time.time()
    while True:
        clips = sorted(clip_dir.glob("*.mp4")) if clip_dir.exists() else []
        if clips:
            w, h = video_dimensions(clips[0])
            cache[key] = [w, h]
            write_json(SEEDANCE_MASSE_JSON, cache)
            return w, h
        if not wait or time.time() - t0 > 6 * 3600:
            raise RuntimeError(f"T2-Maß für {key} nicht verfügbar (kein Clip in {clip_dir}) — FLAG an Watson, kein Raten.")
        time.sleep(POLL_S)

def quer_beschnitt(src_png: Path, dest_png: Path) -> dict:
    """NB2-21:9 (6336x2688, 2.357) exakt auf Seedance-21:9-Maß beschneiden (zentriert)."""
    tw, th = seedance_target_size("quer")
    img = Image.open(src_png).convert("RGB")
    w, h = img.size
    target_ratio = tw / th
    if w / h > target_ratio:                 # zu breit → links/rechts beschneiden
        new_w = round(h * target_ratio)
        x0 = (w - new_w) // 2
        img = img.crop((x0, 0, x0 + new_w, h))
    else:                                     # zu hoch → oben/unten beschneiden
        new_h = round(w / target_ratio)
        y0 = (h - new_h) // 2
        img = img.crop((0, y0, w, y0 + new_h))
    if img.size != (tw, th) and (img.size[0] < tw or abs(img.size[0]/img.size[1] - target_ratio) < 0.01):
        pass  # Auflösung behalten — Ratio stimmt; App skaliert. Nur Ratio-Crop, kein Downscale (Schärfe!).
    img.save(dest_png, "PNG")
    return {"crop_to_ratio": f"{tw}x{th}", "result_size": list(img.size)}

# ── Station 5: App-JPEGs ────────────────────────────────────────────────────

def app_jpeg(src_png: Path, dest_jpg: Path, quality: int = 93) -> dict:
    img = Image.open(src_png).convert("RGB")
    dest_jpg.parent.mkdir(parents=True, exist_ok=True)
    img.save(dest_jpg, "JPEG", quality=quality, optimize=True, subsampling=0)
    return {"quality": quality, "bytes": dest_jpg.stat().st_size}

def kompressionsreihe(src_png: Path, out_dir: Path, qualitaeten=(80, 88, 93, 97)) -> dict:
    """Für Victors T3-Abnahme: 4 Qualitäten + Größentabelle."""
    out_dir.mkdir(parents=True, exist_ok=True)
    img = Image.open(src_png).convert("RGB")
    tabelle = {"quelle": str(src_png), "px": list(img.size),
               "png_bytes": src_png.stat().st_size, "varianten": []}
    for q in qualitaeten:
        out = out_dir / f"{src_png.stem}_q{q}.jpg"
        img.save(out, "JPEG", quality=q, optimize=True, subsampling=0)
        tabelle["varianten"].append({"q": q, "bytes": out.stat().st_size,
                                     "mb": round(out.stat().st_size / 1048576, 2), "datei": out.name})
    write_json(out_dir / "groessentabelle.json", tabelle)
    return tabelle

# ── Station 6: Filme via jobs_magnific ──────────────────────────────────────

def queue_video_job(slug: str, spec: dict, start_img: Path, end_img: Path,
                    ratio: str, out_dir: Path | None = None) -> str:
    # C41: out_dir wird ignoriert — Ergebnis kommt aus OUTPUT_MOTOR / jid /
    jid = f"{slug}_{spec['slot'].lower()}_{spec['label']}_seedance15pro_draft_s"
    jid = safe_slug(jid)[:120]
    job = {
        "id": jid, "type": "video", "video": True,
        "variant": "S", "badge": "S",
        "provider_name": "Seedance 1.5 Pro", "provider": "magnific",
        "provider_route": "magnific_cdp_unlimited",
        "api": "bytedance", "model": "seedance", "mode": "pro-1.5",
        "slug": "bytedance-seedance-pro-1.5", "model_mode": "pro-1.5",
        "resolution": "Draft", "duration": 5,
        "ratio": ratio, "aspectRatio": ratio, "imageCount": 1,
        "enable_audio": True, "withSoundEffects": True,
        "requires_end_image": True, "disable_end_keyframe": False,
        "prompt_file": str(PROMPTS_VID / VIDEO_PROMPT_FILES[spec["slot"]]),
        "start_image": str(start_img), "end_image": str(end_img),
        "negative_prompt": NEGATIVE,
        # C41: output_dir VERBOTEN — Ergebnis in OUTPUT_MOTOR / jid /
        "output_name_prefix": f"{slug}_{spec['order']:02d}_{spec['slot']}_{spec['label']}",
        "filter": f"{slug}_{spec['order']:02d}_{spec['slot']}_{spec['label']}_seedance15pro_draft",
        "source": "THE OBJECT produktionsstrasse v1 (Seedance Draft unlimited)",
        "video_provider_version": "magnific_seedance15pro_only_20260611",
        "cost_note": "Seedance 1.5 Pro Draft via Magnific CDP Unlimited. Simulation guard applies. 0 credits.",
        "object_slug": slug, "object_label": "Produktionsstrasse Objekt",
        "order_index": spec["order"], "slot_letter": spec["slot"],
        "start_slot": spec["start"], "end_slot": spec["end"],
    }
    f = JOBS_MAGNIFIC / f"{jid}.json"
    if not f.exists():
        tmp = f.with_suffix(".tmp")
        tmp.write_text(json.dumps(job, indent=2, ensure_ascii=False))
        tmp.replace(f)
    return jid

def video_state(jid: str) -> dict | None:
    """C41: failed_magnific → {"failed": True}; done_magnific + output vorhanden → {"done": True, "jid": jid}."""
    if (FAILED_MAGNIFIC / f"{jid}.json").exists():
        return {"failed": True}
    if (DONE_MAGNIFIC / f"{jid}.json").exists():
        # done-Signal vorhanden — optional State-JSON für Metadaten laden
        st = read_json(STATE_MAGNIFIC / f"{jid}.json") or {}
        st["done"] = True; st["jid"] = jid
        return st
    return None

def final_video_from_state(state: dict) -> Path | None:
    """C41: Ergebnis liegt in OUTPUT_MOTOR/<jid>/ — wegnehmen = abgeholt."""
    jid = state.get("jid") or ""
    if jid:
        output_dir = OUTPUT_MOTOR / jid
        if output_dir.exists():
            mp4s = sorted(output_dir.glob("*.mp4"))
            if mp4s:
                return mp4s[0]   # Caller macht shutil.move (Abholvertrag)
    # Fallback: alte State-Felder (Rückwärtskompatibilität falls Motor State-JSON schreibt)
    dl = state.get("downloaded") or {}
    if isinstance(dl, dict):
        p = Path(dl.get("outPath") or "")
        if p.exists() and p.suffix.lower() == ".mp4":
            return p
    for item in state.get("outputFiles") or []:
        p = Path(item)
        if p.exists() and p.suffix.lower() == ".mp4":
            return p
    return None

# ── Station 7: Audio v3 + Normalize + Reverse ───────────────────────────────

def ffprobe_duration(path: Path) -> float:
    out = subprocess.run([FFPROBE, "-v", "error", "-show_entries", "format=duration",
                          "-of", "default=nk=1:nw=1", str(path)],
                         text=True, stdout=subprocess.PIPE, check=True).stdout.strip()
    return float(out)

def has_audio(path: Path) -> bool:
    return subprocess.run([FFPROBE, "-v", "error", "-select_streams", "a:0",
                           "-show_entries", "stream=index", "-of", "csv=p=0", str(path)],
                          text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
                          check=False).stdout.strip() != ""

VIDEO_ENC = ["-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
             "-g", "1", "-keyint_min", "1", "-sc_threshold", "0",
             "-pix_fmt", "yuv420p", "-movflags", "+faststart"]

def audio_chain_v3(dur: float) -> str:
    """A2/A3: loudnorm + 0,5s stark geeaste Fades (esin = sinusförmig, super soft)."""
    fade_out_start = max(dur - 0.5, 0.02)
    return (f"loudnorm=I=-20:TP=-2:LRA=11,"
            f"afade=t=in:st=0:d=0.5:curve=esin,"
            f"afade=t=out:st={fade_out_start:.3f}:d=0.5:curve=esin")

def app_finish_video(src: Path, dest: Path, tick: bool) -> dict:
    """Ein Pass: Video normalisieren (fps24, all-keyframes) + Audio v3.
    tick=True (H/J/M): theobject_orbit_motor.wav ÜBER den Originalton — vorgebacken (A4)."""
    dur = ffprobe_duration(src)
    audio_present = has_audio(src)
    cmd = [FFMPEG, "-y", "-i", str(src)]
    if tick and audio_present and TICK_WAV.exists():
        cmd += ["-stream_loop", "-1", "-i", str(TICK_WAV)]
        fc = (f"[1:a]volume=-12dB[tk];"
              f"[0:a][tk]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[mx];"
              f"[mx]{audio_chain_v3(dur)}[aout]")
        cmd += ["-filter_complex", fc, "-map", "0:v:0", "-map", "[aout]"]
        tick_mixed = True
    elif audio_present:
        cmd += ["-map", "0:v:0", "-map", "0:a:0", "-af", audio_chain_v3(dur)]
        tick_mixed = False
    else:
        cmd += ["-map", "0:v:0", "-an"]
        tick_mixed = False
    cmd += ["-vf", "fps=24,scale=trunc(iw/2)*2:trunc(ih/2)*2"]
    cmd += VIDEO_ENC + ["-c:a", "aac", "-b:a", "160k", str(dest) + ".tmp.mp4"]
    subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
    os.replace(str(dest) + ".tmp.mp4", dest)
    return {"audio_version": AUDIO_VERSION, "tick_mixed": tick_mixed,
            "duration": round(dur, 2), "audio": audio_present}

def make_reverse(src: Path) -> Path:
    rev = src.with_name(src.stem + "__reverse" + src.suffix)
    if rev.exists() and rev.stat().st_mtime >= src.stat().st_mtime:
        return rev
    cmd = [FFMPEG, "-y", "-i", str(src), "-map", "0:v:0"]
    if has_audio(src):
        cmd += ["-map", "0:a:0", "-vf", "reverse", "-af", "areverse"]
    else:
        cmd += ["-vf", "reverse", "-an"]
    cmd += VIDEO_ENC + ["-c:a", "aac", "-b:a", "160k", str(rev) + ".tmp.mp4"]
    subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
    os.replace(str(rev) + ".tmp.mp4", rev)
    return rev

# Freeze-Trim (vom Autopilot übernommen, kompakt)
def freezes(path: Path) -> list[tuple[float, float]]:
    proc = subprocess.run([FFMPEG, "-hide_banner", "-nostats", "-i", str(path),
                           "-vf", "freezedetect=n=-45dB:d=0.18", "-an", "-f", "null", "-"],
                          text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
    found, start = [], None
    for line in proc.stderr.splitlines():
        if "freeze_start:" in line:
            start = float(line.split("freeze_start:")[-1].strip())
        elif "freeze_end:" in line and start is not None:
            m = re.search(r"freeze_end:\s*([0-9.]+)", line)
            if m:
                found.append((start, float(m.group(1))))
            start = None
    return found

def trim_freezes(src: Path, dest: Path) -> dict:
    dur = ffprobe_duration(src)
    start_trim, end_trim = 0.0, dur
    for s, e in freezes(src):
        if s <= 0.18 and e > start_trim:
            start_trim = e
        if dur - e <= 0.25 and s < end_trim:
            end_trim = s
        elif dur - s <= 0.85 and s < end_trim:
            end_trim = s
    if end_trim - start_trim < 2.4 or (start_trim <= 0.001 and dur - end_trim <= 0.001):
        shutil.copy2(src, dest)
        return {"trimmed": False, "duration": round(dur, 2)}
    cmd = [FFMPEG, "-y", "-ss", f"{start_trim:.6f}", "-to", f"{end_trim:.6f}",
           "-i", str(src), "-map", "0:v:0", "-map", "0:a?",
           "-vf", "fps=24,scale=trunc(iw/2)*2:trunc(ih/2)*2"]
    cmd += VIDEO_ENC + ["-c:a", "aac", "-b:a", "160k", str(dest) + ".tmp.mp4"]
    subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
    os.replace(str(dest) + ".tmp.mp4", dest)
    return {"trimmed": True, "cut_start": round(start_trim, 3),
            "cut_end": round(dur - end_trim, 3)}

# ── Hauptdurchlauf ───────────────────────────────────────────────────────────

def run(input_foto: Path, orientierung: str, name: str,
        capture_id: str | None, dry_run: bool) -> None:
    t_start = time.time()
    mount_check()
    if not TICK_WAV.exists():
        ASSETS.mkdir(parents=True, exist_ok=True)
        if TICK_SOURCE.exists():
            shutil.copy2(TICK_SOURCE, TICK_WAV)
        else:
            print("FLAG: Ticken-Asset nicht gefunden — H/J/M ohne Ticken (nicht raten).")

    slug = safe_slug(name)
    cid = capture_id or f"live-{time.strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:8]}-{slug}"
    cap = CAPTURES / cid
    fullrun = cap / "fullrun"
    images_dir = fullrun / "images"           # finale Standbilder (Zielformat) — PNG bleibt (Rohdaten)
    raw_dir = fullrun / "generated_images"    # NB2-Rohausgaben
    staffel_dir = fullrun / "staffel_work"
    app_ready = fullrun / "app_ready_gop6_loudnorm"
    jpeg_dir = fullrun / "app_jpegs"
    for d in [images_dir, raw_dir, staffel_dir, app_ready, jpeg_dir]:
        d.mkdir(parents=True, exist_ok=True)

    bild_ratio = "21:9" if orientierung == "quer" else "9:16"
    video_ratio = "21:9" if orientierung == "quer" else "9:21"

    karte = Laufkarte(fullrun / "LAUFKARTE.json", {
        "capture_id": cid, "objekt": name, "slug": slug,
        "orientierung": orientierung, "pipeline_version": PIPELINE_VERSION,
        "input": str(input_foto), "gestartet": now_iso(),
        "stoppuhr_offiziell": "2026-06-12 11:05:36",
    })

    # Capture-Manifest (Ingest-kompatibel)
    a_anchor = images_dir / "A_original.jpg"
    if not a_anchor.exists():
        if input_foto.suffix.lower() in {".jpg", ".jpeg"}:
            shutil.copy2(input_foto, a_anchor)
        else:
            Image.open(input_foto).convert("RGB").save(a_anchor, "JPEG", quality=97, subsampling=0)
    manifest_path = cap / "manifest.json"
    manifest = read_json(manifest_path) or {}
    manifest.update({
        "ok": True, "capture_id": cid, "image": str(a_anchor),
        "object_name": name, "pipeline_version": PIPELINE_VERSION,
        "payload": {"ratio": video_ratio, "orientierung": orientierung,
                    "pipeline_version": PIPELINE_VERSION},
        "autopilot_status": "produktionsstrasse_running",
    })
    write_json(manifest_path, manifest)
    karte.stempel("station0_capture_angelegt", input=str(input_foto))

    if dry_run:
        print(f"DRY-RUN: Capture {cid} angelegt, keine Jobs eingereiht.")
        # Job-JSONs zur Ansicht erzeugen (NICHT einreihen)
        probe_dir = fullrun / "dryrun_jobs"
        probe_dir.mkdir(exist_ok=True)
        for spec in IMAGE_SPECS:
            jid = f"DRYRUN_{slug}_{spec['slot']}"
            job_preview = {
                "id": jid, "image": f"<{spec['source']}-Pfad>", "ratio": bild_ratio,
                "resolution": "4k", "mode": "imagen-nano-banana-2-flash",
                "provider_route": "magnific_mcp",
                "prompt_anfang": slot_prompt_text(spec["slot"])[:160],
            }
            write_json(probe_dir / f"bild_{spec['slot']}.json", job_preview)
        for spec in VIDEO_SPECS:
            write_json(probe_dir / f"film_{spec['slot']}.json", {
                "id": f"DRYRUN_{slug}_{spec['slot']}", "ratio": video_ratio,
                "resolution": "Draft", "duration": 5, "tick": spec["tick"],
                "start": spec["start"], "end": spec["end"],
                "prompt_file": str(PROMPTS_VID / VIDEO_PROMPT_FILES[spec["slot"]]),
            })
        karte.stempel("dry_run_jobjsons", anzahl=len(IMAGE_SPECS) + len(VIDEO_SPECS))
        print(f"Job-Vorschauen: {probe_dir}")
        return

    # ── Bild-Stufe: B → (C∥D) → (E∥F), Wächter nach jedem Bild ──────────────
    anchors: dict[str, Path] = {"A": a_anchor}
    finals: dict[str, Path] = {}              # finale Standbilder im Zielformat
    staffel_info: dict[str, dict] = {}

    def produce_image(slot: str) -> None:
        spec = next(s for s in IMAGE_SPECS if s["slot"] == slot)
        raw_png = raw_dir / f"{slot}_{spec['label']}_raw.png"
        with Station(karte, f"station1_bild_{slot}"):
            if not raw_png.exists():
                src = upload_safe_image(anchors[spec["source"]], staffel_dir)
                jid = queue_image_job(slug, slot, spec["label"], src, bild_ratio,
                                      raw_dir / f"mcp_{slot}")
                karte.stempel(f"bild_{slot}_eingereiht", job_id=jid)
                wait_image_result(jid, raw_dir / f"mcp_{slot}", raw_png)
            # Kompositions-Wächter + max. 1 verschärfter Neuversuch
            check = komposition_pruefen(raw_png, orientierung)
            karte.stempel(f"station2_waechter_{slot}", status="ok" if check["ok"] else "rot", **check)
            if not check["ok"] and not (raw_dir / f"{slot}_retry_done").exists():
                zusatz = VERSCHAERFUNG_QUER if orientierung == "quer" else VERSCHAERFUNG_HOCH
                src = upload_safe_image(anchors[spec["source"]], staffel_dir)
                jid2 = queue_image_job(slug, slot, spec["label"], src, bild_ratio,
                                       raw_dir / f"mcp_{slot}_r2", prompt_suffix=zusatz, attempt=2)
                retry_png = raw_dir / f"{slot}_{spec['label']}_raw_r2.png"
                wait_image_result(jid2, raw_dir / f"mcp_{slot}_r2", retry_png)
                check2 = komposition_pruefen(retry_png, orientierung)
                karte.stempel(f"station2_waechter_{slot}_retry",
                              status="ok" if check2["ok"] else "rot", **check2)
                (raw_dir / f"{slot}_retry_done").write_text("1")
                if check2["ok"] or check2["steril_a"] + check2["steril_b"] > check["steril_a"] + check["steril_b"]:
                    shutil.copy2(retry_png, raw_png)
                    check = check2
                if not check["ok"]:
                    karte.stempel(f"flag_anbau_noetig_{slot}", status="flag",
                                  flags=["anbau_noetig"])
        # Referenzkette IMMER über NB2-Rohformat — entkoppelt die Bildkette vom
        # Zielformat (quer wartet sonst auf das T2-Seedance-Maß und staut alles).
        anchors[slot] = raw_png
        if orientierung == "hoch":
            # Staffel sofort: 9:21-Ergebnis = Keyframe + App-Standbild
            final_png = images_dir / spec["filename"]
            with Station(karte, f"station3_staffel_{slot}"):
                if not final_png.exists():
                    staffel_info[slot] = hochkant_staffel(raw_png, final_png, slug, slot,
                                                          staffel_dir, karte)
            finals[slot] = final_png
            app_jpeg(final_png, jpeg_dir / (final_png.stem + ".jpg"))

    def quer_crop_alle() -> None:
        """Station 4 (nur quer): alle Rohbilder auf Seedance-21:9-Maß beschneiden.
        Wartet ggf. auf das T2-Messergebnis — bewusst NACH der Bildkette."""
        for spec in IMAGE_SPECS:
            slot = spec["slot"]
            raw_png = raw_dir / f"{slot}_{spec['label']}_raw.png"
            if slot in finals or not raw_png.exists():
                continue
            final_png = images_dir / spec["filename"]
            with Station(karte, f"station4_quer_crop_{slot}"):
                if not final_png.exists():
                    staffel_info[slot] = quer_beschnitt(raw_png, final_png)
            finals[slot] = final_png
            app_jpeg(final_png, jpeg_dir / (final_png.stem + ".jpg"))

    # B zuerst (alles hängt dran)
    produce_image("B")

    # Film-Management: Slot einreihen sobald Keyframes da (Tempo-Regel 3)
    video_jobs: dict[str, str] = {}
    video_done: dict[str, Path] = {}
    vids_dir = fullrun / "seedance_video_outputs"

    def keyframe(slot_letter: str) -> Path | None:
        if slot_letter == "A":
            return anchors["A"]
        return finals.get(slot_letter)

    def try_queue_videos() -> None:
        for spec in VIDEO_SPECS:
            if spec["slot"] in video_jobs:
                continue
            s, e = keyframe(spec["start"]), keyframe(spec["end"])
            if s and e and s.exists() and e.exists():
                out_dir = vids_dir / f"{spec['order']:02d}_{spec['slot']}_{spec['label']}"
                jid = queue_video_job(slug, spec, s, e, video_ratio, out_dir)
                video_jobs[spec["slot"]] = jid
                karte.stempel(f"station6_film_{spec['slot']}_eingereiht", job_id=jid)

    try_queue_videos()   # G + H sobald B fertig

    # C+D parallel (Tempo-Regel 1)
    threads = []
    errors: list[str] = []
    def guarded(slot):
        try:
            produce_image(slot)
            try_queue_videos()
        except Exception as ex:
            errors.append(f"{slot}: {ex}")
            karte.stempel(f"flag_bildkette_{slot}", status="fehler", fehler=str(ex))
    for slot in ["C", "D"]:
        th = threading.Thread(target=guarded, args=(slot,), daemon=True)
        th.start(); threads.append(th)
    for th in threads: th.join()

    # E+F parallel (E←C, F←D — nur wenn Quelle da)
    threads = []
    for slot, dep in [("E", "C"), ("F", "D")]:
        if dep in finals:
            th = threading.Thread(target=guarded, args=(slot,), daemon=True)
            th.start(); threads.append(th)
        else:
            karte.stempel(f"flag_uebersprungen_{slot}", status="flag",
                          flags=[f"quelle_{dep}_fehlt"])
    for th in threads: th.join()

    if PROMPT_KUERZUNGEN:
        karte.stempel("flag_prompt_kuerzungen", status="flag",
                      flags=["prompts_ueber_api_limit"],
                      verlorene_zeichen=dict(PROMPT_KUERZUNGEN))

    # Quer: jetzt alle Crops (wartet ggf. auf T2-Maß — Bildkette ist schon durch)
    if orientierung == "quer":
        quer_crop_alle()
    try_queue_videos()

    # Kompressionsreihe am Hero für T3
    if "B" in finals:
        with Station(karte, "station5_kompressionsreihe_B"):
            kompressionsreihe(finals["B"], fullrun / "jpeg_abnahme")

    # ── Filme einsammeln + veredeln ──────────────────────────────────────────
    with Station(karte, "station67_filme_einsammeln_veredeln"):
        t0 = time.time()
        pending = dict(video_jobs)
        while pending and time.time() - t0 < VIDEO_TIMEOUT_S:
            for slot, jid in list(pending.items()):
                st = video_state(jid)
                if not st:
                    continue
                if st.get("failed"):
                    karte.stempel(f"flag_film_{slot}_failed", status="flag",
                                  flags=["seedance_failed"], job_id=jid)
                    pending.pop(slot)
                    continue
                video = final_video_from_state(st)
                if not video:
                    continue
                spec = next(s for s in VIDEO_SPECS if s["slot"] == slot)
                trimmed = staffel_dir / f"trim_{spec['filename']}"
                tr = trim_freezes(video, trimmed)
                # C41 Abholvertrag: wegnehmen = abgeholt (nach trim_freezes, das nur liest)
                video.unlink(missing_ok=True)
                ready = app_ready / f"S_{spec['filename']}"
                fin = app_finish_video(trimmed, ready, tick=spec["tick"])
                make_reverse(ready)
                video_done[slot] = ready
                karte.stempel(f"station7_film_{slot}_fertig", **{**tr, **fin},
                              pfad=str(ready))
                pending.pop(slot)
            if pending:
                time.sleep(POLL_S)
        for slot in pending:
            karte.stempel(f"flag_film_{slot}_timeout", status="flag", flags=["video_timeout"])

    # ── Station 8: Ablage ────────────────────────────────────────────────────
    with Station(karte, "station8_ablage"):
        app_ready_videos = []
        for spec in VIDEO_SPECS:
            p = app_ready / f"S_{spec['filename']}"
            if p.exists():
                app_ready_videos.append({
                    "slot": spec["slot"], "variant": "S", "badge": "S",
                    "provider_name": "Seedance 1.5 Pro", "label": spec["label"],
                    "order_index": spec["order"], "path": str(p),
                    "start_slot": spec["start"], "end_slot": spec["end"],
                })
        status = "app_ready" if len(app_ready_videos) == len(VIDEO_SPECS) else "partial"
        write_json(fullrun / "RUN_LEDGER.json", {
            "status": status, "capture_id": cid, "ratio": video_ratio,
            **{k: str(v) for k, v in anchors.items()},
            **{f"final_{k}": str(v) for k, v in finals.items()},
            "app_jpegs": {p.stem: str(p) for p in sorted(jpeg_dir.glob("*.jpg"))},
            "app_ready_videos": sorted(app_ready_videos, key=lambda i: i["order_index"]),
            "staffel": staffel_info, "audio_version": AUDIO_VERSION,
            "updated_at": now_iso(),
        })
        manifest["autopilot_status"] = status
        manifest["hero_image"] = str(finals.get("B", ""))
        write_json(manifest_path, manifest)

        # Objektarchiv-Spiegel (Rohdaten-Regel)
        archiv = OBJEKTARCHIV / f"{time.strftime('%Y%m%d-%H%M')}_{slug[:40]}_{cid.split('-')[-1]}"
        ab = archiv / "rohdaten" / "bilder"; af = archiv / "rohdaten" / "filme"
        ab.mkdir(parents=True, exist_ok=True); af.mkdir(parents=True, exist_ok=True)
        nummer = {"A": "01", "B": "02", "C": "03", "D": "04", "E": "05", "F": "06"}
        shutil.copy2(a_anchor, ab / "01_A_original.jpg")
        for slot, p in finals.items():
            shutil.copy2(p, ab / f"{nummer[slot]}_{p.name}")
        for slot, p in video_done.items():
            spec = next(s for s in VIDEO_SPECS if s["slot"] == slot)
            shutil.copy2(p, af / f"{spec['order']+6:02d}_{spec['slot']}_{spec['label']}_S.mp4")
        karte.stempel("station8_archiv_spiegel", pfad=str(archiv))

    gesamt = time.time() - t_start
    karte.finish(gesamt)
    fehlt = [s["slot"] for s in VIDEO_SPECS if s["slot"] not in video_done]
    print(f"\n{'='*60}\nFERTIG: {cid}")
    print(f"Gesamt: {gesamt/60:.1f} min | Filme fertig: {len(video_done)}/8"
          + (f" | offen: {fehlt}" if fehlt else ""))
    print(f"LAUFKARTE: {fullrun / 'LAUFKARTE.json'}")
    if errors:
        print(f"FEHLER in Bildkette: {errors}")


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--input", required=True)
    ap.add_argument("--orientierung", required=True, choices=["quer", "hoch"])
    ap.add_argument("--name", required=True)
    ap.add_argument("--capture-id", default=None)
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()
    src = Path(args.input)
    if not src.exists():
        sys.exit(f"ABBRUCH: Input nicht gefunden: {src}")
    run(src, args.orientierung, args.name, args.capture_id, args.dry_run)


if __name__ == "__main__":
    main()
