#!/usr/bin/env python3
# 4-engine blind page builder v2 (gpunoise 重跑 + fasth3-V2 补测并入).
# v1 (build_blind_page_gpunoise.py) 的 a/b/c 槽位映射原样保留 (blind_map.json 断言相等),
# fasth3-V2 追加为每 case 下一空槽 (xjh 组=丁, val 组=丙) — Kai 已投票不受影响。
# kais-html §0: blind slots + loudness norm (same gain/case, recompute over ALL arms)
# + CAL pair + reveal (0914 dual hard standard) + export + self-checks.
import base64, hashlib, json, os, random, re, subprocess, sys

BASE2 = "/home/kai/shared/2026-09-15/gpunoise_rerun3"          # v1 三臂台账
FH    = "/home/kai/shared/2026-09-16/fasth3v2_probe"           # fasth3-V2 补测台账
BASE  = "/home/kai/shared/2026-09-14/ab4eng_6case"             # cases.json 复用
OUT   = "/home/kai/shared/2026-09-15/page"
os.makedirs(OUT, exist_ok=True)

ARMS  = ["rh", "vdn", "lxv", "fasth3"]
ZH    = {"a": "甲", "b": "乙", "c": "丙", "d": "丁"}
ENGINES = {
 "rh": "rh-v4 · 蒸馏4步快档",
 "vdn": "vdn-8 · VDN 8步",
 "lxv": "lightx2v-8-768p · LXV 8步",
 "fasth3": "fasth3-V2 · 8步蒸馏int8剪枝",
}
ARMKEY = {"rh": "rh_v4", "vdn": "vdn_8", "lxv": "lightx2v_8_768p", "fasth3": "fasth3"}
CASES = {
 "xjh_S2_2": {"t": "S22 小江湖·深夜藏子", "sub": "快推 MS · 4.0s · 1344×768 · seed=2026091406",
   "g": "看：推镜速度感、把阿毛塞进树洞的手部遮挡逻辑、长袍旋摆、阿毛眼神消失进阴影的瞬间。"},
 "xjh_S2_3": {"t": "S23 小江湖·撞角碎腿", "sub": "手持 FS 高动 · 6.0s · 1344×768 · seed=2026091404",
   "g": "看：铁角撞腿的接触物理、碎腿飞散方向、墨绿毒液飞溅冒烟、后飞惯性。"},
 "xjh_S3_2": {"t": "S32 小江湖·手插泥土", "sub": "静帧 INSERT 特写 · 4.5s · 1344×768 · seed=2026091402",
   "g": "看：指缝簌簌落土的颗粒感、龟裂分形与黑脉纹路的呼应、静帧里的微动（呼吸感）。"},
 "xjh_S4_2": {"t": "S42 小江湖·指尖黑化", "sub": "静帧 CU · 4.5s · 1344×768 · seed=2026091401",
   "g": "看：指尖色变渐变（墨绿→灰黑）、黑脉爬到指尖的过程、掌中甲壳虹彩、上缘面部神色。"},
 "xjh_S5_3": {"t": "S53 小江湖·对视数数", "sub": "MCU 微表情 · 6.5s · 1344×768 · seed=2026091403",
   "g": "看：张嘴数数的口型节奏、阿毛兴奋闭眼微表情、莓汁嘴边、花青金色瞳孔的反光、同高对视构图。"},
 "xjh_S6_2": {"t": "S62 小江湖·升镜穿林", "sub": "Crane-up EWS 大场景 · 9.0s · 1344×768 · seed=2026091405",
   "g": "看：升镜连续性、穿树冠的遮挡过渡、森林逐层展开的景深、阿毛缩成小点的大小对比。"},
 "val_c05":  {"t": "C05 标准集·卡通跑车赛道", "sub": "中动 · 10.1s · 1280×704 · seed=2026091405",
   "g": "看：车身高光流动、轮胎旋转与地面接触、起步加速惯性、山丘绿植视差。"},
 "val_c07":  {"t": "C07 标准集·村街对白", "sub": "低动对白 · 14.9s档 · 1280×704 · seed=2026091407",
   "g": "看：口型与台词贴合、微表情、服饰纹样一致性、街景参照物是否稳定不漂。"},
 "val_c08":  {"t": "C08 标准集·全速追逐", "sub": "高动 · 14.9s档 · 1280×704 · seed=2026091408",
   "g": "看：全速奔跑四肢一致性、裙摆/水花物理、人物交错瞬间、急停惯性。"},
}
SHORT = {"xjh_S2_2": "S22", "xjh_S2_3": "S23", "xjh_S3_2": "S32", "xjh_S4_2": "S42",
         "xjh_S5_3": "S53", "xjh_S6_2": "S62", "val_c05": "C05", "val_c07": "C07", "val_c08": "C08"}
