#!/usr/bin/env python
"""lapian.py v2 — 拉片 -> 人读报告素材 + agent 复刻包 (recreate/).
Usage:
  python lapian.py <video> <outdir> [--gpu http://100.114.94.47:8790] [--stages detect,shots,labels,maps,audio,grade,edl,package]
Runs CPU work locally (ffmpeg, PySceneDetect, colour stats, LUT, EDL) and calls the lapian GPU gateway
(see C:\\KB\\README.md) for TransNetV2, ShotVL, CameraBench, Qwen2.5-VL, Depth/DWPose/SAM2, demucs/beat_this/WhisperX/allin1.
Every model output is stored with source + confidence; failures are recorded under _work/errors.json, never faked.
Editorial fields (prompts, character cards, text overlays) are merged from <outdir>/_work/editorial.json if present.
"""
import argparse, json, os, subprocess, sys, math, time, shutil
from pathlib import Path
import numpy as np, cv2
VERSION = "2.0"
GPU = os.environ.get("LAPIAN_GPU", "http://100.114.94.47:8790")
# tailscale serve on the 5090 routes by Host name; LAN direct access needs the firewall rule (see C:\KB\README.md). Set LAPIAN_GPU_HOST="" for LAN.
GPU_HOST = os.environ.get("LAPIAN_GPU_HOST", "5090.tail16d477.ts.net:8790")
def _sess():
    import requests
    s = requests.Session(); s.trust_env = False  # bypass system proxy (Clash TUN) for tailnet/LAN
    if GPU_HOST: s.headers["Host"] = GPU_HOST
    return s
ERR = {}
def sh(*a): return subprocess.run([str(x) for x in a], capture_output=True, text=True, encoding="utf-8", errors="replace")
def jdump(o, p): Path(p).parent.mkdir(parents=True, exist_ok=True); Path(p).write_text(json.dumps(o, ensure_ascii=False, indent=2), encoding="utf-8")
def jload(p, d=None):
    p = Path(p); return json.loads(p.read_text(encoding="utf-8")) if p.exists() else d
