"""Storyboard → Video (GPT Image 2 + Seedance/PixVerse) – Pollo-Style Hybrid."""

from __future__ import annotations

import base64
import mimetypes
import subprocess
import uuid
from pathlib import Path
from typing import Any, Callable

import requests

ROOT = Path(__file__).resolve().parents[1]

RUNWAY_RATIO_FALLBACK = {
    "9:16": "720:1280",
    "16:9": "1280:720",
    "1:1": "960:960",
    "4:3": "1104:832",
    "3:4": "832:1104",
}

ASPECT_IMAGE_SIZE = {
    "9:16": "1024x1536",
    "16:9": "1536x1024",
    "1:1": "1024x1024",
}

ASPECT_VIDEO_SIZE = {
    "9:16": (720, 1280),
    "16:9": (1280, 720),
    "1:1": (1080, 1080),
}

PRESETS: dict[str, dict[str, str]] = {
    "cake_reveal": {
        "name": "Is it Cake? – Trophäen-Reveal",
        "hero": (
            "Cinematic close-up of a golden FIFA World Cup style trophy on a modern kitchen marble counter, "
            "purple ambient LED lighting, shallow depth of field, photorealistic, dramatic commercial hero shot, "
            "no people, no text."
        ),
        "hero_motion": (
            "Slow cinematic dolly-in toward the golden trophy on the kitchen counter. "
            "Subtle light reflections, premium commercial intro, no text, 4 seconds feel."
        ),
        "concept": (
            "Pixar-style 3D chef in purple-lit luxury kitchen bakes a golden FIFA trophy cake. "
            "Sequence: chef intro, cracks egg, pours flour, whips cream, slices strawberries, "
            "frostes chocolate cake, reveals golden trophy on counter, fork cuts trophy showing strawberry cake inside."
        ),
        "motion": (
            "Animate storyboard panels left-to-right, top-to-bottom. Chef bakes step by step, "
            "dramatic reveal of golden trophy, fork cuts in — inside is strawberry cake. "
            "Smooth dolly camera, commercial TikTok pacing, no text."
        ),
    },
    "product_demo": {
        "name": "Produkt-Launch",
        "hero": (
            "Premium product hero shot on marble surface, soft studio lighting, luxury commercial photography, "
            "single product center frame, no hands, no text."
        ),
        "hero_motion": "Slow orbit and dolly around the product hero shot. Premium launch video intro.",
        "concept": (
            "Cinematic product hero on marble counter, unboxing hands, close-up details, "
            "lifestyle use, happy customer reaction, logo moment — 9 panels, consistent lighting."
        ),
        "motion": (
            "Product commercial montage following storyboard panels. Slow hero shots, "
            "hands unbox, macro details, lifestyle scene, satisfied smile. Premium ad pacing."
        ),
    },
    "dating_hook": {
        "name": "Dating-Hook (TikTok)",
        "hero": (
            "Romantic café golden hour, two coffee cups on table, soft bokeh, warm cinematic hook shot, "
            "no faces visible, no text."
        ),
        "hero_motion": "Gentle push-in on café table, warm golden hour light, romantic TikTok hook.",
        "concept": (
            "Two stylish young adults meet at café — glance, smile, phone notification, "
            "awkward cute moment, laugh together, walk off holding hands — 9 panels, warm golden hour."
        ),
        "motion": (
            "Romantic TikTok montage from storyboard. Glances, smiles, phone buzz, laugh, "
            "walk away together. Warm cinematic motion, vertical social format."
        ),
    },
}


def _find_ffmpeg() -> str | None:
    from jam.tiktok_remix import _find_ffmpeg

    return _find_ffmpeg()


def providers_status() -> dict[str, bool]:
    from jam.ai_video_api import runway_api_key
    from jam.dialogue_scene import _openai_key
    from jam.pixverse_api import api_key as pixverse_key

    return {
        "openai": bool(_openai_key()),
        "runway": bool(runway_api_key()),
        "pixverse": bool(pixverse_key()),
    }


def validate_ready(
    *,
    has_upload: bool = False,
    has_hero_upload: bool = False,
    hero_shot: bool = False,
) -> tuple[bool, str]:
    st = providers_status()
    needs_openai = (not has_upload) or (hero_shot and not has_hero_upload)
    if needs_openai and not st["openai"]:
        return False, "openai_api_key fehlt (GPT Image für Storyboard/Hero)."
    if not st["runway"] and not st["pixverse"]:
        return False, "runway_api_key oder pixverse_api_key fehlt (Animation)."
    return True, "OK"