KEY = "gpnoise_v1"   # 保持 v1 localStorage key — 老 a/b/c 投票延续有效

# 0) 台账合并: v1 三臂 + fasth3-V2 补测
recs = {}
for line in open(f"{BASE2}/arms.jsonl"):
    r = json.loads(line)
    recs[(r["case"], r["arm"])] = r
for line in open(f"{FH}/arms.jsonl"):
    r = json.loads(line)
    assert r["arm"] == "fasth3"
    assert r.get("ok"), f"fasth3 {r['case']} not ok"
    recs[(r["case"], "fasth3")] = r
for line in open("/home/kai/shared/2026-09-16/vdn_val_probe/arms.jsonl"):
    r = json.loads(line)
    assert r["arm"] == "vdn_8" and r.get("ok")
    recs[(r["case"], "vdn_8")] = r  # 覆盖 v1 缺失项 (v1 map 无此键, 此处为 val 补测真值)

# 1) 槽位: v1 blind_map 原样保留 + fasth3 追加下一空槽
v1map = json.load(open(f"{BASE2}/blind_map.json"))["mapping"]
ALLSLOTS = ["a", "b", "c", "d"]
mapping = {}
problems = []
for case in CASES:
    m1 = v1map.get(case)
    if not m1:
        problems.append(f"{case}: v1 blind_map 缺失")
        continue
    # 断言 v1 槽位映射与台账一致 (投票连续性保证)
    for s, d in m1.items():
        r = recs.get((case, ARMKEY[d["eng"]]))
        assert r and r.get("ok"), f"{case}{s}: v1 arm {d['eng']} 台账不符"
    m = {s: dict(d) for s, d in m1.items()}
    # v2 起 blind_map 已含 fasth3 槽; v3 基底直接沿用, 已在场则跳过
    if "fasth3" not in [d["eng"] for d in m.values()]:
        nxt = next(s for s in ALLSLOTS if s not in m)
        r = recs[(case, "fasth3")]
        m[nxt] = {"eng": "fasth3", "wall": round(r["wall_s"]),
                  "qw": r.get("queue_wait_s"), "rs": r.get("render_s"),
                  "src": r["videoUrl"]}
    # vdn val 补测 (0916 226f) 并入: v1 该臂缺席, fasth3 之后的下一空槽
    if (case, "vdn_8") in recs and "vdn" not in [d["eng"] for d in m.values()]:
        nxt2 = next(s for s in ALLSLOTS if s not in m)
        rv = recs[(case, "vdn_8")]
        m[nxt2] = {"eng": "vdn", "wall": round(rv["wall_s"]),
                   "qw": rv.get("queue_wait_s"), "rs": rv.get("render_s"),
                   "src": rv["videoUrl"], "clamp": "226f(241f边界钳制)"}
    mapping[case] = m
if problems:
    print("PROBLEMS:", problems)
    sys.exit(3)

# 2) 响度归一: 增益按全臂 max 重算 (同 case 同增益); 与 v1 增益一致则老文件复用
def max_vol(f):
    r = subprocess.run(["ffmpeg", "-i", f, "-af", "volumedetect", "-f", "null", "-"],
                       capture_output=True, text=True)
    m = re.search(r"max_volume: ([-\d.]+) dB", r.stderr)
    return float(m.group(1)) if m else 0.0

for case in CASES:
    mvs = {s: max_vol(d["src"]) for s, d in mapping[case].items()}
    if max(mvs.values()) < -55:
        gain = 0.0
    else:
        gain = round(-3.0 - max(mvs.values()), 2)
    old_gain = next(iter(v1map[case].values())).get("gain")
    for s, d in mapping[case].items():
        out = f"{OUT}/bt_{case}_{s}.mp4"
        need = not (os.path.exists(out) and os.path.getsize(out) > 100000
                    and old_gain is not None and abs(gain - old_gain) < 0.05)
        if need:
            r = subprocess.run(["ffmpeg", "-y", "-i", d["src"], "-af", f"volume={gain}dB",
                                "-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
                                "-movflags", "+faststart", out], capture_output=True, text=True)
            assert r.returncode == 0, f"ffmpeg fail {case}{s}: {r.stderr[-300:]}"
        d["gain"] = gain
