#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""钟馗夜馒 V2 <-> V3 迭代对比页生成器（idempotent, 按 ab-compare-html 蓝图）"""
import json, os, glob, re, shutil, subprocess, datetime, html

BASE = "/data/workspace/kais-hermes-skills/skills/kais-movie-pipeline/episodes"
V = {"V2": os.path.join(BASE, "ep-zhongkui-v2-ep01"),
     "V3": os.path.join(BASE, "ep-zhongkui-v3-ep01")}
OUT = "/home/kai/shared/2026-08-27/zhongkui-v2v3-comparison-tmp"
OUTPAGE = "/home/kai/shared/2026-08-27/zhongkui-v2v3-compare"
ISO = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}")

for d in (OUTPAGE,):
    os.makedirs(os.path.join(OUTPAGE, "m"), exist_ok=True)
os.makedirs(OUT, exist_ok=True)

# ---------- state extraction ----------
def load(ep):
    return json.load(open(os.path.join(V[ep], ".pipeline-state.json")))

def collect_ts(entry):
    found = []
    def walk(o):
        if isinstance(o, dict):
            for x in o.values(): walk(x)
        elif isinstance(o, list):
            for x in o: walk(x)
        elif isinstance(o, str) and ISO.match(o): found.append(o)
    walk(entry); return sorted(found)

def durations(ep):
    s = load(ep); seq = []
    for k, pv in s.get("phases", {}).items():
        if isinstance(pv, dict):
            tss = collect_ts(pv)
            if tss: seq.append((datetime.datetime.fromisoformat(min(tss)), k))
    seq.sort()
    out, prev = {}, None
    for t, k in seq:
        out[k] = None if prev is None else round((t - prev).total_seconds() / 60, 1)
        prev = t
    return out

D2, D3 = durations("V2"), durations("V3")
PHASES = [p for p in D3.keys()] 
allp = list(dict.fromkeys(list(D2.keys()) + list(D3.keys())))

STATE = {e: load(e) for e in V}
def anchor(ep, probe):
    best = None
    for pk, pv in STATE[ep].get("phases", {}).items():
        if isinstance(pv, dict):
            ms = re.findall(r'"%s":\s*([0-9.]+)' % probe, json.dumps(pv))
            if ms: best = (pk, float(ms[-1]))
    return best

A = {}
for probe in ("total_score", "fit_score", "consistency_score", "overall_score"):
    A[probe] = {"V2": anchor("V2", probe), "V3": anchor("V3", probe)}

# ---------- media ----------
from PIL import Image
def thumb(src, dst, maxw=900, q=82):
    try:
        im = Image.open(src);
        if im.mode != "RGB": im = im.convert("RGB")
        w, h = im.size
        if w > maxw: im = im.resize((maxw, int(h * maxw / w)))
        im.save(dst, "JPEG", quality=q)
        return True
    except Exception:
        return False

def fcp(a, b):
    try: shutil.copy2(a, b); os.chmod(b, 0o644); return True
    except Exception: return False

M = lambda name: "m/" + name
med_manifest = []

# masters
mast = {}
for e in V:
    g = glob.glob(V[e] + "/assets/P13/master.mp4")
    if g:
        dst = f"{e.lower()}_master.mp4"
        fcp(g[0], os.path.join(OUTPAGE, "m", dst))
        r = subprocess.run(["ffprobe","-v","quiet","-show_entries","format=duration","-of","csv=p=0",g[0]],capture_output=True,text=True)
        mast[e] = {"src": M(dst), "dur": float(r.stdout.strip() or 0)}
        med_manifest.append(dst)