def build_storyboard_prompt(concept: str, *, cols: int = 3, rows: int = 3) -> str:
    panels = cols * rows
    steps = []
    for i in range(1, panels + 1):
        steps.append(f"panel {i}")
    grid_hint = ", ".join(steps)
    return (
        f"A {cols}x{rows} storyboard grid with exactly {panels} equal panels and thin white gutters. "
        "NO text, NO labels, NO watermarks, NO panel numbers drawn on image. "
        f"Story sequence across {grid_hint}: {concept.strip()} "
        "Same character design, same wardrobe, same environment in every panel. "
        "Cinematic lighting, Pixar-quality 3D render, photorealistic food and props."
    )[:4000]


def build_hero_prompt(hero_concept: str, *, aspect_ratio: str = "9:16") -> str:
    orient = "vertical 9:16 portrait" if aspect_ratio == "9:16" else (
        "horizontal 16:9 widescreen" if aspect_ratio == "16:9" else "square 1:1"
    )
    return (
        f"Single cinematic hero frame, {orient}. NO text, NO watermarks, NO collage. "
        f"{hero_concept.strip()} "
        "Ultra sharp, commercial advertising quality, dramatic lighting, one clear subject."
    )[:4000]


def compose_ad_poster(hero_path: Path, storyboard_path: Path, dest: Path) -> Path | None:
    """Pollo-Style Vorschaubild: Hero oben, Storyboard unten."""
    try:
        from PIL import Image
    except ImportError:
        return None
    if not hero_path.is_file() or not storyboard_path.is_file():
        return None
    hero = Image.open(hero_path).convert("RGB")
    board = Image.open(storyboard_path).convert("RGB")
    target_w = 1080
    hero_h = int(target_w * hero.height / max(hero.width, 1))
    board_h = int(target_w * board.height / max(board.width, 1))
    hero = hero.resize((target_w, hero_h), Image.Resampling.LANCZOS)
    board = board.resize((target_w, board_h), Image.Resampling.LANCZOS)
    gap = 12
    out = Image.new("RGB", (target_w, hero_h + gap + board_h), (12, 10, 20))
    out.paste(hero, (0, 0))
    out.paste(board, (0, hero_h + gap))
    dest.parent.mkdir(parents=True, exist_ok=True)
    out.save(dest, format="JPEG", quality=92)
    return dest


def normalize_video_clip(src: Path, dest: Path, *, width: int, height: int) -> Path:
    ff = _find_ffmpeg()
    if not ff or not src.is_file():
        return src
    from jam.gpu_render import ffmpeg_encode_args

    dest.parent.mkdir(parents=True, exist_ok=True)
    vf = (
        f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
        f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p"
    )
    try:
        subprocess.run(
            [
                ff,
                "-y",
                "-i",
                str(src),
                "-vf",
                vf,
                "-an",
                *ffmpeg_encode_args(crf_fallback="20"),
                "-movflags",
                "+faststart",
                str(dest),
            ],
            capture_output=True,
            timeout=300,
            check=True,
        )
        return dest if dest.is_file() else src
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
        return src


def generate_storyboard_image(
    prompt: str,
    dest: Path,
    *,
    size: str = "1024x1536",
) -> Path:
    from jam.dialogue_scene import _try_openai_image

    dest.parent.mkdir(parents=True, exist_ok=True)
    if not _try_openai_image(prompt, dest, size=size):
        raise RuntimeError(
            "Storyboard-Bild fehlgeschlagen – openai_api_key in data/jami-secrets.local.json setzen "
            "(GPT Image / gpt-image-1)."
        )
    return dest


def prepare_image_for_api(path: Path, work_dir: Path) -> Path:
    """Runway/PixVerse: Größe und Dateigröße begrenzen."""
    from PIL import Image

    work_dir.mkdir(parents=True, exist_ok=True)
    img = Image.open(path).convert("RGB")
    w, h = img.size
    max_side = 1536
    if max(w, h) > max_side:
        scale = max_side / max(w, h)
        img = img.resize((int(w * scale), int(h * scale)), Image.Resampling.LANCZOS)
    out = work_dir / f"api_{path.stem}.jpg"
    img.save(out, format="JPEG", quality=88, optimize=True)
    if out.stat().st_size > 4_500_000:
        img.save(out, format="JPEG", quality=72, optimize=True)
    return out