json.dump({"mapping": mapping, "key": KEY, "v": 2},
          open(f"{BASE2}/blind_map.json", "w"), ensure_ascii=False, indent=1)

# 3) reference thumbs (已有则跳过)
from PIL import Image
for case, cd in CASES.items():
    refs = json.load(open(f"{BASE}/cases.json"))[case]["refs"][:3]
    for i, rp in enumerate(refs):
        out = f"{OUT}/ref_{case}_{i}.jpg"
        if not os.path.exists(out):
            im = Image.open(rp).convert("RGB")
            im.thumbnail((320, 320), Image.LANCZOS)
            im.save(out, quality=82)

# 4) probe + panels
def probe(p):
    r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_packets",
                        "-show_entries", "stream=width,height,nb_read_packets", "-of", "csv=p=0", p],
                       capture_output=True, text=True).stdout.strip()
    a = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries",
                        "stream=codec_name", "-of", "csv=p=0", p], capture_output=True, text=True).stdout.strip()
    return r, a

frame_notes = []
panels_html = {}
for case in CASES:
    parts = []
    fmax = 0
    probes = {}
    for s in mapping[case]:
        f = f"bt_{case}_{s}.mp4"
        (r, a) = probe(f"{OUT}/{f}")
        assert r and a == "aac", f"{f} spec {r} {a}"
        probes[s] = r
        try: fmax = max(fmax, int(r.split(",")[-1]))
        except Exception: pass
    for s in mapping[case]:
        f = f"bt_{case}_{s}.mp4"
        try:
            nf = int(probes[s].split(",")[-1])
            if fmax and fmax - nf > 1:
                frame_notes.append(f"{SHORT[case]}{ZH[s]}少{fmax-nf}帧(档位长度钳制)")
        except Exception:
            pass
        k = f"{SHORT[case]}{ZH[s]}"
        parts.append(f'''
    <div class="panel">
      <div class="armtag">{ZH[s]}<span class="badge">{CASES[case]["sub"].split(" · ")[-2]} · {CASES[case]["sub"].split(" · ")[-3]} · 原声</span><span class="cfg-tag">🧬 {ENGINES[mapping[case][s]["eng"]]}</span></div>
      <div class="vidwrap"><video src="{f}" loop preload="metadata" muted playsinline></video>
        <button class="fs-btn" onclick="fsGo(this.parentElement.querySelector('video'))">⛶ 全屏</button></div>
      <div class="metabar">终审:
        <span class="chips" data-k="{k}">''' + "".join(
            f'<button class="vbtn" data-v="{g}" onclick="setVote(\'{k}\',\'{g}\',this)">{g}</button>' for g in "优良中差"
        ) + '''</span></div>
    </div>''')
    panels_html[case] = "".join(parts)

# CAL pair = S32 slot c both sides (v1 口径不变)
cal_src = "bt_xjh_S3_2_c.mp4"
(cal_r, cal_a) = probe(f"{OUT}/{cal_src}")
assert cal_r and cal_a == "aac"
cal_panels = "".join(f'''
    <div class="panel">
      <div class="armtag">{ZH[s]}<span class="badge">校准片 · 两侧同片</span></div>
      <div class="vidwrap"><video src="{cal_src}" loop preload="metadata" muted playsinline></video>
        <button class="fs-btn" onclick="fsGo(this.parentElement.querySelector('video'))">⛶ 全屏</button></div>
      <div class="metabar">终审:
        <span class="chips" data-k="CAL{ZH[s]}">''' + "".join(
    f'<button class="vbtn" data-v="{g}" onclick="setVote(\'CAL{ZH[s]}\',\'{g}\',this)">{g}</button>' for g in "优良中差"
) + '''</span></div>
    </div>''' for s in ["a", "b"])

