#!/usr/bin/env python3
"""本地臂 v4: Qwen3.8-27B CPU llama-server :8126。协议=云端v4(自然语言观察+文本判定)。"""
import json, base64, time, re, urllib.request
from pathlib import Path

EP = Path("/home/kai/runs/ep-cat-worker-001")
FR = Path("/home/kai/shared/qwen-eye-exp/frames")
OUT = Path("/home/kai/shared/qwen-eye-exp/results")
API = "http://127.0.0.1:8126/v1/chat/completions"
shots = json.load(open(EP/'.pipeline-assets/shot-list.json'))['value']
spec_by_id = {s['shot_id']: s for s in shots}

OBS_PROMPT = """逐帧仔细看这5帧(按时间序,来自同一视频)。用自然语言描述你实际看到的:
1. 每帧画面主体是什么(人/动物/物体), 什么颜色外观?
2. 主体在画面中的位置(左/中/右, 大小占比)?
3. 从第1帧到第5帧, 主体大小、位置、朝向有什么变化? 背景有变化吗?
4. 有没有明显画面缺陷(肢体畸形/糊化/黑屏/文字水印)?
5. 综合判断: 景别(远景/全景/中景/近景/特写)? 运镜(固定/跟随/推进/拉远)?
直接描述, 不要输出JSON, 不要猜测拍摄意图。"""

JUDGE_TMPL = """你是影视质检员。下面是【镜头分析员对视频帧的独立观察】(自然语言)和【SPEC规格】。
逐维对比判断。观察是唯一事实。参考基准:
- 远景/WS=主体很小环境为主; 全景/FS=全身; 中景/MS=膝上; 近景/MCU=胸上; 过肩/OTS=有前景肩膀; 特写/CU=头肩; INSERT=局部物件
- 运镜按观察的帧间变化判断: 主体渐大+背景不变≈推进; 主体渐小+背景展开≈拉远; 主体位置稳+背景流动≈跟随; 无变化≈固定
- 三分法: 主体横向在两侧1/3区域; 引导线: 观察中有线条元素
- 容差: 相邻一级景别OK; 主体物种不符(动物vs人)=content_match=false
- 画面缺陷(畸形/糊化/黑屏)存在 → 直接fail
- 注意: SPEC中intent描述的角色(如橘橘)是拟人化动物角色, 观察到猫/动物即视为匹配

观察: <OBS>

SPEC: <SPEC>

严格输出JSON(不要markdown): {"framing_ok":bool,"camera_ok":bool,"composition_ok":bool,"axis_ok":bool,"content_match":bool,"confidence":0.0-1.0,"verdict":"pass|fail","reasons":"一句话"}"""

def b64(p): return base64.b64encode(Path(p).read_bytes()).decode()
def call(content, n=1200):
    body = json.dumps({"model":"qwen3.8","temperature":0.1,"max_tokens":n,
        "messages":[{"role":"user","content":content}]}).encode()
    req = urllib.request.Request(API, data=body, headers={"Content-Type":"application/json"})
    with urllib.request.urlopen(req, timeout=1800) as r:
        resp = json.load(r)
    msg = resp['choices'][0]['message']
    return (msg.get('content') or msg.get('reasoning_content') or '').strip()
def pj(t):
    m=re.search(r'\{.*\}',t,re.S)
    try: return json.loads(m.group(0)) if m else None
    except: return None

def judge(sid, spec):
    d = FR/sid
    imgs=[{"type":"image_url","image_url":{"url":"data:image/jpeg;base64,"+b64(d/f)}} for f in ['f0.jpg','f1.jpg','f2.jpg','f3.jpg','f4.jpg']]
    t0=time.time()
    obs=call(imgs+[{"type":"text","text":OBS_PROMPT}])
    spec_slim=json.dumps({k:spec.get(k) for k in ['shot_id','shot_type','framing','camera_movement','composition','axis_line','intent']},ensure_ascii=False)
    jtxt=call([{"type":"text","text":JUDGE_TMPL.replace('<OBS>',obs).replace('<SPEC>',spec_slim)}], n=600)
    c=pj(jtxt)
    return {"video":sid,"spec_of":spec['shot_id'],"elapsed_s":round(time.time()-t0,1),"obs":obs,
            "verdict":c.get('verdict') if c else "PARSE_FAIL",
            "dims":{k:c.get(k) for k in ['framing_ok','camera_ok','composition_ok','axis_ok','content_match']} if c else None,
            "reasons":c.get('reasons') if c else jtxt[:200]}

CONTROLS=[("S1-01","S4-01"),("S5-03","S8-01"),("S7-03","S6-02"),("S4-02","S2-02"),("S8-01","S1-03"),("S3-01","S1-02"),("S1-02","S2-01"),("S7-02","S5-03")]
log=open(OUT/'judge_v4_local.jsonl','w')
tasks=[("pos",s['shot_id'],s) for s in shots]+[("neg",v,spec_by_id[sp]) for v,sp in CONTROLS]
for i,(tag,vid,spec) in enumerate(tasks):
    try: r=judge(vid,spec); r['tag']=tag
    except Exception as e: r={"tag":tag,"video":vid,"error":str(e)[:300]}
    log.write(json.dumps(r,ensure_ascii=False)+"\n"); log.flush()
    print(f"[{i+1}/28] {tag} v={vid} s={r.get('spec_of')} -> {r.get('verdict','ERR')} | {str(r.get('reasons',''))[:70]}",flush=True)
print("DONE")