def split_storyboard_grid(
    image_path: Path,
    out_dir: Path,
    *,
    cols: int = 3,
    rows: int = 3,
) -> list[Path]:
    from PIL import Image

    out_dir.mkdir(parents=True, exist_ok=True)
    img = Image.open(image_path).convert("RGB")
    w, h = img.size
    cw, ch = w // cols, h // rows
    inset_x = max(2, int(cw * 0.025))
    inset_y = max(2, int(ch * 0.025))
    frames: list[Path] = []
    n = 0
    for row in range(rows):
        for col in range(cols):
            box = (
                col * cw + inset_x,
                row * ch + inset_y,
                (col + 1) * cw - inset_x,
                (row + 1) * ch - inset_y,
            )
            panel = img.crop(box)
            fp = out_dir / f"panel_{n:02d}.png"
            panel.save(fp, format="PNG")
            frames.append(fp)
            n += 1
    return frames


def _image_data_uri(path: Path) -> str:
    mime = mimetypes.guess_type(str(path))[0] or "image/png"
    b64 = base64.b64encode(path.read_bytes()).decode("ascii")
    return f"data:{mime};base64,{b64}"


def animate_runway(
    image_path: Path,
    prompt: str,
    dest: Path,
    *,
    model: str = "seedance2",
    ratio: str = "9:16",
    duration: int = 8,
    timeout_sec: float = 900,
    work_dir: Path | None = None,
) -> Path:
    from jam.ai_video_api import _runway_headers, download_video, runway_wait_task

    api_img = prepare_image_for_api(image_path, work_dir or image_path.parent)
    ratios = [ratio]
    if ratio in RUNWAY_RATIO_FALLBACK:
        fb = RUNWAY_RATIO_FALLBACK[ratio]
        if fb not in ratios:
            ratios.append(fb)

    last_err = ""
    for r_ratio in ratios:
        body: dict[str, Any] = {
            "model": model,
            "promptText": prompt.strip()[:4000],
            "promptImage": _image_data_uri(api_img),
            "ratio": r_ratio,
            "duration": max(4, min(15, int(duration))),
        }
        try:
            resp = requests.post(
                "https://api.dev.runwayml.com/v1/image_to_video",
                headers=_runway_headers(),
                json=body,
                timeout=60,
            )
            if resp.status_code >= 400:
                last_err = f"Runway ITV {resp.status_code}: {resp.text[:300]}"
                continue
            task_id = str(resp.json().get("id") or "")
            if not task_id:
                last_err = "Runway: keine Task-ID"
                continue
            task = runway_wait_task(task_id, timeout_sec=timeout_sec)
            outputs = task.get("output") or []
            if not outputs:
                last_err = "Runway: kein Video"
                continue
            dest.parent.mkdir(parents=True, exist_ok=True)
            return download_video(str(outputs[0]), dest)
        except (requests.RequestException, RuntimeError, TimeoutError) as e:
            last_err = str(e)[:300]
    raise RuntimeError(last_err or "Runway Animation fehlgeschlagen")


def animate_pixverse(
    image_path: Path,
    prompt: str,
    dest: Path,
    *,
    aspect_ratio: str = "9:16",
    duration: int = 5,
    quality: str = "720p",
    model: str = "v5.5",
    timeout_sec: float = 900,
) -> Path:
    from jam import pixverse_api

    img_id = pixverse_api.upload_image(image_path)
    video_id = pixverse_api.image_to_video(
        img_id,
        prompt,
        aspect_ratio=aspect_ratio,
        duration=duration,
        model=model,
        quality=quality,
    )
    result = pixverse_api.wait_for_video(video_id, timeout_sec=timeout_sec)
    url = str(result.get("url") or "")
    if not url:
        raise RuntimeError("PixVerse: keine Video-URL")
    dest.parent.mkdir(parents=True, exist_ok=True)
    return pixverse_api.download_video(url, dest)


def pick_video_engine(prefer: str = "auto") -> str:
    st = providers_status()
    pref = (prefer or "auto").strip().lower()
    if pref == "runway" and st["runway"]:
        return "runway"
    if pref == "pixverse" and st["pixverse"]:
        return "pixverse"
    if st["runway"]:
        return "runway"
    if st["pixverse"]:
        return "pixverse"
    raise RuntimeError("Weder Runway noch PixVerse API-Key konfiguriert.")