def group_html(gid, title, sub, panels, sound_labels, gridcls="quad"):
    return f'''
<h2 id="g_{gid}_t">{title} <small class="note">{sub}</small></h2>
<div class="toolbar" id="g_{gid}">
  <button class="primary act-play">▶ 同步播放</button>
  <button class="act-pause">⏸ 暂停</button>
  <button class="act-restart">⏮ 回到 0s</button>
  <div class="sep"></div>
  <span class="note">速度</span>
  <select class="rate"><option value="1">1×</option><option value="0.5">0.5×</option>
    <option value="0.25">0.25× 慢放</option><option value="2">2×</option></select>
  <div class="sep"></div>
  <span class="note">出声</span>
  <select class="snd"><option value="-1">静音</option>{sound_labels}</select>
  <div class="sep"></div>
  <button class="act-zoom">🔍 放大 2.2×</button>
</div>
<div class="{gridcls}" id="g_{gid}_row">{panels}</div>
'''

snd_opts = {c: "".join(f'<option value="{i}">{ZH[s]}</option>' for i, s in enumerate(mapping[c])) for c in CASES}
cal_snd = '<option value="0">甲</option><option value="1">乙</option>'

def gridcls_of(case):
    n = len(mapping[case])
    return "quad" if n == 4 else ("tri" if n == 3 else "duo")

groups = []
for case, cd in CASES.items():
    nrefs = len(json.load(open(f"{BASE}/cases.json"))[case]["refs"][:3])
    refs = "".join(f'<img class="refimg" src="ref_{case}_{i}.jpg">' for i in range(nrefs))
    groups.append(group_html(case, cd["t"], cd["sub"] + " · 同seed同refs · 0.25×慢放判读：" + cd["g"] + " · 参考图为对照锚（不作判题）",
                             panels_html[case], snd_opts[case], gridcls_of(case))
                  + f'<div class="refrow">{refs}</div>'
                  + f'<input class="cmt" id="cmt_{case}" placeholder="{SHORT[case]} 组意见（可选，写入总结串）">')
groups.append(group_html("cal", "CAL 校准组", "两侧同片（不计分母）。若判甲乙有明显差异 = 本轮判读作废，请重置状态后重评", cal_panels, cal_snd, gridcls="duo"))

# reveal (0914 dual hard standard) — fasth3 臂无锁事件 → 显示 (串行全程)
rev_rows = []
for case in CASES:
    for s in mapping[case]:
        d = mapping[case][s]
        k = f"{SHORT[case]}{ZH[s]}"
        if d["rs"] is None:
            tstr = f"{d['wall']}s <small class=\"note\">(串行全程)</small>"
        else:
            qws = f"{d['qw']:.0f}" if d["qw"] is not None else "-"
            rss = f"{d['rs']:.0f}" if d["rs"] is not None else "-"
            tstr = f"{d['wall']}s <small class=\"note\">(渲{rss}+等锁{qws})</small>"
        rev_rows.append(f'<tr id="rvrow_{k}"><td>{SHORT[case]} {ZH[s]}</td><td class="cfgcell"><b>{ENGINES[d["eng"]]}</b></td><td>{tstr}</td><td><span class="rv" id="rv_{k}">—</span><span class="yours" id="yours_{k}"></span></td></tr>')
reveal = ('<details id="reveal"><summary>🔓 揭晓（评完再看）</summary>'
          + '<div id="banners"></div>'
          + '<table><tr><th>槽位</th><th>引擎</th><th>渲染耗时</th><th>你的评分</th></tr>'
          + "".join(rev_rows) +
          '</table><p class="note">*uncontended 串行协议：v1 三臂 24 臂零锁等待（等锁均&lt;1s）；fasth3-V2 补测 9 臂同协议串行（无锁事件，全程即渲染）。'
          'CAL组两侧均为 S32丙 同片。同case各臂同seed、同refs、同链（ref2va · H3 原生音频）；响度组内归一（同增益 · 真峰≈-3dB）。'
          + ("帧数注：" + "；".join(frame_notes) + "。" if frame_notes else "")
          + '标准集 val 三 case 有一臂因 241f 边界门拒单缺席（三臂组，缺席臂不计分母）。'
          + 'fasth3-V2 于 09-16 同协议补测并入（丁/丙槽）。评分制式 优=3/良=2/中=1/差=0 求和排名。</p></details>')

ALLK = ",".join(f'"{SHORT[c]}{ZH[s]}"' for c in CASES for s in mapping[c])
CASESHORT = json.dumps(SHORT, ensure_ascii=False)
ENGS_JSON = json.dumps({c: {s: ENGINES[mapping[c][s]["eng"]] for s in mapping[c]} for c in CASES}, ensure_ascii=False)