def tc(f, fps): s = int(f // fps); return "%02d:%02d:%02d:%02d" % (s // 3600, s // 60 % 60, s % 60, round(f - s * fps))
def gpu_post(ep, files, data=None, timeout=3600):
    import requests
    fh = {k: open(v, "rb") for k, v in files.items()}
    try:
        r = _sess().post(GPU + ep, files=fh, data=data or {}, timeout=timeout); r.raise_for_status(); return r.json()
    finally:
        for f in fh.values(): f.close()
def gpu_get(path, dst):
    import requests
    r = _sess().get(GPU + path, timeout=600); r.raise_for_status(); Path(dst).parent.mkdir(parents=True, exist_ok=True); Path(dst).write_bytes(r.content)

def imread(p):
    p = Path(p)
    return cv2.imdecode(np.fromfile(str(p), np.uint8), cv2.IMREAD_COLOR) if p.exists() else None  # unicode-safe on Windows
def imwrite(p, im, params=()):
    ok, b = cv2.imencode(Path(p).suffix or ".jpg", im, list(params)); Path(p).write_bytes(b.tobytes()); return ok
def probe(v):
    r = json.loads(sh("ffprobe", "-v", "error", "-select_streams", "v:0", "-count_frames", "-show_entries", "stream=width,height,r_frame_rate,nb_read_frames", "-of", "json", v).stdout)["streams"][0]
    a, b = map(int, r["r_frame_rate"].split("/")); fps = a / b; n = int(r["nb_read_frames"])
    return {"fps": round(fps, 3), "frames": n, "size": [r["width"], r["height"]], "duration_s": round(n / fps, 3)}

def stage_detect(v, W, meta):
    from scenedetect import detect, ContentDetector, AdaptiveDetector
    det = {"pyscenedetect_content": [s[0].get_frames() for s in detect(str(v), ContentDetector())][1:],
           "pyscenedetect_adaptive": [s[0].get_frames() for s in detect(str(v), AdaptiveDetector())][1:]}
    try:
        g = gpu_post("/shots/detect", {"video": v}); det["transnetv2"] = g.get("transnetv2")
    except Exception as e: ERR["transnetv2"] = repr(e)
    votes = {}
    for k in ("pyscenedetect_content", "pyscenedetect_adaptive"):
        for c in det[k]: votes.setdefault(c, set()).add(k)
    tn = (det.get("transnetv2") or {}).get("cuts", [])
    for c in tn:
        near = [x for x in votes if abs(x - c) <= 2]
        (votes[near[0]] if near else votes.setdefault(c, set())).add("transnetv2")
    cuts = sorted(c for c, s in votes.items() if len(s) >= 2)
    det["consensus"] = cuts; det["votes"] = {str(c): sorted(s) for c, s in sorted(votes.items())}
    jdump(det, W / "_work" / "detect_raw.json"); return cuts

def color_stats(img):
    rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 255
    lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB).astype(np.float32); hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    Z = rgb.reshape(-1, 3)[::7]; K = 5
    _, lbl, ctr = cv2.kmeans(Z.astype(np.float32), K, None, (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 20, 1.0), 3, cv2.KMEANS_PP_CENTERS)
    cnt = np.bincount(lbl.ravel(), minlength=K); order = np.argsort(-cnt)
    pal = [{"hex": "#%02x%02x%02x" % tuple(int(c * 255) for c in ctr[i]), "share": round(float(cnt[i] / cnt.sum()), 3)} for i in order]
    lin = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4).reshape(-1, 3).mean(0)
    M = np.array([[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]]); X, Y, Zc = M @ lin
    cct = None
    if X + Y + Zc > 1e-4:
        x, y = X / (X + Y + Zc), Y / (X + Y + Zc); n = (x - 0.3320) / (0.1858 - y); cct = int(449 * n**3 + 3525 * n**2 + 6823.3 * n + 5520.33)  # McCamy
    L = lab[..., 0] * 100 / 255
    return {"palette": pal, "luma": round(float(L.mean()), 1), "contrast": round(float(L.std()), 1), "sat": round(float(hsv[..., 1].mean() / 2.55), 1),
            "lab_mean": [round(float(L.mean()), 2), round(float(lab[..., 1].mean() - 128), 2), round(float(lab[..., 2].mean() - 128), 2)],
            "lab_std": [round(float(L.std()), 2), round(float(lab[..., 1].std()), 2), round(float(lab[..., 2].std()), 2)],
            "cct_k_est": cct if cct and 1500 < cct < 15000 else None, "cct_note": "McCamy on frame-average (graded footage; indicative only)"}

def stage_shots(v, W, meta, cuts):
    fps = meta["fps"]; b = [0] + cuts + [meta["frames"]]; shots = []
    for i in range(len(b) - 1):
        sid = "S%02d" % (i + 1); d = W / "recreate" / "shots" / sid; d.mkdir(parents=True, exist_ok=True)
        a, z = b[i], b[i + 1] - 1; mid = (a + z) // 2
        fr = {}
        for k, f in (("first", a), ("mid", mid), ("last", z)):
            sh("ffmpeg", "-y", "-loglevel", "error", "-i", v, "-vf", f"select=eq(n\\,{f})", "-vframes", "1", "-q:v", "2", d / f"ref_{k}.jpg")
            im = imread(d / f"ref_{k}.jpg")
            if im is not None: fr[k] = im
        sh("ffmpeg", "-y", "-loglevel", "error", "-i", v, "-vf", f"select=between(n\\,{a}\\,{z}),setpts=PTS-STARTPTS", "-af", f"atrim=start={a/fps}:end={(z+1)/fps},asetpts=PTS-STARTPTS",
           "-c:v", "libx264", "-crf", "16", "-pix_fmt", "yuv420p", "-c:a", "aac", d / "ref_clip.mp4")
        st = color_stats(fr["mid"]) if "mid" in fr else {}
        shots.append({"id": sid, "in_frame": a, "out_frame": z, "frames": z - a + 1, "tc_in": tc(a, fps), "tc_out": tc(z + 1, fps), "start_s": round(a / fps, 3), "dur_s": round((z - a + 1) / fps, 3), "color": st})
    jdump(shots, W / "_work" / "shots_base.json"); return shots