def animate_image(
    image_path: Path,
    prompt: str,
    dest: Path,
    *,
    engine: str = "auto",
    duration: int = 8,
    aspect_ratio: str = "9:16",
    work_dir: Path | None = None,
) -> tuple[Path, str]:
    eng = pick_video_engine(engine)
    wd = work_dir or image_path.parent

    def _pixverse() -> tuple[Path, str]:
        path = animate_pixverse(
            image_path,
            prompt,
            dest,
            aspect_ratio=aspect_ratio,
            duration=min(8, max(5, duration)),
            quality="720p",
        )
        return path, "pixverse/i2v"

    if eng == "runway":
        try:
            path = animate_runway(
                image_path,
                prompt,
                dest,
                model="seedance2",
                ratio=aspect_ratio,
                duration=duration,
                work_dir=wd,
            )
            return path, "runway/seedance2"
        except Exception:
            if (engine or "auto").strip().lower() == "auto" and providers_status()["pixverse"]:
                return _pixverse()
            raise

    return _pixverse()


def concat_videos(clips: list[Path], dest: Path) -> Path:
    ff = _find_ffmpeg()
    if not ff:
        raise RuntimeError("ffmpeg fehlt")
    valid = [p for p in clips if p.is_file() and p.stat().st_size > 1000]
    if not valid:
        raise RuntimeError("Keine Clips zum Zusammenfügen")
    if len(valid) == 1:
        dest.parent.mkdir(parents=True, exist_ok=True)
        import shutil

        shutil.copy2(valid[0], dest)
        return dest

    work = dest.parent
    list_file = work / f"concat_{uuid.uuid4().hex[:8]}.txt"
    list_file.write_text(
        "\n".join(f"file '{p.resolve().as_posix()}'" for p in valid),
        encoding="utf-8",
    )
    tmp = work / f"merged_{uuid.uuid4().hex[:8]}.mp4"
    subprocess.run(
        [ff, "-y", "-f", "concat", "-safe", "0", "-i", str(list_file), "-c", "copy", str(tmp)],
        capture_output=True,
        timeout=300,
        check=True,
    )
    dest.parent.mkdir(parents=True, exist_ok=True)
    from jam.gpu_render import ffmpeg_encode_args

    subprocess.run(
        [
            ff,
            "-y",
            "-i",
            str(tmp),
            *ffmpeg_encode_args(crf_fallback="20"),
            "-pix_fmt",
            "yuv420p",
            "-movflags",
            "+faststart",
            str(dest),
        ],
        capture_output=True,
        timeout=600,
        check=True,
    )
    try:
        list_file.unlink(missing_ok=True)
        tmp.unlink(missing_ok=True)
    except OSError:
        pass
    return dest


def _probe_duration(path: Path) -> float:
    ff = _find_ffmpeg()
    if not ff or not path.is_file():
        return 8.0
    try:
        r = subprocess.run(
            [
                ff,
                "-v",
                "error",
                "-show_entries",
                "format=duration",
                "-of",
                "default=noprint_wrappers=1:nokey=1",
                str(path),
            ],
            capture_output=True,
            text=True,
            timeout=30,
        )
        return max(1.0, float((r.stdout or "8").strip()))
    except (ValueError, subprocess.SubprocessError, OSError):
        return 8.0


def add_background_music(video: Path, dest: Path) -> Path:
    """Leise Promo-Musik unter Video legen."""
    ff = _find_ffmpeg()
    from jam.promo_bgm import ensure_bgm_path

    if not ff:
        return video
    bgm = ensure_bgm_path()
    if not bgm:
        return video
    dur = _probe_duration(video)
    dest.parent.mkdir(parents=True, exist_ok=True)
    try:
        subprocess.run(
            [
                ff,
                "-y",
                "-i",
                str(video),
                "-i",
                str(bgm),
                "-filter_complex",
                (
                    f"[1:a]aloop=loop=-1:size=2e+09,atrim=0:{dur:.2f},volume=0.12[bgm];"
                    f"[0:a]aformat=sample_rates=44100:channel_layouts=stereo[voice];"
                    "[voice][bgm]amix=inputs=2:duration=first:dropout_transition=2[aout]"
                ),
                "-map",
                "0:v",
                "-map",
                "[aout]",
                "-c:v",
                "copy",
                "-c:a",
                "aac",
                "-b:a",
                "192k",
                "-t",
                f"{dur:.2f}",
                "-movflags",
                "+faststart",
                str(dest),
            ],
            capture_output=True,
            timeout=300,
            check=True,
        )
        return dest if dest.is_file() else video
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
        try:
            subprocess.run(
                [
                    ff,
                    "-y",
                    "-i",
                    str(video),
                    "-i",
                    str(bgm),
                    "-filter_complex",
                    (
                        f"[1:a]aloop=loop=-1:size=2e+09,atrim=0:{dur:.2f},volume=0.12[bgm]"
                    ),
                    "-map",
                    "0:v",
                    "-map",
                    "[bgm]",
                    "-c:v",
                    "copy",
                    "-c:a",
                    "aac",
                    "-b:a",
                    "192k",
                    "-shortest",
                    "-movflags",
                    "+faststart",
                    str(dest),
                ],
                capture_output=True,
                timeout=300,
                check=True,
            )
            return dest if dest.is_file() else video
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
            return video