JS = r'''
const KEY='__KEY__';
let V=JSON.parse(localStorage.getItem(KEY+'_v')||'{}');
let N=JSON.parse(localStorage.getItem(KEY+'_n')||'{}');
const ENGINE_ARMS=[__ALLK__];
const SHORT=__CASESHORT__;
const ENGS=__ENGS__;
const SCORE={'优':3,'良':2,'中':1,'差':0};
function clean(s){return (s||'').replace(/[\[=\|]/g,' ').replace(/\s+/g,' ').trim().slice(0,80);}
function setVote(k,g,btn){
  if(V[k]===g){delete V[k];}else{V[k]=g;}
  localStorage.setItem(KEY+'_v',JSON.stringify(V)); paint(); updateBar(); paintReveal();
}
function paint(){
  document.querySelectorAll('.chips').forEach(ch=>{
    const k=ch.dataset.k;
    ch.querySelectorAll('.vbtn').forEach(b=>{b.classList.toggle('active',V[k]===b.dataset.v);});
  });
}
document.addEventListener('input',e=>{
  if(e.target.classList&&e.target.classList.contains('cmt')&&e.target.id!=='ovr'){
    const c=e.target.id.replace('cmt_','');
    if(e.target.value)N[c]=e.target.value;else delete N[c];
    localStorage.setItem(KEY+'_n',JSON.stringify(N)); updateBar();
  }
  if(e.target.id==='ovr'){N._overall=e.target.value;localStorage.setItem(KEY+'_n',JSON.stringify(N));updateBar();}
});
function bestOf(cs){
  const slots=Object.keys(ENGS[cs]).map(s=>SHORT[cs]+({a:'甲',b:'乙',c:'丙',d:'丁'})[s]);
  const voted=slots.filter(k=>V[k]);
  if(!voted.length)return null;
  const mx=Math.max(...voted.map(k=>SCORE[V[k]]));
  const win=voted.filter(k=>SCORE[V[k]]===mx);
  return {win:win, label:V[win[0]], tie:win.length>1, full:voted.length===slots.length};
}
function paintReveal(){
  let bh='';
  for(const cs of Object.keys(ENGS)){
    const b=bestOf(cs);
    if(!b){bh+='<div class="bn bn-wait">⏳ '+SHORT[cs]+'：未评</div>';continue;}
    if(!b.full){bh+='<div class="bn bn-wait">⏳ '+SHORT[cs]+'：评了 '+b.win.length+'/'+Object.keys(ENGS[cs]).length+' 臂（全臂评完出结论）</div>';continue;}
    const names=b.win.map(k=>k.slice(-1)+' = '+ENGS[cs][({甲:'a',乙:'b',丙:'c',丁:'d'})[k.slice(-1)]]).join('　|　');
    bh+='<div class="bn '+(b.tie?'bn-tie':'bn-win')+'">🏆 '+SHORT[cs]+' 你评最高: '+names+(b.tie?' <em>（并列）</em>':'')+'</div>';
  }
  const bel=document.getElementById('banners'); if(bel)bel.innerHTML=bh;
  for(const k of ENGINE_ARMS.concat(['CAL甲','CAL乙'])){
    const el=document.getElementById('rv_'+k); if(!el)continue;
    const v=V[k];
    el.textContent=v||'—';
    el.className='rv '+(v==='优'?'rv3':v==='良'?'rv2':v==='中'?'rv1':v==='差'?'rv0':'');
    const row=document.getElementById('rvrow_'+k);
    if(row)row.classList.toggle('rvrow-voted',!!v);
    const y=document.getElementById('yours_'+k);
    if(y)y.textContent=v?('你的选择: '+v):'';
  }
}
function buildSummary(){
  const done=ENGINE_ARMS.filter(k=>V[k]).length;
  let parts=ENGINE_ARMS.map(k=>k+'='+(V[k]||'?'));
  const cal=['CAL甲','CAL乙'].map(k=>k+'='+(V[k]||'?'));
  let s='4引擎盲选 '+done+'/'+ENGINE_ARMS.length+': '+parts.join(' ')+' CAL: '+cal.join(' ');
  const notes=Object.keys(SHORT).filter(c=>N[c]).map(c=>SHORT[c]+'注['+clean(N[c])+']');
  if(notes.length)s+=' | '+notes.join(' ');
  s+=' | 总评:'+(N._overall?clean(N._overall):'—');
  return s;
}
function updateBar(){document.getElementById('sumbox').value=buildSummary();}
function doCopy(){
  const t=document.getElementById('sumbox').value;
  const b=document.querySelector('#copybtn'); const o=b.textContent;
  const ok=()=>{b.textContent='✅ 已复制';setTimeout(()=>b.textContent=o,2500);};
  if(window.isSecureContext&&navigator.clipboard&&navigator.clipboard.writeText){
    navigator.clipboard.writeText(t).then(ok,ok);
  }else{
    const ta=document.getElementById('sumbox'); ta.focus(); ta.select();
    let good=false; try{good=document.execCommand('copy');}catch(e){}
    ok(); if(!good)alert('请手动全选文本框复制');
  }
}
function fsGo(v){
  if(document.fullscreenElement){document.exitFullscreen();return;}
  (v.requestFullscreen||v.webkitRequestFullscreen||function(){}).call(v);
}
function setupGroup(gid){
  const root=document.getElementById('g_'+gid);
  const vids=Array.from(document.getElementById('g_'+gid+'_row').querySelectorAll('video'));
  let master=null;
  function adopt(m){master=m;root.querySelectorAll('.panel').forEach(p=>p.classList.remove('master-hint'));m.closest('.panel').classList.add('master-hint');syncTo(m);}
  function syncTo(m){vids.forEach(v=>{if(v!==m){
    try{if(Math.abs(v.currentTime-m.currentTime)>0.05)v.currentTime=m.currentTime;}catch(e){}
    if(m.paused){if(!v.paused)v.pause();}else if(v.paused)v.play().catch(()=>{});
  }});}
  root.querySelector('.act-play').onclick=()=>{const m=master||vids[0];m.play().catch(()=>{});adopt(m);};
  root.querySelector('.act-pause').onclick=()=>{(master||vids[0]).pause();adopt(master||vids[0]);};
  root.querySelector('.act-restart').onclick=()=>{vids.forEach(v=>{try{v.currentTime=0;}catch(e){}});adopt(master||vids[0]);};
  vids.forEach(v=>{
    ['play','pause','seeked'].forEach(ev=>v.addEventListener(ev,()=>{if(master===v)syncTo(v);}));
    v.addEventListener('click',()=>{adopt(v);if(v.paused)v.play().catch(()=>{});});
    v.addEventListener('dblclick',()=>fsGo(v));
  });
  setInterval(()=>{const m=master||vids[0];
    if(m&&!m.paused){vids.forEach(v=>{if(v!==m&&Math.abs(v.currentTime-m.currentTime)>0.08){try{v.currentTime=m.currentTime;}catch(e){}}});}
  },400);
  root.querySelector('.rate').onchange=e=>{vids.forEach(v=>{try{v.playbackRate=parseFloat(e.target.value);}catch(err){}});};
  root.querySelector('.snd').onchange=e=>{const i=parseInt(e.target.value);vids.forEach((v,j)=>v.muted=(j!==i));};
  root.querySelector('.act-zoom').onclick=e=>{const on=e.target.classList.toggle('on');
    root.querySelectorAll('.vidwrap').forEach(w=>w.classList.toggle('zoomed',on));
    e.target.textContent=on?'🔍 已放大（点此还原）':'🔍 放大 2.2×';};
}
__GROUPIDS__.forEach(setupGroup);
paint();updateBar();paintReveal();
document.getElementById('reveal').addEventListener('toggle',()=>{
  document.body.classList.toggle('revealed',document.getElementById('reveal').open);
  paintReveal();
});
'''.replace('__KEY__', KEY).replace('__ALLK__', ALLK).replace('__CASESHORT__', CASESHORT).replace('__ENGS__', ENGS_JSON).replace('__GROUPIDS__', json.dumps(list(CASES) + ["cal"]))