def black(p): im = imread((p)); return im is None or im.mean() < 3

def stage_labels(W, shots):
    raw = jload(W / "_work" / "labels_raw.json", {})
    for s in shots:
        d = W / "recreate" / "shots" / s["id"]
        if black(d / "ref_mid.jpg"): continue
        for ep, key in (("/label/shot", "shotvl"), ("/label/camera", "camerabench"), ("/caption/timeline", "qwen")):
            if raw.get(s["id"], {}).get(key) and "error" not in raw[s["id"]][key]: continue
            try: r = gpu_post(ep, {"clip": d / "ref_clip.mp4"}, {"context": s.get("context", "")} if key == "qwen" else None)
            except Exception as e: r = {"error": repr(e)}; ERR[f"{s['id']}.{key}"] = repr(e)
            raw.setdefault(s["id"], {})[key] = r; jdump(raw, W / "_work" / "labels_raw.json"); print(s["id"], key, "ok" if "error" not in r else r["error"][:200], flush=True)
    try: gpu_post("/vlm/unload", {})
    except Exception: pass
    return raw

def stage_maps(W, shots):
    raw = jload(W / "_work" / "maps_raw.json", {})
    for s in shots:
        d = W / "recreate" / "shots" / s["id"]
        if black(d / "ref_first.jpg") or s["id"] in raw and not any(k.startswith("error") for k in raw[s["id"]]): continue
        try:
            r = gpu_post("/vision/maps", {"image": d / "ref_first.jpg"})
            for k, n in (("depth", "control_depth.png"), ("pose", "control_pose.png")):
                if k in r: gpu_get(r[k], d / n)
            if "sam2" in r: gpu_get(r["sam2"]["mask"], d / "layout_mask.png")
        except Exception as e: r = {"error": repr(e)}; ERR[f"{s['id']}.maps"] = repr(e)
        raw[s["id"]] = r; jdump(raw, W / "_work" / "maps_raw.json"); print(s["id"], "maps", list(r), flush=True)
    return raw