def export_to_desktop(video: Path, job_id: str) -> Path | None:
    try:
        from jam.jampulse_desktop import videos_storyboard_dir

        out_dir = videos_storyboard_dir()
    except ImportError:
        out_dir = Path.home() / "Desktop" / "VidPuls" / "Videos" / "Storyboard"
        out_dir.mkdir(parents=True, exist_ok=True)
    if not video.is_file():
        return None
    dest = out_dir / f"storyboard_{job_id}.mp4"
    import shutil

    shutil.copy2(video, dest)
    return dest


def build_hero_segment(
    work_dir: Path,
    *,
    hero_concept: str,
    hero_motion: str,
    hero_upload: Path | None,
    aspect_ratio: str,
    engine: str,
    hero_duration: int,
    progress: Callable[[str], None] | None = None,
) -> tuple[Path, Path]:
    """Hero-Bild + kurzer Seedance-Intro-Clip (Pollo-Style)."""

    def _p(msg: str) -> None:
        if progress:
            progress(msg)

    hero_img = work_dir / "hero.png"
    if hero_upload and hero_upload.is_file():
        import shutil

        shutil.copy2(hero_upload, hero_img)
        _p("Hochgeladenes Hero-Bild wird verwendet …")
    else:
        _p("Hero-Shot mit GPT Image generieren …")
        img_size = ASPECT_IMAGE_SIZE.get(aspect_ratio, "1024x1536")
        generate_storyboard_image(
            build_hero_prompt(hero_concept, aspect_ratio=aspect_ratio),
            hero_img,
            size=img_size,
        )

    _p("Hero-Shot animieren (Seedance Intro) …")
    hero_raw = work_dir / "hero_animated.mp4"
    motion = hero_motion.strip() or (
        "Slow cinematic dolly-in, premium commercial hero intro, subtle camera motion, no text."
    )
    animate_image(
        hero_img,
        motion,
        hero_raw,
        engine=engine,
        duration=max(4, min(6, int(hero_duration))),
        aspect_ratio=aspect_ratio,
        work_dir=work_dir,
    )
    w, h = ASPECT_VIDEO_SIZE.get(aspect_ratio, (720, 1280))
    hero_norm = work_dir / "hero_norm.mp4"
    normalize_video_clip(hero_raw, hero_norm, width=w, height=h)
    return hero_img, hero_norm


def merge_hero_with_clips(
    hero_clip: Path,
    main_clips: list[Path],
    dest: Path,
    *,
    aspect_ratio: str = "9:16",
) -> Path:
    w, h = ASPECT_VIDEO_SIZE.get(aspect_ratio, (720, 1280))
    normed: list[Path] = [hero_clip]
    for i, clip in enumerate(main_clips):
        out = clip.parent / f"norm_{i:02d}.mp4"
        normed.append(normalize_video_clip(clip, out, width=w, height=h))
    return concat_videos(normed, dest)