# per-shot pairing: V2 final-render channel vs V3 segment channel
v2shots = {os.path.basename(p): p for p in glob.glob(V["V2"] + "/assets/P11/S*_B*.mp4")}
v3shots = {os.path.basename(p): p for p in glob.glob(V["V3"] + "/assets/P11a5/segments/S*_B*.mp4")}
pairs = sorted(set(v2shots) & set(v3shots))
v2_only = sorted(set(v2shots) - set(v3shots))
v3_only = sorted(set(v3shots) - set(v2shots))
PAIRS = []
for sid in pairs:
    d2 = f"v2_{sid}"; d3 = f"v3_{sid}"
    ok1 = fcp(v2shots[sid], os.path.join(OUTPAGE,"m",d2))
    ok2 = fcp(v3shots[sid], os.path.join(OUTPAGE,"m",d3))
    if ok1 and ok2:
        PAIRS.append({"sid": sid[:-4], "a": M(d2), "b": M(d3)})
        med_manifest += [d2, d3]

UNIQ = []
for tag, dd, pfx in (("V2", v2_only, "v2u"), ("V3", v3_only, "v3u")):
    srcmap = v2shots if tag == "V2" else v3shots
    for sid in dd[:8]:
        dst = f"{pfx}_{sid}"
        if fcp(srcmap[sid], os.path.join(OUTPAGE,"m",dst)):
            UNIQ.append({"tag": tag, "sid": sid[:-4], "src": M(dst)})
            med_manifest.append(dst)

# scene images S07
def collect_scene(ep):
    root = os.path.join(V[ep], "assets", "S07")
    out = {}
    for p in glob.glob(root + "/**/*.png", recursive=True) + glob.glob(root + "/**/*.jp*g", recursive=True):
        rel = os.path.relpath(p, root)
        key = rel.replace("\\", "/")
        if key not in out: out[key] = p
    return out
S2, S3 = collect_scene("V2"), collect_scene("V3")
scene_pairs, scene_miss, uniq_imgs = [], [], []
common_keys = sorted(set(S2) & set(S3))
for key in common_keys:
    if key.endswith(".jpg") or os.path.getsize(S2[key]) < 30000: continue
    dst = "v2_" + key.replace("/", "_"); dst2 = "v3_" + key.replace("/", "_")
    if os.path.splitext(dst)[1] == ".png": dst = os.path.splitext(dst)[0]+".jpg"; dst2 = os.path.splitext(dst2)[0]+".jpg"
    ok1 = thumb(S2[key], os.path.join(OUTPAGE,"m",dst)); ok2 = thumb(S3[key], os.path.join(OUTPAGE,"m",dst2))
    if ok1 and ok2:
        scene_pairs.append({"key": key, "a": M(dst), "b": M(dst2)})
        med_manifest += [dst, dst2]
for key in sorted(set(S2)-set(S3)):
    dst = "v2only_" + key.replace("/","_")
    if thumb(S2[key], os.path.join(OUTPAGE,"m",dst)): uniq_imgs.append({"tag":"V2","key":key,"src":M(dst)})
for key in sorted(set(S3)-set(S2))[:12]:
    dst = "v3only_" + key.replace("/","_")
    if thumb(S3[key], os.path.join(OUTPAGE,"m",dst)): uniq_imgs.append({"tag":"V3","key":key,"src":M(dst)})

# turnarounds + storyboard
TA2 = {os.path.basename(p): p for p in glob.glob(V["V2"]+"/assets/turnaround_sheets/*.png")+glob.glob(V["V2"]+"/assets/turnaround_final/*.png")}
TA3 = {os.path.basename(p): p for p in glob.glob(V["V3"]+"/assets/turnaround_final/*.png")+glob.glob(V["V3"]+"/assets/Tier0/**/*.png", recursive=True)[:6]}
ta_pairs = []
for k in sorted(set(TA2)&set(TA3))[:4]:
    a="tav2_"+k; b="tav3_"+k
    if thumb(TA2[k],os.path.join(OUTPAGE,"m",a),700) and thumb(TA3[k],os.path.join(OUTPAGE,"m",b),700):
        ta_pairs.append({"key":k,"a":M(a),"b":M(b)})
SB2 = glob.glob(V["V2"]+"/assets/storyboard_sheets/*.png")
SB3 = glob.glob(V["V3"]+"/assets/storyboard_sheets/*.png")