def srt_time(t): ms = int(round(t * 1000)); return "%02d:%02d:%02d,%03d" % (ms // 3600000, ms // 60000 % 60, ms // 1000 % 60, ms % 1000)
def stage_audio(v, W):
    A = W / "recreate" / "audio"; A.mkdir(parents=True, exist_ok=True)
    try: r = gpu_post("/audio/analyze", {"media": v}, {"language": "zh"})
    except Exception as e: ERR["audio"] = repr(e); return {}
    jdump(r, W / "_work" / "audio_raw.json")
    gpu_get(r["full"], A / "full.wav")
    for k, u in (r.get("stems") or {}).items(): gpu_get(u, A / f"{k}.wav")
    segs = (r.get("asr") or {}).get("segments") or []
    with open(A / "dialogue.srt", "w", encoding="utf-8") as f:
        for i, sg in enumerate(segs, 1): f.write(f"{i}\n{srt_time(sg['start'])} --> {srt_time(sg['end'])}\n{sg['text'].strip()}\n\n")
    with open(A / "dialogue_words.srt", "w", encoding="utf-8") as f:
        n = 0
        for sg in segs:
            for w in sg.get("words", []):
                if "start" in w: n += 1; f.write(f"{n}\n{srt_time(w['start'])} --> {srt_time(w['end'])}\n{w['word']}\n\n")
    bt = r.get("beats") or {}
    if "beats" in bt:
        db = set(bt["downbeats"])
        with open(A / "beats.csv", "w", encoding="utf-8") as f:
            f.write("time_s,is_downbeat,bar,beat_in_bar\n"); bar, k = 0, 0
            for t in bt["beats"]:
                if t in db: bar += 1; k = 1
                else: k += 1
                f.write(f"{t},{int(t in db)},{bar},{k}\n")
    if (r.get("structure") or {}).get("segments"): jdump(r["structure"], A / "sections.json")
    return r

def write_cube(path, lab_mean_ref, lab_std_ref, n=33, title="look"):
    # Reinhard Lab statistics transfer from a neutral source assumption (L 50±20, a/b 0±12) -> reference look. Starting-point LUT.
    src_m, src_s = np.array([50, 0, 0.]), np.array([20, 12, 12.])
    g = np.linspace(0, 1, n); B, G, R = np.meshgrid(g, g, g, indexing="ij")
    rgb = np.stack([R, G, B], -1).reshape(-1, 1, 3).astype(np.float32)
    lab = cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB).reshape(-1, 3)  # float: L 0..100, a/b ~ -127..127
    m, s = np.array(lab_mean_ref), np.array(lab_std_ref); s[0] = s[0]
    out = (lab - src_m) / src_s * s + m; out = 0.65 * out + 0.35 * lab if False else out
    o = cv2.cvtColor(out.reshape(-1, 1, 3).astype(np.float32), cv2.COLOR_LAB2RGB).reshape(-1, 3).clip(0, 1)
    with open(path, "w") as f:
        f.write(f'TITLE "{title}"\nLUT_3D_SIZE {n}\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n')
        for r_, g_, b_ in o: f.write("%.6f %.6f %.6f\n" % (r_, g_, b_))

def stage_grade(W, shots):
    G = W / "recreate" / "grade"; G.mkdir(parents=True, exist_ok=True)
    looks = {"warm_backlight": [s for s in shots if s["color"] and s["color"]["luma"] > 28], "low_key": [s for s in shots if s["color"] and 3 < s["color"]["luma"] <= 28]}
    pal = {}
    for name, ss in looks.items():
        if not ss: continue
        labs = []
        for s in ss:
            im = imread((W / "recreate" / "shots" / s["id"] / "ref_mid.jpg")).astype(np.float32) / 255
            labs.append(cv2.cvtColor(im, cv2.COLOR_BGR2LAB).reshape(-1, 3))
        L = np.concatenate(labs); m, sd = L.mean(0), L.std(0)
        write_cube(G / f"look_{name}.cube", m, sd, title=name); pal[name] = {"shots": [s["id"] for s in ss], "lab_mean": m.round(2).tolist(), "lab_std": sd.round(2).tolist()}
    if (G / "look_warm_backlight.cube").exists(): shutil.copy(G / "look_warm_backlight.cube", G / "look.cube")
    pal["per_shot"] = {s["id"]: s["color"].get("palette") for s in shots if s["color"]}
    pal["note"] = "look_*.cube = Reinhard Lab-statistics transfer from a neutral source (L50±20,a/b0±12) to the reference look; a starting point. For exact match use ComfyUI-KJNodes ColorMatch with the shot's ref_mid.jpg as target."
    jdump(pal, G / "palette.json")

def stage_edl(W, shots, meta, title):
    E = W / "recreate" / "edl"; E.mkdir(parents=True, exist_ok=True); fps = meta["fps"]; L = [f"TITLE: {title}", "FCM: NON-DROP FRAME", ""]
    for i, s in enumerate(shots, 1):
        src_in, src_out = tc(0, fps), tc(s["frames"], fps); rec_in, rec_out = tc(s["in_frame"], fps), tc(s["out_frame"] + 1, fps)
        L += [f"{i:03d}  {s['id']:<8} V     C        {src_in} {src_out} {rec_in} {rec_out}", f"* FROM CLIP NAME: {s['id']}.mp4", ""]
    (E / "timeline.edl").write_text("\n".join(L), encoding="utf-8")
    with open(E / "timeline.csv", "w", encoding="utf-8") as f:
        f.write("shot,file,record_in_s,record_out_s,dur_s,frames\n")
        for s in shots: f.write(f"{s['id']},shots/{s['id']}/gen.mp4,{s['start_s']},{round(s['start_s']+s['dur_s'],3)},{s['dur_s']},{s['frames']}\n")