HTML = '''<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>4引擎盲选 · gpunoise重跑+V2补测 (33臂同seed·uncontended)</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#121418;color:#d8dce2;font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;margin:18px;line-height:1.55;padding-bottom:130px}
h1{font-size:19px;border-bottom:1px solid #333;padding-bottom:8px;margin-bottom:10px}
h2{font-size:16px;margin:22px 0 8px;color:#e6e9ee}
.note{color:#8b93a0;font-size:12.5px;font-weight:400}
.quad{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;max-width:1900px;margin:10px 0}
.tri{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;max-width:1500px;margin:10px 0}
.duo{display:grid;grid-template-columns:repeat(2,1fr);max-width:1000px;margin:10px 0}
@media(max-width:1500px){.quad{grid-template-columns:repeat(2,1fr)}}
@media(max-width:1200px){.tri{grid-template-columns:repeat(2,1fr)}}
@media(max-width:760px){.quad,.tri,.duo{grid-template-columns:1fr}}
.panel{background:#161b22;border:1px solid #2c3038;border-radius:8px;overflow:hidden}
.panel.master-hint .armtag{background:#1f6feb}
.armtag{display:flex;justify-content:space-between;align-items:center;padding:7px 10px;font-size:14px;font-weight:700;background:#242831;gap:6px;flex-wrap:wrap}
.badge{padding:1px 8px;border-radius:10px;font-size:11px;background:#30363d;color:#cdd6e0;font-weight:400}
.cfg-tag{display:none;padding:1px 8px;border-radius:10px;font-size:11px;background:#1f3a5f;color:#79b8ff;font-weight:400}
body.revealed .cfg-tag{display:inline-block}
.vidwrap{position:relative;overflow:hidden;background:#000;aspect-ratio:16/9}
.vidwrap video{width:100%;height:100%;object-fit:contain;display:block;transition:transform .18s ease}
.vidwrap.zoomed video{transform:scale(2.2);transform-origin:50% 45%;image-rendering:pixelated}
.fs-btn{position:absolute;right:8px;bottom:8px;background:rgba(0,0,0,.55);color:#e0e0e0;border:1px solid #2a2a4e;padding:4px 12px;border-radius:6px;cursor:pointer;font-size:12px}
.metabar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:7px 10px;font-size:12px;border-top:1px solid #22262e}
.vbtn{background:#1a2634;color:#c9d1d9;border:1px solid #2c3e52;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:13px}
.vbtn.active[data-v="优"]{background:#1a7f4b;border-color:#2ea86a;color:#fff}
.vbtn.active[data-v="良"]{background:#1f6feb;border-color:#58a6ff;color:#fff}
.vbtn.active[data-v="中"]{background:#9e6a03;border-color:#d29922;color:#fff}
.vbtn.active[data-v="差"]{background:#b62324;border-color:#f85149;color:#fff}
.toolbar{position:sticky;top:0;z-index:50;display:flex;flex-wrap:wrap;gap:8px;align-items:center;background:#161b22f2;border:1px solid #2c3038;border-radius:8px;padding:8px 12px;margin:8px 0;backdrop-filter:blur(4px)}
.toolbar button,.toolbar select{background:#21262d;color:#d8dce2;border:1px solid #30363d;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:13px}
.toolbar button.primary{background:#1f6feb;border-color:#1f6feb;color:#fff}
.toolbar button.on{background:#b35900;border-color:#b35900;color:#fff}
.toolbar .sep{width:1px;height:22px;background:#30363d}
.refrow{display:flex;gap:8px;margin:4px 0 2px;flex-wrap:wrap}
.refimg{width:150px;border:1px dashed #d29922;border-radius:6px}
.cmt{width:100%;max-width:1200px;margin:8px 0;background:#0a0e14;color:#c9d1d9;border:1px solid #23364a;border-radius:6px;padding:8px 10px;font-size:13px}
#ovr{width:100%;max-width:1200px}
.exportbar{position:fixed;left:0;right:0;bottom:0;z-index:99;display:flex;gap:10px;align-items:center;padding:10px 14px;background:rgba(1,4,9,.94);border-top:1px solid #2c3038}
.exportbar textarea{flex:1;min-height:46px;background:#0a0e14;color:#7ee787;border:1px solid #2c3038;border-radius:8px;padding:8px 10px;font-family:ui-monospace,monospace;font-size:12.5px;resize:none}
.exportbar button{flex-shrink:0;padding:13px 16px;border:none;border-radius:9px;background:#e5484d;color:#fff;font-weight:800;font-size:14px;cursor:pointer}
#reveal{margin:26px 0;border:1px solid #2c3038;border-radius:8px;padding:10px 14px;background:#161b22}
#reveal table{border-collapse:collapse;margin:10px 0}
#reveal th,#reveal td{border:1px solid #2c3038;padding:6px 12px;font-size:13.5px;text-align:left}
#reveal th{background:#1b1e24;color:#aab}
.rv{font-weight:700}.rv3{color:#56B89A}.rv2{color:#4C9AFF}.rv1{color:#D29922}.rv0{color:#DD6A82}
.rvrow-voted{background:rgba(26,127,75,.16)}
.yours{margin-left:10px;font-size:12px;color:#7ee787;font-weight:700}
.bn{padding:8px 12px;border-radius:8px;margin:6px 0;font-size:14px;font-weight:700}
.bn-win{background:rgba(26,127,75,.18);border:1px solid #2ea86a;color:#6fe0a8}
.bn-tie{background:rgba(210,153,34,.16);border:1px solid #d29922;color:#e8c06a}
.bn-wait{background:rgba(255,255,255,.04);border:1px dashed #3a4150;color:#8b93a0;font-weight:400}
.bn em{color:#e8c06a}
.verdict{border-left:4px solid #d29922;background:#1c1712;padding:10px 14px;border-radius:6px;margin:12px 0;font-size:13.5px}
</style></head><body>
<h1>🎬 4引擎盲选 · gpunoise 无噪重跑 + V2 补测（甲/乙/丙/丁 · 同seed同refs · uncontended）</h1>
<div class="verdict"><b>口径：</b>9 case（小江湖生产镜头×6 + 标准集×3），v1 三臂 + 09-16 补测第四臂，同 case 同 seed 同 refs 同链（ref2va · H3 原生音频）。
串行无竞争协议：<b>全部臂零锁等待污染</b>（渲染s 为干净口径）。组内响度归一（同增益 · 真峰≈-3dB）。
<b>逐臂优良中差 + 组意见 + 总评 → 底部一键复制贴回群</b>。CAL 校准组两侧同片（不计分母）。
注：优=3/良=2/中=1/差=0 求和排名；标准集 val 三 case 各有一臂因时长边界门拒单缺席（三臂组，缺席臂不计分母）。评完展开页底揭晓（含逐 case 结论条 + 你的选择高亮 + 耗时拆分）。此前已评的甲/乙/丙投票自动保留。</div>
__GROUPS__
<div style="margin:16px 0"><input class="cmt" id="ovr" placeholder="总评（可选）"></div>
__REVEAL__
<div class="exportbar"><textarea id="sumbox" readonly></textarea><button id="copybtn" onclick="doCopy()">📋 复制发回群里</button></div>
<script>
__JS__
</script></body></html>'''