def csize(pat_map):
    tot=0
    for p in pat_map.values(): tot+=os.path.getsize(p)
    return tot

vid_mb = sum(os.path.getsize(os.path.join(OUTPAGE,"m",f)) for f in med_manifest if f.endswith(".mp4"))/1e6
tot2 = sum(v for v in D2.values() if v); tot3 = sum(v for v in D3.values() if v)
NOW = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")

def fmt(v, unit="min"):
    return "—" if v is None else (f"{v:.0f}{unit}" if unit=="min" else str(v))

rows_dur = []
mx = 1
for p in allp:
    a,b = D2.get(p), D3.get(p)
    mx = max(mx, a or 0, b or 0)
for p in allp:
    a,b = D2.get(p), D3.get(p)
    if a is None and b is None: continue
    dlt = "" 
    if a is not None and b is not None:
        d = b - a
        cls = "up" if d < -1 else ("down" if d > 1 else "flat")
        sign = "+" if d>0 else ""
        dlt = f'<td class="{cls}">{sign}{d:.0f}m</td>' if abs(d)>=1 else '<td class="flat">~</td>'
    else:
        na = "<span class='dim'>新增</span>" if a is None else ""
        nb = "<span class='dim'>移除</span>" if b is None else ""
        dlt = f"<td></td>"
    ca = f'{(a or 0)/mx*100:.0f}' ; cb = f'{(b or 0)/mx*100:.0f}'
    note=""
    if (a or 0)>240: note="含夜间挂机/人工等待窗"
    wa = a if a is not None else "—"; wb = b if b is not None else "—"
    rows_dur.append(f"""<tr><td class="mono">{html.escape(p)}</td>
      <td><div class="bar-wrap"><div class="bar v1" style="width:{ca}%"></div><span>{wa}</span></div></td>
      <td><div class="bar-wrap"><div class="bar v2" style="width:{cb}%"></div><span>{wb}</span></div></td>
      {dlt}<td class="dim">{note}</td></tr>""")

def anchor_row(name, probe, better_up=True, unit=""):
    v2r, v3r = A[probe]["V2"], A[probe]["V3"]
    a = f"{v2r[1]:g}{unit}" if v2r else "—"
    b = f"{v3r[1]:g}{unit}" if v3r else "—"
    cls = "flat"; arrow="→"
    if v2r and v3r:
        dv = v3r[1]-v2r[1]
        good = (dv>0.005) if better_up else (dv<-0.005)
        bad  = (dv<-0.005) if better_up else (dv>0.005)
        if abs(dv)<=0.005: cls="flat"; arrow=f"≈ ({dv:+.2f})"
        elif good: cls="up"; arrow=f"↑ (+{dv:.2f})" if dv>0 else f"↑ ({dv:.2f})"
        elif bad: cls="down"; arrow=f"↓ ({dv:+.2f})"
    src2 = v2r[0] if v2r else ""; src3 = v3r[0] if v3r else ""
    nt = (f"V2@{src2}" + (f" · V3@{src3}" if src3 and src3!=src2 else ""))
    return f"<tr><td>{name}</td><td class='mono'>{a}</td><td class='{cls}'>{b}</td><td class='{cls}'>{arrow}</td><td class='dim'>{html.escape(nt)}</td></tr>"

anchor_html = "".join([
    anchor_row("P03 剧本审计 total_score","total_score"),
    anchor_row("P01 hook fit_score","fit_score"),
    anchor_row("P08 场景一致性 consistency_score","consistency_score"),
    anchor_row("P15 holistic overall_score","overall_score"),
])