def contact_sheet(W, shots, out, cols=5, w=320):
    tiles = []
    for s in shots:
        im = imread((W / "recreate" / "shots" / s["id"] / "ref_mid.jpg")); im = cv2.resize(im, (w, int(im.shape[0] * w / im.shape[1])))
        cv2.putText(im, f"{s['id']} {s['dur_s']:.2f}s", (8, 26), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2); tiles.append(im)
    while len(tiles) % cols: tiles.append(np.zeros_like(tiles[0]))
    rows = [np.hstack(tiles[i:i + cols]) for i in range(0, len(tiles), cols)]; imwrite((out), np.vstack(rows), [cv2.IMWRITE_JPEG_QUALITY, 88])


SEEDANCE_CAM = {"static": "固定镜头", "push in": "镜头缓慢推进", "pull out": "镜头缓慢拉远", "pan left": "镜头向左摇", "pan right": "镜头向右摇", "tilt up": "镜头上摇", "tilt down": "镜头下摇",
  "truck left": "镜头向左平移", "truck right": "镜头向右平移", "pedestal up": "镜头上升", "pedestal down": "镜头下降", "zoom in": "镜头变焦推近", "zoom out": "镜头变焦拉远", "arc / orbit": "镜头环绕", "handheld": "手持轻微晃动", "tracking": "跟拍"}
def gen_plan(dur):
    g = 5 if dur <= 4.8 else 10; lead = round(min(0.5, (g - dur) / 2), 3)
    return g, {"from_s": lead, "to_s": round(lead + dur, 3), "note": "生成后按 from/to 截取；若动作发生时刻不同，在 [0, gen_dur-dur] 内滑动窗口使动作对齐 action_timeline"}
def crop(src, box, dst):
    im = imread((src)); h, w = im.shape[:2]; x0, y0, x1, y1 = box
    c = im[int(y0*h):int(y1*h), int(x0*w):int(x1*w)]
    if min(c.shape[:2]) < 512: f = 512 / min(c.shape[:2]); c = cv2.resize(c, None, fx=f, fy=f, interpolation=cv2.INTER_LANCZOS4)
    Path(dst).parent.mkdir(parents=True, exist_ok=True); imwrite((dst), c, [cv2.IMWRITE_JPEG_QUALITY, 94])