def run_storyboard_job(
    work_dir: Path,
    *,
    concept: str,
    motion_prompt: str,
    mode: str = "grid",
    preset: str = "",
    engine: str = "auto",
    duration: int = 8,
    aspect_ratio: str = "9:16",
    storyboard_path: Path | None = None,
    hero_upload: Path | None = None,
    hero_shot: bool = False,
    hero_concept: str = "",
    hero_motion: str = "",
    hero_duration: int = 4,
    add_music: bool = False,
    progress: Callable[[str], None] | None = None,
) -> dict:
    """Vollständige Pipeline. progress(msg) optional."""

    def _p(msg: str) -> None:
        if progress:
            progress(msg)

    work_dir.mkdir(parents=True, exist_ok=True)
    if preset and preset in PRESETS:
        p = PRESETS[preset]
        if not concept.strip():
            concept = p["concept"]
        if not motion_prompt.strip():
            motion_prompt = p["motion"]
        if not hero_concept.strip():
            hero_concept = str(p.get("hero") or "")
        if not hero_motion.strip():
            hero_motion = str(p.get("hero_motion") or "")

    _p("Storyboard mit GPT Image generieren …")
    storyboard = work_dir / "storyboard.png"
    if storyboard_path and storyboard_path.is_file():
        import shutil

        shutil.copy2(storyboard_path, storyboard)
        _p("Hochgeladenes Storyboard wird verwendet …")
    else:
        img_size = ASPECT_IMAGE_SIZE.get(aspect_ratio, "1024x1536")
        sb_prompt = build_storyboard_prompt(concept)
        generate_storyboard_image(sb_prompt, storyboard, size=img_size)

    result: dict[str, Any] = {
        "storyboard": str(storyboard),
        "concept": concept,
        "mode": mode,
    }

    mode_l = (mode or "grid").strip().lower()
    if mode_l == "frames":
        _p("Storyboard in 9 Panels schneiden …")
        panels_dir = work_dir / "panels"
        panels = split_storyboard_grid(storyboard, panels_dir)
        result["panels"] = [str(p) for p in panels]

        clips: list[Path] = []
        clip_dur = max(3, min(8, duration // max(len(panels), 1) + 2))
        for i, panel in enumerate(panels):
            _p(f"Panel {i + 1}/{len(panels)} animieren …")
            clip_path = work_dir / f"clip_{i:02d}.mp4"
            panel_motion = (
                f"{motion_prompt} Focus on storyboard panel {i + 1}. "
                "Single scene, smooth cinematic motion."
            )
            animate_image(
                panel,
                panel_motion,
                clip_path,
                engine=engine,
                duration=clip_dur,
                aspect_ratio=aspect_ratio,
                work_dir=work_dir,
            )
            clips.append(clip_path)

        _p("Clips lokal zusammenfügen (GPU/FFmpeg) …")
        result["clips"] = [str(c) for c in clips]
        result["engine"] = pick_video_engine(engine)
        if hero_shot:
            main_clips = clips
        else:
            story_final = work_dir / "story_main.mp4"
            concat_videos(clips, story_final)
            main_clips = [story_final]
            result["video"] = str(story_final)
    else:
        _p("Ganzes Storyboard mit Seedance/PixVerse animieren …")
        raw = work_dir / "animated.mp4"
        _, eng_label = animate_image(
            storyboard,
            motion_prompt
            or (
                "Animate storyboard panels left-to-right top-to-bottom. "
                "Follow each panel exactly. Smooth cinematic camera, commercial pacing, no text."
            ),
            raw,
            engine=engine,
            duration=duration,
            aspect_ratio=aspect_ratio,
            work_dir=work_dir,
        )
        final = work_dir / "story_main.mp4"
        import shutil

        shutil.copy2(raw, final)
        result["video"] = str(final)
        result["engine"] = eng_label
        main_clips = [final]

    if hero_shot:
        hero_img, hero_clip = build_hero_segment(
            work_dir,
            hero_concept=hero_concept,
            hero_motion=hero_motion,
            hero_upload=hero_upload,
            aspect_ratio=aspect_ratio,
            engine=engine,
            hero_duration=hero_duration,
            progress=progress,
        )
        result["hero"] = str(hero_img)
        poster = work_dir / "ad_poster.jpg"
        if compose_ad_poster(hero_img, storyboard, poster):
            result["poster"] = str(poster)
        _p("Hero-Intro + Storyboard zusammenfügen …")
        merged = work_dir / "final.mp4"
        merge_hero_with_clips(hero_clip, main_clips, merged, aspect_ratio=aspect_ratio)
        result["video"] = str(merged)
        result["hero_clip"] = str(hero_clip)

    final = Path(result["video"])
    if add_music and final.is_file():
        _p("Hintergrundmusik hinzufügen …")
        with_music = work_dir / "final_music.mp4"
        mixed = add_background_music(final, with_music)
        if mixed.is_file() and mixed.stat().st_size > 1000:
            import shutil

            shutil.copy2(mixed, final)
    result["video"] = str(final)
    return result