counts = [
 ("成片 master.mp4", f"27MB / {mast['V2']['dur']:.0f}s", f"15MB / {mast['V3']['dur']:.0f}s"),
 ("逐镜成片数（终渲通道）", f"{len(v2shots)} 镜", f"{len(v3shots)} 镜"),
 ("场景节点", "6 幕（S01–S06）", "5 幕（S01–S05，S06 并入收束）"),
 ("条件帧 PNG（P11 spec）", len(glob.glob(V['V2']+'/assets/P11/**/*.png',recursive=True)), len(glob.glob(V['V3']+'/assets/P11/**/*.png',recursive=True))),
 ("storyboard sheets", len(SB2), len(SB3)),
 ("turnaround 池", f"{len(TA2)} 张", f"{len(TA3)} 张"),
 ("管线墙钟 Σ耗时", fmt(tot2), fmt(tot3)),
]
counts_html = "".join(f"<tr><td>{n}</td><td class='mono'>{a}</td><td class='mono' style='color:#58a6ff'>{b}</td></tr>" for n,a,b in counts)

video_pair_html = ""
for pr in PAIRS:
    video_pair_html += f"""
<div class="pairrow">
  <div class="card"><div class="cardhead"><b class="mono">{pr['sid']}</b><span class="tag v2b">V2 成片</span></div>
    <video controls preload="metadata" src="{pr['a']}"></video></div>
  <div class="card"><div class="cardhead"><b class="mono">{pr['sid']}</b><span class="tag v3">V3 终版</span></div>
    <video controls preload="metadata" src="{pr['b']}"></video></div>
</div>"""

uniq_vid_html = "".join(f"""<div class="ucard"><div class="cardhead"><span class="tag {'v2b' if u['tag']=='V2' else 'v3'}">{u['tag']} 独有</span><b class="mono">{u['sid']}</b></div><video controls preload="metadata" src="{u['src']}"></video></div>""" for u in UNIQ)

scene_group = {}
for sp in scene_pairs:
    g = sp["key"].split("/")[0]
    scene_group.setdefault(g, []).append(sp)
scene_html = ""
for g, items in sorted(scene_group.items()):
    cells = []
    for sp in items[:6]:
        nm = os.path.splitext(os.path.basename(sp["key"]))[0]
        cells.append(f"""<div class="pgrid"><div><span class="mini dim">{nm}</span><div class="pcell"><img loading="lazy" src="{sp['a']}"></div></div>
<div><span class="mini dim">V3</span><div class="pcell"><img loading="lazy" src="{sp['b']}"></div></div></div>""")
    scene_html += f"<h3 class='sub'>🏞 {g}</h3><div class='sceneblock'>{''.join(cells)}</div>"

uniq_img_html = "".join(f"<figure><img loading='lazy' src='{u['src']}'><figcaption><span class='tag {'v2b' if u['tag']=='V2' else 'v3'}'>{u['tag']}</span> <span class='mini mono'>{u['key'][:40]}</span></figcaption></figure>" for u in uniq_imgs[:16])

ta_html = "".join(f"""<div class="pairrow narrow"><div class="card"><div class="cardhead"><span class="tag v2b">V2</span><span class="mini mono">{t['key']}</span></div><img loading="lazy" src="{t['a']}"></div>
<div class="card"><div class="cardhead"><span class="tag v3">V3</span><span class="mini mono">{t['key']}</span></div><img loading="lazy" src="{t['b']}"></div></div>""" for t in ta_pairs)

sb_thumbs = ""
for i,p in enumerate(SB2[:2]):
    n=f"sb2_{i}.jpg"; thumb(p,os.path.join(OUTPAGE,"m",n),800); sb_thumbs+=f"<div class='card'><div class='cardhead'><span class='tag v2b'>V2</span></div><img loading='lazy' src='m/{n}'></div>"
for i,p in enumerate(SB3[:2]):
    n=f"sb3_{i}.jpg"; thumb(p,os.path.join(OUTPAGE,"m",n),800); sb_thumbs+=f"<div class='card'><div class='cardhead'><span class='tag v3'>V3</span></div><img loading='lazy' src='m/{n}'></div>"

