ai-review-video.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.10"
  4. # dependencies = [
  5. # "requests>=2.28.0",
  6. # ]
  7. # ///
  8. """
  9. AI看片评审闭环 —— 渲染出的动画MP4喂给视频理解模型(seed-2.0-lite),
  10. 按固定checklist逐段送审 + 全片低清扫一遍,汇总成结构化markdown评审报告。
  11. ⚠️ 可选云能力:会把压缩后的成片片段发送到火山方舟官方接口(ark.cn-beijing.volces.com)
  12. 做视频理解评审,使用你自己的 ARK_API_KEY。首次调用需 --yes 或 HUASHU_CLOUD_OK=1
  13. 显式确认。数据流向声明见仓库根 SECURITY.md。本地免费替代:scripts/verify-video.sh 截帧人工看。
  14. Usage:
  15. uv run ai-review-video.py --video 成片.mp4 --yes
  16. uv run ai-review-video.py --video 成片.mp4 --context 导演稿.md --yes
  17. uv run ai-review-video.py --video 成片.mp4 --segment-len 60 --output 报告.md --yes
  18. 调用链路:
  19. 1. ffprobe 探测时长/音轨
  20. 2. 有音轨 → ffmpeg silencedetect 提取音效onset时间表(模型听不到视频音轨,
  21. 实测2026-07-17:input_video只送画面。音画对位检查=本地onset+模型画面核对)
  22. 3. 按 --segment-len 切段并压缩(1280宽/15fps/crf28,扁平动画约0.5MB/分钟)
  23. 4. 逐段送审(checklist①-⑧),每段prompt标注原片时间范围
  24. 5. 全片再压一版低清(960宽/10fps)单独送审,专查跨段叙事连贯/hero贯穿
  25. 6. 文本汇总call:按checklist逐项合并,产出最终报告;分段原始发现保留在附录
  26. API key:优先读环境变量 ARK_API_KEY,其次读 skill 根目录 .env(只提取这一个变量),绝不硬编码。
  27. 代理:requests session 关闭 trust_env(不继承本机代理配置),免疫 ALL_PROXY 之类残留代理导致的 TLS 报错。
  28. """
  29. import argparse
  30. import json
  31. import os
  32. import re
  33. import subprocess
  34. import sys
  35. import tempfile
  36. import time
  37. from base64 import b64encode
  38. from pathlib import Path
  39. import requests
  40. API_URL = "https://ark.cn-beijing.volces.com/api/v3/responses"
  41. DEFAULT_MODEL = "doubao-seed-2-0-lite-260215"
  42. ENV_PATH = Path(__file__).resolve().parents[2] / ".env" # skill 根目录 .env(已 gitignore)
  43. MAX_SEGMENT_MB = 8 # 单段压缩产物超过这个值就再压一档
  44. CHECKLIST = """\
  45. ① 黑帧/空窗/渲染残缺:整帧或大面积黑屏、白屏、元素未渲染出来、明显破图
  46. ② 文字问题:字卡/标签被裁切、溢出容器、错字、乱码、字叠字
  47. ③ 元素重叠遮挡:不该重叠的元素互相遮挡、层级错误、穿模
  48. ④ 叙事连贯性:场景过渡分三类——硬切(前后帧整页突变,无任何衔接)、
  49. 交叉淡入淡出(旧场景透明度渐隐)、morph(元素连续变形/位移到新场景)。
  50. 报告时必须写明你看到的是哪一类,不要把淡入淡出误报成硬切;
  51. 硬切=⚡,淡入淡出在导演稿要求morph时=💡「过渡偷懒」
  52. ⑤ hero/主体贯穿性:如果有贯穿全片的主体元素,它是否在场景切换中断裂、消失、突变位置
  53. ⑥ 节奏死段:见下方「静止段客观检测表」(ffmpeg逐帧检测,≥3秒完全静止的区间)。
  54. 你的任务不是找死段,而是对表中每个区间判断:是刻意hold(字卡阅读/弹幕停留/收尾定格)
  55. 还是真死段(画面无信息可读还停着)。刻意hold=不报或💡,真死段=⚡
  56. ⑦ 音效打点(见下方onset时间表):核对每个音效时间点画面是否有对应事件
  57. ⑧ 构图:明显失衡、大片无意义空白、重要元素贴边或被挤到角落"""
  58. SEVERITY_RULE = """\
  59. 严重度分三级:
  60. - ⚠️致命:交付前必须修(黑帧、错字、文字被裁、元素叠死、明显破图)
  61. - ⚡重要:观感明显受损(硬切感、hero断裂、超3秒死段、构图明显失衡)
  62. - 💡建议:锦上添花的改进点"""
  63. def log(msg):
  64. print(msg, file=sys.stderr, flush=True)
  65. def load_api_key():
  66. key = os.getenv("ARK_API_KEY")
  67. if not key and ENV_PATH.exists():
  68. # 只提取 ARK_API_KEY 一个变量,不把 .env 整文件灌进环境
  69. for line in ENV_PATH.read_text(encoding="utf-8").splitlines():
  70. line = line.strip()
  71. if line.startswith("ARK_API_KEY") and "=" in line:
  72. key = line.split("=", 1)[1].strip().strip("'\"")
  73. break
  74. if not key or key.startswith("your_"):
  75. sys.exit("Error: ARK_API_KEY 未配置(skill 根目录 .env 或环境变量),拒绝继续。不编造评审结果。")
  76. return key
  77. def run(cmd):
  78. r = subprocess.run(cmd, capture_output=True, text=True)
  79. if r.returncode != 0:
  80. raise RuntimeError(f"命令失败: {' '.join(cmd)}\n{r.stderr[-2000:]}")
  81. return r
  82. def probe(video: Path):
  83. r = run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
  84. "-show_entries", "stream=codec_type", "-of", "json", str(video)])
  85. info = json.loads(r.stdout)
  86. duration = float(info["format"]["duration"])
  87. has_audio = any(s.get("codec_type") == "audio" for s in info.get("streams", []))
  88. return duration, has_audio
  89. def detect_audio_onsets(video: Path, noise_db=-45, min_silence=0.3):
  90. """silencedetect反推音效onset。返回原片秒数列表。"""
  91. r = subprocess.run(
  92. ["ffmpeg", "-i", str(video), "-af",
  93. f"silencedetect=noise={noise_db}dB:d={min_silence}", "-f", "null", "-"],
  94. capture_output=True, text=True)
  95. onsets = [round(float(m), 1) for m in
  96. re.findall(r"silence_end:\s*([\d.]+)", r.stderr)]
  97. # 片头非静音(开场即有声)时补0
  98. starts = re.findall(r"silence_start:\s*([\d.-]+)", r.stderr)
  99. if starts and float(starts[0]) > min_silence:
  100. onsets.insert(0, 0.0)
  101. return onsets
  102. def detect_static_segments(video: Path, noise=0.001, min_dur=3.0):
  103. """freezedetect找≥min_dur秒完全静止的区间。返回[(start,end)]原片秒。"""
  104. r = subprocess.run(
  105. ["ffmpeg", "-i", str(video), "-vf",
  106. f"freezedetect=n={noise}:d={min_dur}", "-f", "null", "-"],
  107. capture_output=True, text=True)
  108. starts = re.findall(r"freeze_start:\s*([\d.]+)", r.stderr)
  109. durs = re.findall(r"freeze_duration:\s*([\d.]+)", r.stderr)
  110. return [(round(float(s), 1), round(float(s) + float(d), 1))
  111. for s, d in zip(starts, durs)]
  112. def compress(src: Path, dst: Path, ss=None, t=None, width=1280, fps=15, crf=28):
  113. cmd = ["ffmpeg", "-y", "-v", "error"]
  114. if ss is not None:
  115. cmd += ["-ss", str(ss)]
  116. if t is not None:
  117. cmd += ["-t", str(t)]
  118. cmd += ["-i", str(src), "-vf", f"scale={width}:-2,fps={fps}",
  119. "-c:v", "libx264", "-crf", str(crf), "-preset", "veryfast",
  120. "-pix_fmt", "yuv420p", "-an", str(dst)]
  121. run(cmd)
  122. def fmt_ts(sec: float) -> str:
  123. return f"{int(sec) // 60}:{int(sec) % 60:02d}"
  124. def ask_model(session, api_key, model, prompt, video_path: Path | None = None, retries=1):
  125. content = []
  126. if video_path is not None:
  127. b64 = b64encode(video_path.read_bytes()).decode()
  128. content.append({"type": "input_video", "video_url": f"data:video/mp4;base64,{b64}"})
  129. content.append({"type": "input_text", "text": prompt})
  130. payload = {"model": model, "input": [{"role": "user", "content": content}]}
  131. last_err = None
  132. for attempt in range(retries + 1):
  133. try:
  134. resp = session.post(
  135. API_URL, json=payload, timeout=600,
  136. headers={"Authorization": f"Bearer {api_key}",
  137. "Content-Type": "application/json"})
  138. if resp.status_code != 200:
  139. last_err = f"API {resp.status_code}: {resp.text[:500]}"
  140. continue
  141. data = resp.json()
  142. usage = data.get("usage", {})
  143. text = ""
  144. out = data.get("output")
  145. if isinstance(out, list):
  146. for item in out:
  147. if isinstance(item, dict) and item.get("type") == "message":
  148. for c in item.get("content", []):
  149. if isinstance(c, dict) and c.get("type") == "output_text":
  150. text += c.get("text", "")
  151. elif isinstance(out, str):
  152. text = out
  153. if not text:
  154. choices = data.get("choices", [])
  155. if choices:
  156. text = choices[0].get("message", {}).get("content", "")
  157. if text:
  158. return text, usage
  159. last_err = f"响应无文本: {json.dumps(data, ensure_ascii=False)[:500]}"
  160. except requests.RequestException as e:
  161. last_err = f"网络错误: {e}"
  162. if attempt < retries:
  163. log(f" 重试({last_err[:120]})...")
  164. time.sleep(3)
  165. raise RuntimeError(last_err)
  166. def segment_prompt(seg_start, seg_end, duration, context_text, onsets_in_seg,
  167. statics_in_seg):
  168. p = [f"你是动画成片质检员,任务是严格挑毛病,不夸片子。",
  169. f"这段视频是一部总长{fmt_ts(duration)}的动画成片的一个片段,"
  170. f"对应原片 {fmt_ts(seg_start)}–{fmt_ts(seg_end)}。"
  171. f"片段内第t秒 = 原片第{fmt_ts(seg_start)}+t秒,报告里一律用原片时间(分:秒)。"]
  172. if context_text:
  173. p.append("以下是全片导演稿(评审上下文,用来判断叙事意图和该出现什么):\n"
  174. "<导演稿>\n" + context_text + "\n</导演稿>")
  175. p.append("逐项检查以下checklist,只报本片段内的发现:\n" + CHECKLIST)
  176. if statics_in_seg:
  177. ts = "、".join(f"{fmt_ts(a)}–{fmt_ts(b)}({b - a:.1f}s)" for a, b in statics_in_seg)
  178. p.append(f"⑥的静止段客观检测表(本段内,原片时间):{ts}。逐个判断刻意hold还是真死段。")
  179. else:
  180. p.append("本片段内无≥3秒静止段,⑥直接写「未发现」。")
  181. if onsets_in_seg:
  182. ts = "、".join(f"{fmt_ts(t)}({t}s)" for t in onsets_in_seg)
  183. p.append(f"⑦的onset时间表(本段内音效实际出现的原片时间):{ts}。"
  184. f"你听不到声音,只需核对这些时间点画面上是否有值得配音效的事件"
  185. f"(转场/字卡落定/撞击/元素出现),没有对应事件的时间点=音效打空,要报。")
  186. else:
  187. p.append("本片段内没有检测到音效onset,⑦跳过;但如果本段有强烈画面事件"
  188. "(撞击/字卡/转场)却无音效覆盖,可在⑦下用💡提出。")
  189. p.append(SEVERITY_RULE)
  190. p.append("输出格式:markdown。按①-⑧逐项,每项下用列表:\n"
  191. "- [原片分:秒] 严重度emoji 具体描述\n"
  192. "该项无问题就写「未发现」。只报你真正看到的,不确定的标「存疑」,不编造。")
  193. return "\n\n".join(p)
  194. def global_prompt(duration, context_text):
  195. p = ["你是动画成片质检员。这是一部动画成片的全片低清版(评审用压缩,画质低是正常的,"
  196. "不要报画质/清晰度问题),总长" + fmt_ts(duration) + "。"]
  197. if context_text:
  198. p.append("导演稿:\n<导演稿>\n" + context_text + "\n</导演稿>")
  199. p.append("只做三件事(细节问题已有分段评审负责,你不用管):\n"
  200. "A. 叙事连贯性:从头到尾看,哪些时间点是PowerPoint式硬切(整页突变无过渡)?\n"
  201. "B. hero/主体贯穿性:贯穿全片的主体元素在哪些切换处断裂、消失或突变?\n"
  202. "C. 整体节奏:哪些区间拖(长时间无新信息)、哪些区间赶?\n\n"
  203. + SEVERITY_RULE +
  204. "\n\n输出markdown,A/B/C三节,发现带[分:秒]时间点。无问题写「未发现」。不编造。")
  205. return "\n\n".join(p)
  206. def synthesis_prompt(duration, seg_reports, global_report):
  207. parts = ["你是评审报告主编。下面是同一部" + fmt_ts(duration) +
  208. "动画成片的分段评审 + 全片评审原始记录,把它们合并成一份最终报告正文。",
  209. "要求:\n"
  210. "1. 按checklist①-⑧逐项组织,每项下按时间顺序列发现:- [分:秒] 严重度 描述\n"
  211. "2. 同一问题被多段重复报的合并成一条;分段与全片评审矛盾时两说并存标「存疑」\n"
  212. "3. 保留每条发现的时间点和严重度emoji(⚠️/⚡/💡),不新增原始记录里没有的发现\n"
  213. "4. 开头给一个「问题总数:⚠️x ⚡y 💡z」的统计行和三句话以内的总评\n"
  214. "5. 只输出报告正文markdown,不要客套话",
  215. "<全片评审>\n" + global_report + "\n</全片评审>"]
  216. for (s, e, text) in seg_reports:
  217. parts.append(f"<分段评审 原片{fmt_ts(s)}–{fmt_ts(e)}>\n{text}\n</分段评审>")
  218. return "\n\n".join(parts)
  219. def main():
  220. ap = argparse.ArgumentParser(description="AI看片评审:动画MP4 → checklist结构化评审报告")
  221. ap.add_argument("--video", required=True, help="成片路径(mp4)")
  222. ap.add_argument("--context", help="导演稿/分幕说明md路径(可选,作为评审上下文)")
  223. ap.add_argument("--segment-len", type=int, default=60, help="分段长度秒(默认60)")
  224. ap.add_argument("--model", default=DEFAULT_MODEL, help=f"模型(默认{DEFAULT_MODEL})")
  225. ap.add_argument("--output", "-o", help="报告路径(默认视频同目录<视频名>-AI评审.md)")
  226. ap.add_argument("--yes", action="store_true",
  227. help="确认将压缩后的视频段发送到火山方舟官方接口(或设 HUASHU_CLOUD_OK=1)")
  228. args = ap.parse_args()
  229. video = Path(args.video).resolve()
  230. if not video.exists():
  231. sys.exit(f"Error: 视频不存在 {video}")
  232. if not args.yes and os.getenv("HUASHU_CLOUD_OK") != "1":
  233. sys.exit(
  234. f"[云能力确认] 本次将把 {video.name} 压缩后分段发送到 ark.cn-beijing.volces.com"
  235. "(火山方舟官方接口,使用你自己的 ARK_API_KEY 做视频理解评审)。\n"
  236. "确认无误请重跑并加 --yes,或设置环境变量 HUASHU_CLOUD_OK=1。"
  237. "数据流向声明见 SECURITY.md;本地免费替代:scripts/verify-video.sh。")
  238. out_path = Path(args.output) if args.output else video.parent / f"{video.stem}-AI评审.md"
  239. context_text = ""
  240. if args.context:
  241. ctx = Path(args.context)
  242. if not ctx.exists():
  243. sys.exit(f"Error: 上下文文件不存在 {ctx}")
  244. context_text = ctx.read_text(encoding="utf-8")[:12000]
  245. api_key = load_api_key()
  246. session = requests.Session()
  247. session.trust_env = False # 免疫 ALL_PROXY 等代理坑
  248. duration, has_audio = probe(video)
  249. log(f"视频 {fmt_ts(duration)},音轨={'有' if has_audio else '无'}")
  250. onsets = detect_audio_onsets(video) if has_audio else []
  251. if has_audio:
  252. log(f"音效onset检测:{len(onsets)}个 → {['%.1f' % t for t in onsets]}")
  253. # 静止段客观检测(相邻区间合并)
  254. raw_statics = detect_static_segments(video)
  255. statics = []
  256. for a, b in raw_statics:
  257. if statics and a - statics[-1][1] < 0.2:
  258. statics[-1] = (statics[-1][0], b)
  259. else:
  260. statics.append((a, b))
  261. log(f"静止段检测(≥3s):{len(statics)}个 → "
  262. f"{[f'{a:.0f}-{b:.0f}s' for a, b in statics]}")
  263. total_usage = {"input_tokens": 0, "output_tokens": 0}
  264. def add_usage(u):
  265. for k in total_usage:
  266. total_usage[k] += u.get(k, 0) or 0
  267. seg_reports, failures = [], []
  268. with tempfile.TemporaryDirectory(prefix="ai-review-") as tmp:
  269. tmp = Path(tmp)
  270. # 分段
  271. bounds = []
  272. t0 = 0.0
  273. while t0 < duration - 1:
  274. bounds.append((t0, min(t0 + args.segment_len, duration)))
  275. t0 += args.segment_len
  276. log(f"分段:{len(bounds)}段 × ≤{args.segment_len}s")
  277. for i, (s, e) in enumerate(bounds, 1):
  278. seg = tmp / f"seg{i}.mp4"
  279. compress(video, seg, ss=s, t=e - s)
  280. if seg.stat().st_size > MAX_SEGMENT_MB * 1024 * 1024:
  281. compress(video, seg, ss=s, t=e - s, width=960, fps=10, crf=32)
  282. mb = seg.stat().st_size / 1048576
  283. onsets_in = [t for t in onsets if s <= t < e]
  284. statics_in = [(a, b) for a, b in statics if a < e and b > s]
  285. log(f"段{i} {fmt_ts(s)}–{fmt_ts(e)}({mb:.1f}MB,onset×{len(onsets_in)},"
  286. f"静止段×{len(statics_in)})送审...")
  287. try:
  288. text, usage = ask_model(session, api_key, args.model,
  289. segment_prompt(s, e, duration, context_text,
  290. onsets_in, statics_in),
  291. seg)
  292. add_usage(usage)
  293. seg_reports.append((s, e, text))
  294. except RuntimeError as err:
  295. log(f" 段{i}送审失败:{err}")
  296. failures.append((s, e, str(err)))
  297. # 全片低清pass
  298. log("全片低清版送审(叙事/hero/节奏)...")
  299. full = tmp / "full.mp4"
  300. compress(video, full, width=960, fps=10, crf=30)
  301. global_report, global_fail = "", None
  302. try:
  303. global_report, usage = ask_model(session, api_key, args.model,
  304. global_prompt(duration, context_text), full)
  305. add_usage(usage)
  306. except RuntimeError as err:
  307. global_fail = str(err)
  308. log(f" 全片pass失败:{err}")
  309. if not seg_reports and not global_report:
  310. sys.exit("Error: 所有送审调用均失败,无法产出报告。不编造评审结果。\n" +
  311. "\n".join(f"{fmt_ts(s)}–{fmt_ts(e)}: {m}" for s, e, m in failures))
  312. # 汇总
  313. log("汇总最终报告...")
  314. try:
  315. body, usage = ask_model(session, api_key, args.model,
  316. synthesis_prompt(duration, seg_reports,
  317. global_report or "(全片pass调用失败,无记录)"))
  318. add_usage(usage)
  319. except RuntimeError as err:
  320. log(f"汇总call失败({err}),退化为原始记录拼接")
  321. body = "> 汇总call失败,以下为各pass原始记录直接拼接。\n\n" + \
  322. (global_report or "") + "\n\n" + \
  323. "\n\n".join(f"## 分段 {fmt_ts(s)}–{fmt_ts(e)}\n{t}" for s, e, t in seg_reports)
  324. lines = [f"# {video.name} · AI评审报告",
  325. "",
  326. f"> 模型:{args.model} | 评审时间:{time.strftime('%Y-%m-%d %H:%M')} | "
  327. f"片长:{fmt_ts(duration)} | 分段:{len(seg_reports)}成功/{len(failures)}失败 | "
  328. f"音效onset:{len(onsets)}个 / 静止段≥3s:{len(statics)}个"
  329. f"(均为本地ffmpeg客观检测;模型不闻声,音画对位=onset+画面核对) | "
  330. f"tokens:in {total_usage['input_tokens']} / out {total_usage['output_tokens']}",
  331. ""]
  332. if failures:
  333. lines.append("> ⚠️ 以下时间段送审失败,未被评审覆盖:" +
  334. ";".join(f"{fmt_ts(s)}–{fmt_ts(e)}({m[:100]})" for s, e, m in failures))
  335. lines.append("")
  336. if global_fail:
  337. lines.append(f"> ⚠️ 全片连贯性pass调用失败:{global_fail[:200]}")
  338. lines.append("")
  339. lines.append(body)
  340. lines.append("\n\n---\n\n## 附录 · 客观检测数据(ffmpeg,非模型判断)\n")
  341. lines.append("静止段≥3s:" + ("、".join(
  342. f"{fmt_ts(a)}–{fmt_ts(b)}({b - a:.1f}s)" for a, b in statics) or "无"))
  343. lines.append("\n音效onset:" + ("、".join(fmt_ts(t) for t in onsets) or "无/无音轨"))
  344. lines.append("\n## 附录 · 各段原始评审记录\n")
  345. if global_report:
  346. lines.append("### 全片pass(叙事/hero/节奏)\n\n" + global_report + "\n")
  347. for s, e, t in seg_reports:
  348. lines.append(f"### 分段 原片{fmt_ts(s)}–{fmt_ts(e)}\n\n{t}\n")
  349. out_path.write_text("\n".join(lines), encoding="utf-8")
  350. log(f"报告已写入: {out_path}")
  351. print(out_path)
  352. if __name__ == "__main__":
  353. main()