def stage_package(W, meta, shots, title):
    R = W / "recreate"; ed = jload(W / "_work" / "editorial.json", {}); lab = jload(W / "_work" / "labels_raw.json", {}); mp = jload(W / "_work" / "maps_raw.json", {})
    au = jload(W / "_work" / "audio_raw.json", {}); det = jload(W / "_work" / "detect_raw.json", {})
    for cid, c in (ed.get("characters") or {}).items():
        for n, (sid, k, box) in c.get("crops", {}).items(): crop(R / "shots" / sid / f"ref_{k}.jpg", box, R / "characters" / cid / n)
        jdump({k: v for k, v in c.items() if k != "crops"} | {"refs": {n: f"from shots/{sid}/ref_{k}.jpg bbox_norm {box}" for n, (sid, k, box) in c.get("crops", {}).items()}, "lock_face_ref": "ref_face.jpg"}, R / "characters" / cid / "card.json")
    beats = ((au.get("beats") or {}).get("beats")) or []
    onsets_note = "beats from beat_this" if beats else "no beat_this output"
    qa = []
    for s in shots:
        sid = s["id"]; e = (ed.get("shots") or {}).get(sid, {}); L = lab.get(sid, {}); d = R / "shots" / sid
        sv = (L.get("shotvl") or {}).get("labels") or {}; cb = L.get("camerabench") or {}; qw = (L.get("qwen") or {}).get("json") or {}
        labels = {k: {"value": v["label"], "conf": v["conf"], "source": "ShotVL-7B (option-prob)"} for k, v in sv.items()}
        if cb.get("top"): labels["camera_motion_camerabench"] = {"value": cb["top"][0], "conf": cb["top"][1], "source": "CameraBench qwen2.5-vl-7b-cam-motion VQAScore", "all": cb.get("scores")}
        mv = (sv.get("camera_movement") or {}).get("label")
        cam_zh = SEEDANCE_CAM.get(mv, "固定镜头") if mv else None
        g, trim = gen_plan(s["dur_s"]); cut = s["start_s"]; nb = min(beats, key=lambda b: abs(b - cut)) if beats else None
        ctrl = [n for n in ("control_depth.png", "control_pose.png", "layout_mask.png") if (d / n).exists()]
        regions = ((mp.get(sid) or {}).get("sam2") or {}).get("regions")
        zh = e.get("zh", ""); en = e.get("en", "")
        if cam_zh and cam_zh not in zh and e.get("mode") != "black": zh += f"（{cam_zh}）"
        sj = {"id": sid, "in_frame": s["in_frame"], "out_frame": s["out_frame"], "frames": s["frames"], "tc_in": s["tc_in"], "tc_out": s["tc_out"], "start_s": s["start_s"], "dur_s": s["dur_s"],
              "gen_dur_s": g if e.get("mode") != "black" else 0, "trim": trim if e.get("mode") != "black" else None, "characters": e.get("chars", []),
              "labels": labels, "action_timeline": qw.get("action_timeline"), "action_timeline_source": "Qwen2.5-VL-7B Tarsier-style prompt" if qw.get("action_timeline") else None,
              "vlm_caption": {k: qw.get(k) for k in ("subjects", "camera", "setting", "style", "prompt_en", "prompt_zh")} if qw else None,
              "color": s["color"], "blocking_sam2_regions": regions,
              "prompts": {"seedance_zh": (ed.get("style_zh", "") + "。" + zh) if zh else None, "seedance_en": (ed.get("style_en", "") + ". " + en) if en else None,
                          "comfy_en": (en + " " + ed.get("style_en", "")) if en else None, "comfy_zh": zh or None, "negative_zh": ed.get("neg_zh"), "negative_en": ed.get("neg_en"),
                          "source": "editorial (Grok Bot, from keyframes + v1 blueprint) + model camera keyword"},
              "mode": e.get("mode"), "refs": {"first": "ref_first.jpg", "mid": "ref_mid.jpg", "last": "ref_last.jpg", "clip": "ref_clip.mp4", "characters": [f"../../characters/{c}/ref_face.jpg" for c in e.get("chars", []) if (R / "characters" / c / "ref_face.jpg").exists()]},
              "controls": ctrl, "comfy_hint": {"model": "Wan2.2 I2V 14B / 混元 I2V", "fps": 16, "frames": 81 if g == 5 else 161, "resolution": "720x720 then upscale 1080", "controls_use": "depth→ControlNet/VACE depth, pose→DWPose/VACE pose, ColorMatch target=ref_mid.jpg"},
              "audio_sync": {"note": e.get("audio"), "cut_s": cut, "nearest_beat_s": nb, "cut_minus_beat_ms": round((cut - nb) * 1000) if nb is not None else None},
              "qa": {"dur_frames_exact": s["frames"], "max_dur_err_s": 0.034, "palette_dE2000_max": 8, "luma_abs_err_max": 5, "layout_mask_IoU_min": 0.6 if "layout_mask.png" in ctrl else None, "face_id_cosine_min": 0.6 if e.get("chars") else None, "camera_motion_must": mv}}
        jdump(sj, d / "shot.json"); qa.append({"shot": sid, **sj["qa"]})
    jdump({"checks": qa, "global": ["总时长 = %.3fs（%d 帧 @%sfps）" % (meta["duration_s"], meta["frames"], meta["fps"]), "切点与 edl/timeline.edl 完全一致", "A 的簪子：S01–S03 在头上，S04 掉落，S06 起不在头上", "S01/S06/S08 机位一致（同一构图，可用 S01 ref_first 做布局参考）", "文字层按 text_overlays.json 后期叠加，生成时不要文字", "对白与 audio/dialogue.srt 对齐（误差 ≤80ms）"], "how": "dE2000 用 colour-science 比较 5 色板；IoU 用 SAM2 掩码；face_id 用任意人脸识别 embedding（如 insightface）"}, R / "qa" / "checklist.json")
    jdump({"items": ed.get("text_overlays", []), "canvas": meta["size"], "coords": "bbox_norm = [x0,y0,x1,y1] in 0–1 of frame"}, R / "text_overlays.json")
    jdump({"title": title, "version": VERSION, "made": time.strftime("%F %T"), "fps": meta["fps"], "size": meta["size"], "frames": meta["frames"], "duration_s": meta["duration_s"], "aspect": "1:1",
           "shots": [s["id"] for s in shots], "cuts": det.get("consensus"), "detectors": {k: det.get(k) for k in ("pyscenedetect_content", "pyscenedetect_adaptive")} | {"transnetv2_cuts": (det.get("transnetv2") or {}).get("cuts")},
           "style_prefix": {"zh": ed.get("style_zh"), "en": ed.get("style_en")}, "negative": {"zh": ed.get("neg_zh"), "en": ed.get("neg_en")},
           "characters": {k: {"name_zh": v.get("name_zh"), "card": f"characters/{k}/card.json"} for k, v in (ed.get("characters") or {}).items()},
           "audio": {n: f"audio/{n}" for n in sorted(os.listdir(R / "audio"))} if (R / "audio").exists() else {}, "grade": "grade/look.cube", "edl": "edl/timeline.edl", "text_overlays": "text_overlays.json", "qa": "qa/checklist.json",
           "tools": jload(W / "_work" / "tools.json", {})}, R / "manifest.json")