HTML = f"""<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>钟馗夜馒 · V2 → V3 迭代对比</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}} body{{background:#0d1117;color:#c9d1d9;font-family:-apple-system,'PingFang SC','Noto Sans SC',sans-serif;line-height:1.45}}
a{{color:#58a6ff}}
.hdr{{position:sticky;top:0;z-index:100;background:#161b22ee;border-bottom:1px solid #30363d;padding:10px 18px;backdrop-filter:blur(6px)}}
.hdr h1{{font-size:17px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}}
.chip{{font-size:11px;padding:2px 9px;border-radius:99px;font-weight:600}}
.chip.ok{{background:#23863633;color:#3fb950}}.chip.run{{background:#1f6feb33;color:#58a6ff}}.chip.warn{{background:#bb800933;color:#d29922}}
.sub2{{color:#8b949e;font-size:12px;margin-top:2px}}
section{{max-width:1500px;margin:0 auto;padding:18px 14px}}
h2{{font-size:15px;margin:14px 0 10px;color:#e6edf3;border-left:3px solid #1f6feb;padding-left:8px}}
table{{width:100%;border-collapse:collapse;font-size:13px;background:#161b22;border-radius:8px;overflow:hidden}}
th,td{{padding:7px 9px;border-bottom:1px solid #21262d;text-align:left;vertical-align:middle}}
th{{background:#1c2128;color:#8b949e;font-weight:600;font-size:12px}}
.mono{{font-family:ui-monospace,Menlo,monospace;font-size:12px}}
.dim{{color:#8b949e;font-size:12px}}
.up{{color:#3fb950;font-weight:600}}.down{{color:#f85149;font-weight:600}}.flat{{color:#d29922}}
.bar-wrap{{display:flex;align-items:center;gap:7px;min-width:190px}} .bar{{height:10px;border-radius:3px}} .bar.v1{{background:#8957e5}} .bar.v2{{background:#1f6feb}}
.tag{{font-size:10px;padding:1px 7px;border-radius:99px;margin-left:auto;font-weight:700}}
.tag.v2b{{background:#8957e533;color:#ab7df8}} .tag.v3{{background:#1f6feb33;color:#58a6ff}}
.pairrow{{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px}}
.pairrow.narrow{{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}}
.card{{background:#161b22;border:1px solid #30363d;border-radius:10px;overflow:hidden}}
.cardhead{{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px;background:#1c2128}}
.cardhead b{{margin-right:auto}}
video,img{{width:100%;display:block;background:#000}}
.card img{{object-fit:contain;max-height:420px}}
.ucard{{background:#161b22;border:1px solid #30363d;border-radius:10px;overflow:hidden;min-width:230px}}
.vgrid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px}}
.sceneblock{{display:flex;flex-wrap:wrap;gap:10px}}
.pgrid{{width:calc(25% - 8px);min-width:210px}} .pcell img{{aspect-ratio:16/9;object-fit:cover;border-radius:6px;border:1px solid #30363d}}
.mini{{font-size:10px}}
footer{{max-width:1500px;margin:0 auto;padding:16px 14px 40px;color:#8b949e;font-size:12px}}
.big{{font-size:26px;font-weight:800;color:#e6edf3}}
.kpis{{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:6px}}
.kpi{{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:12px}}
.kpi .lbl{{font-size:11px;color:#8b949e}}
@media(max-width:820px){{.pairrow,.pgrid{{grid-template-columns:1fr;width:100%}}}}
</style></head><body>

<div class="hdr"><h1>🥟 钟馗夜馒 EP01 · 迭代对比 <span class="chip ok">V2 已交付 82</span><span class="chip run">V3 今晨交付</span><span class="chip warn">同位镜头≠同画面</span>
</h1><div class="sub2">快照 {NOW} CST · 数据源 = 各版本 .pipeline-state.json + assets 实盘 · 主轴 V2(ep-zhongkui-v2-ep01) ↔ V3(ep-zhongkui-v3-ep01)，requirement.json 两侧完全一致 = 干净对照</div></div>

<section>
<div class="kpis">
 <div class="kpi"><div class="lbl">P03 剧本分</div><div class="big up">0.82→0.88</div></div>
 <div class="kpi"><div class="lbl">成片时长</div><div class="big">55s→33s</div></div>
 <div class="kpi"><div class="lbl">节拍数(6幕→5幕)</div><div class="big">18→20</div></div>
 <div class="kpi"><div class="lbl">V3 渲染引擎</div><div class="big">H3 ref2va<br><span style="font-size:11px;color:#8b949e">物化硬门(fb003)首批受益</span></div></div>
</div></section>

<section><h2>S1 ⏱ 逐 Phase 耗时（UTC 墙钟相邻差，紫=V2 蓝=V3，分钟）</h2>
<table><tr><th>phase</th><th>V2</th><th>V3</th><th>Δ</th><th>备注</th></tr>{''.join(rows_dur)}</table></section>

<section><h2>S2 🎯 质量锚点</h2>
<table><tr><th>锚点</th><th>V2</th><th>V3</th><th>判定</th><th>来源 phase</th></tr>{anchor_html}</table>
<div class="dim" style="margin-top:6px">V3 的 holistic audit 因 LLM 超时降级跳过（runner 日志实录），overall 只有单侧可比。</div></section>

<section><h2>S3 📦 资产计数</h2>
<table><tr><th>资产类</th><th>V2</th><th>V3</th></tr>{counts_html}</table></section>

<section><h2>S4 🎬 成片 A/B + 同位节拍视频对</h2>
<div class="pairrow">
 <div class="card"><div class="cardhead"><b>MASTER 全片</b><span class="tag v2b">V2 · 55s</span></div><video controls preload="metadata" src="{mast['V2']['src']}"></video></div>
 <div class="card"><div class="cardhead"><b>MASTER 全片</b><span class="tag v3">V3 · 33s</span></div><video controls preload="metadata" src="{mast['V3']['src']}"></video></div>
</div>
<p class="dim" style="margin:4px 0 12px">⚠ 免责：同 ID 仅代表同场景内第 N 个节拍位 —— 两版剧本独立重写（6幕18拍 vs 5幕20拍），同位号画面内容不同属预期。全部默认有声。</p>
{video_pair_html}</section>

<section><h2>🎞 单侧独有节拍（剧本差异区）</h2><div class="vgrid">{uniq_vid_html or '<span class="dim">无</span>'}</div></section>

<section><h2>S5 🏞 场景图对（assets/S07 同名配对，上V2下V3）</h2>{scene_html or '<span class="dim">无同名场景对</span>'}</section>

<section><h2>S6 🧍 Turnaround / 分镜板</h2>
<h3 class="sub">Turnaround 配对</h3>{ta_html or '<span class="dim">V3 未建 turnaround_sheets 池（Tier0 新体系），无可同位配对</span>'}
<h3 class="sub">Storyboard sheets</h3><div class="pairrow narrow">{sb_thumbs}</div></section>

<section><h2>S7 ➕ 单侧独有图像资产</h2><div class="vgrid" style="grid-template-columns:repeat(auto-fill,minmax(200px,1fr))">{uniq_img_html or '<span class="dim">无</span>'}</div></section>

<footer>
数据源：<br>
· V2 = {V['V2']}<br>· V3 = {V['V3']}<br>
视频总体积 ~{vid_mb:.0f}MB（本页 m/ 目录）。快照静态页——后续迭代说「刷新对比html」会覆盖同一文件。<br>
背景状态：另一在跑项目《云朵剪裁师》(ep-fantasy-cute-comedy-30s) 至快照时处于 P06，与本对比无关；ep-zhongkui-v4-forge 仅有 forge-manifest 文本产物，未入本页。
</footer>
</body></html>"""

open(os.path.join(OUTPAGE, "compare.html"), "w").write(HTML)
os.system(f"chmod -R a+rX {OUTPAGE}")
print(json.dumps({"page": OUTPAGE+"/compare.html",
  "video_pairs": len(PAIRS), "uniq_vid": len(UNIQ),
  "scene_pairs": len(scene_pairs), "uniq_imgs": len(uniq_imgs),
  "ta_pairs": len(ta_pairs), "media_files": len(med_manifest),
  "vid_mb": round(vid_mb)}, ensure_ascii=False))