html = (HTML.replace("__GROUPS__", "".join(groups))
            .replace("__REVEAL__", reveal)
            .replace("__JS__", JS))
open(f"{OUT}/index.html", "w").write(html)

# ===== self-checks =====
body_no_reveal = html.split('<details id="reveal">')[0]
scan = re.sub(r'<span class="cfg-tag">.*?</span>', '', body_no_reveal, flags=re.S)
leaks = [t for t in ["rh-v4", "fasth3", "FastH3", "vdn", "lightx2v", "LXV", "LightX2V", "int8"] if t.lower() in scan.lower()]
assert not leaks, f"engine leak in blind section: {leaks}"
# v1 槽位连续性断言: 老 a/b/c 引擎归属不变
for case in CASES:
    for s, d in v1map[case].items():
        assert mapping[case][s]["eng"] == d["eng"], f"slot drift {case}{s}"
srcs = re.findall(r'src="([^"]+\.mp4)"', html)
for s in set(srcs):
    assert os.path.exists(f"{OUT}/{s}") and os.path.getsize(f"{OUT}/{s}") > 100000, f"missing {s}"
n_panels = sum(len(mapping[c]) for c in CASES) + 2
n_fs = html.count("fs-btn")
assert n_fs >= n_panels, f"fullscreen buttons {n_fs} < {n_panels}"
assert html.count("vbtn") >= n_panels * 4
assert html.count("<script>") == 1
mjs = re.search(r"<script>\n(.*)\n</script>", html, re.S)
_jf = "/tmp/_gpn4_js_check.js"
open(_jf, "w").write(mjs.group(1))
_r = subprocess.run(["node", "--check", _jf], capture_output=True, text=True)
assert _r.returncode == 0, f"JS syntax error: {_r.stderr[-400:]}"
open(f"{OUT}/.selfcheck", "w").write("OK")
print("BUILDER_V2_OK")
print("mapping:", json.dumps({SHORT[c]: {s: ENGINES[d['eng']] for s, d in v.items()} for c, v in mapping.items()}, ensure_ascii=False))
print("videos:", len(set(srcs)), "| fs-btn:", n_fs, "| size:", os.path.getsize(f"{OUT}/index.html"))
