""" composer/qa.py — 出片质检辅助(接触印相表) 用途:出片后自动抽若干关键帧拼成一张「接触印相表」(contact sheet), 让人**一眼**核对: · 口型是否与台词对上(有台词的镜头) · 跨镜人物是否同一张脸(一致性) · 表情/构图/光线是否连贯 纯本地 ffmpeg 处理,零成本。 """ import subprocess import sys from pathlib import Path _SCRIPT_ROOT = Path(__file__).resolve().parent.parent if str(_SCRIPT_ROOT) not in sys.path: sys.path.insert(0, str(_SCRIPT_ROOT)) from lib.logger import get_logger log = get_logger("composer.qa") def _ffmpeg() -> str: try: from lib.ffmpeg import FFMPEG return FFMPEG except Exception: return "ffmpeg" def _duration(video_path: str) -> float: try: from lib.ffmpeg import probe_duration return float(probe_duration(video_path) or 0) except Exception: return 0.0 def make_contact_sheet(video_path: str, out_path: str, cols: int = 4, rows: int = 2, tile_w: int = 360) -> str: """抽 `cols*rows` 帧均匀分布拼成一张图(默认 4x2 = 8 帧) · 均匀采样(按视频时长算间隔),覆盖整个片段 · 输出 jpg,宽 ≈ cols * tile_w """ src = Path(video_path) if not src.exists(): raise FileNotFoundError(f"视频不存在: {video_path}") n = max(1, cols * rows) dur = _duration(str(src)) interval = max(0.2, (dur / n)) if dur > 0 else 1.0 dst = Path(out_path) dst.parent.mkdir(parents=True, exist_ok=True) vf = f"fps=1/{interval:.3f},scale={tile_w}:-2,tile={cols}x{rows}" r = subprocess.run( [_ffmpeg(), "-y", "-i", str(src), "-vf", vf, "-frames:v", "1", "-q:v", "3", str(dst)], capture_output=True, text=True, timeout=180) if r.returncode != 0 or not dst.exists() or dst.stat().st_size < 1000: raise RuntimeError(f"生成接触印相表失败: {r.stderr.strip()[-200:]}") log.info(" 🖼 质检图: %s(%d 帧 · %dx%d)", dst.name, n, cols, rows) return str(dst) def make_shot_qa(job_dir: str, sb=None) -> dict: """为一次出片生成质检图(成片整体 + 每个镜头) 返回 {"final": path, "shots": [paths...]};失败不抛(质检是辅助,不应阻塞交付) """ out: dict = {"final": "", "shots": []} d = Path(job_dir) if not d.exists(): return out # ① 成片整体 final = d / "final_muxed.mp4" if not final.exists(): final = d / "final.mp4" if final.exists(): try: out["final"] = make_contact_sheet(str(final), str(d / "qa" / "final_sheet.jpg")) except Exception as e: log.warning(" ⚠️ 成片质检图失败: %s", e) # ② 逐镜头(对口型后的片段优先,核对口型最准) qa_dir = d / "qa" shots = [] try: cands = sorted(d.glob("lipsync_*.mp4")) or sorted(d.glob("shot_*.mp4")) for i, v in enumerate(cands, 1): try: shots.append(make_contact_sheet( str(v), str(qa_dir / f"{v.stem}_sheet.jpg"), cols=3, rows=2, tile_w=280)) except Exception as e: log.debug(" 镜头质检图失败 %s: %s", v.name, e) except Exception as e: log.warning(" ⚠️ 逐镜质检图失败: %s", e) out["shots"] = shots return out if __name__ == "__main__": import argparse ap = argparse.ArgumentParser(description="出片质检辅助(接触印相表)") ap.add_argument("job_dir", help="出片目录(含 final.mp4 或 final_muxed.mp4)") args = ap.parse_args() r = make_shot_qa(args.job_dir) print(f"成片质检图: {r['final'] or '(无)'}") print(f"镜头质检图: {len(r['shots'])} 张") for p in r["shots"][:8]: print(f" {p}")