def main():
    ap = argparse.ArgumentParser(); ap.add_argument("video"); ap.add_argument("outdir"); ap.add_argument("--gpu", default=GPU)
    ap.add_argument("--stages", default="detect,shots,labels,maps,audio,grade,edl,package"); ap.add_argument("--title", default="lapian")
    a = ap.parse_args(); globals()["GPU"] = a.gpu.rstrip("/")
    v, W = Path(a.video).resolve(), Path(a.outdir).resolve(); (W / "_work").mkdir(parents=True, exist_ok=True); st = set(a.stages.split(","))
    meta = probe(v); jdump(meta, W / "_work" / "probe.json")
    cuts = stage_detect(v, W, meta) if "detect" in st else jload(W / "_work" / "detect_raw.json")["consensus"]
    shots = stage_shots(v, W, meta, cuts) if "shots" in st else jload(W / "_work" / "shots_base.json")
    if "labels" in st: stage_labels(W, shots)
    if "maps" in st: stage_maps(W, shots)
    if "audio" in st: stage_audio(v, W)
    if "grade" in st: stage_grade(W, shots)
    if "package" in st: stage_package(W, meta, shots, a.title)
    if "edl" in st: stage_edl(W, shots, meta, a.title); contact_sheet(W, shots, W / "contact_sheet.jpg")
    old = jload(W / "_work" / "errors.json", {}); old.update({k: {"err": e, "at": time.strftime("%F %T")} for k, e in ERR.items()}); jdump(old, W / "_work" / "errors.json")
    print("done; errors:", list(ERR))
if __name__ == "__main__": main()
