From 893b04969580a671963dc1e5e39a4ccdcec8b578 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 22:13:07 +0800 Subject: [PATCH 01/40] =?UTF-8?q?fix(media-pipeline):=20TTS=20=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E9=9D=99=E9=BB=98=E9=99=8D=E7=BA=A7=E5=8F=8C=E5=B1=82?= =?UTF-8?q?=E6=8A=A4=E6=A0=8F=E4=B8=8E=E8=84=9A=E6=9C=AC=E5=81=A5=E5=A3=AE?= =?UTF-8?q?=E6=80=A7=E4=BF=AE=E5=A4=8D;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tts.py 漏写 --engine indextts 时,克隆专属带值参数(--ref/--style/--num-beams 等) 由 stderr 提示升级为硬失败:{id}.mp3 单槽位下照跑会把整集克隆音频静默改写为 edge 预置音色(此前仅 --plan 路径硬化,合成路径漏了) - 新增 .engine 音色签名标记:记录上次成功合成的引擎/音色/风格,不一致时硬失败并 要求显式 --allow-voice-switch(检查前置于 --plan,排期阶段即拦截误重录;不参与 逐句摘要,零缓存影响) - IndexTTS 服务启动命令三处副本收口为 server_launch_hint(),消灭 <仓库路径> 占位符 - build_narration.py 报错口径对齐 tts.py:缺文件给可操作退出;句出现在分幕标题 之前/重复 id/前缀不符均带文件行号与修复指引 - tts_sample.py 新增 --cleanup:试听结束自动删除产物目录(小样含本人音色,属生物 特征信息,此前仅打印 rm 提示靠手工执行) 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- media/pipeline/scripts/build_narration.py | 22 +++++- media/pipeline/scripts/tts.py | 89 ++++++++++++++++++++--- media/pipeline/scripts/tts_sample.py | 22 ++++-- 3 files changed, 114 insertions(+), 19 deletions(-) diff --git a/media/pipeline/scripts/build_narration.py b/media/pipeline/scripts/build_narration.py index 36845d2c..8607878c 100644 --- a/media/pipeline/scripts/build_narration.py +++ b/media/pipeline/scripts/build_narration.py @@ -13,10 +13,12 @@ import argparse import json import re +import sys from pathlib import Path LINE_RE = re.compile(r"^- \[(?P[a-z0-9-]+)\]\s+(?P.+)$") SCENE_RE = re.compile(r"^## (?PP\d+)\b") +FORMAT_DOC = "media/pipeline/README.md 第二节格式契约" def main() -> None: @@ -28,19 +30,33 @@ def main() -> None: src = root / "script" / "narration.md" dst = root / "script" / "narration.json" + # 与 tts.py 同口径的可操作退出(而非裸 FileNotFoundError 栈) + if not src.is_file(): + sys.exit(f"narration.md 不存在: {src} —— 见 {FORMAT_DOC}") + scene = "" items: list[dict[str, str]] = [] seen: set[str] = set() - for raw in src.read_text(encoding="utf-8").splitlines(): + for lineno, raw in enumerate(src.read_text(encoding="utf-8").splitlines(), 1): if m := SCENE_RE.match(raw): scene = m.group("scene") continue if m := LINE_RE.match(raw): sid, text = m.group("id"), m.group("text").strip() + if not scene: + # 幕名为空时下一条校验会报出「与所在幕 不一致」这种令人困惑的信息, + # 故先明确指出真正的原因:首句之前缺 `## Pn` 标题。 + sys.exit( + f"{src}:{lineno} 句 {sid} 出现在任何 `## Pn` 分幕标题之前 —— " + f"每句必须归属于某一幕,见 {FORMAT_DOC}" + ) if sid in seen: - raise SystemExit(f"重复句 id: {sid}") + sys.exit(f"{src}:{lineno} 重复句 id: {sid}") if not sid.startswith(scene.lower() + "-"): - raise SystemExit(f"句 id {sid} 与所在幕 {scene} 不一致") + sys.exit( + f"{src}:{lineno} 句 id {sid} 与所在幕 {scene} 不一致 —— " + f"句 id 必须以幕名小写为前缀(应为 {scene.lower()}-…)" + ) seen.add(sid) items.append({"id": sid, "scene": scene, "text": text}) diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index 1b65a6c5..2dbec64e 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -226,6 +226,49 @@ def steady_match( return any(sid.startswith(p) for p in prefixes) +# ---------------- 音色签名护栏(防整集静默改写) ---------------- + +#: 音频目录下的音色签名标记。逐句 `{id}.sha` 只能发现「这一句该重合成」, +#: 发现不了「整集正被换成另一种声音」——因为换音色时每一句的摘要都合法地变了。 +#: 本标记补的正是这个缺口:它记录上次成功合成的引擎/音色/风格, +#: 与本次不一致时硬失败,要求显式 --allow-voice-switch。不参与逐句摘要,故零缓存影响。 +ENGINE_MARKER = ".engine" + +#: tts_server.py 的启动命令此前有三份副本(本文件、tts_sample.py、VOICE-CLONING.md), +#: 其中一份还留着 `<仓库路径>` 占位符——粘贴即错。收敛为唯一生成处。 +SERVER_SCRIPT = Path(__file__).resolve().parent / "tts_server.py" + + +def server_launch_hint(port: int = 8766) -> str: + """返回可直接粘贴的 IndexTTS 服务启动命令(服务须运行在 index-tts 自己的环境里)。""" + return ( + " cd ~/tools/index-tts && uv run --frozen --with fastapi --with uvicorn \\\n" + " --with soundfile --with numpy --with lameenc \\\n" + f" python {SERVER_SCRIPT} --model-dir checkpoints --port {port}" + ) + + +def check_voice_marker(out_dir: Path, signature: str, allow_switch: bool) -> None: + """比对音色签名;不一致且未显式放行时硬失败。""" + marker = out_dir / ENGINE_MARKER + if not marker.exists(): + return + previous = marker.read_text(encoding="utf-8").strip() + if previous == signature or allow_switch: + return + sys.exit( + f"音色签名与上次合成不一致,将整集改写({len(list(out_dir.glob('*.mp3')))} 个 mp3 单槽位覆盖):\n" + f" 上次: {previous}\n" + f" 本次: {signature}\n" + f"若确为有意重录,请显式加 --allow-voice-switch;否则请检查 --engine/--ref/--style 是否写错。\n" + f"详见 {MANUAL} §六" + ) + + +def write_voice_marker(out_dir: Path, signature: str) -> None: + (out_dir / ENGINE_MARKER).write_text(signature + "\n", encoding="utf-8") + + # ---------------- 引擎一:edge-tts(历史路径,保持字节级一致) ---------------- @@ -503,6 +546,11 @@ async def main() -> None: ) parser.add_argument("--rate", default=DEFAULT_RATE, help="[edge] 语速(默认 +4%%)") parser.add_argument("--force", action="store_true", help="忽略缓存强制重合成") + parser.add_argument( + "--allow-voice-switch", + action="store_true", + help="放行音色签名变更(引擎/音色/风格换档=整集重录,须显式确认)", + ) parser.add_argument("--list-styles", action="store_true", help="列出风格预设并退出") idx = parser.add_argument_group("indextts 声音克隆") @@ -610,28 +658,44 @@ async def main() -> None: parser.error( "--plan 仅对 --engine indextts 生效(是否漏写 --engine indextts?)" ) - ignored = [ + # 克隆专属参数分两类处置。**带值参数硬失败**:漏写 --engine indextts 的典型手型 + # 就是「照抄文档打了 --ref/--style 却丢了 --engine」,而 {id}.mp3 是单槽位、 + # 两引擎摘要必然不同 ⇒ 照跑就会把整集克隆音频静默改写成 edge 预置音色。 + # 上面 --plan 已按此论证硬化,合成路径同理(否则只硬化了「看」、没硬化「跑」)。 + clone_only = [ flag for flag, val in { "--ref": args.ref, "--emo-vector": args.emo_vector, "--emo-ref": args.emo_ref, "--emo-text": args.emo_text, - "--emo-alpha": args.emo_alpha, - "--duration-factor": args.duration_factor, + "--emo-alpha": args.emo_alpha is not None, + "--duration-factor": args.duration_factor is not None, "--num-beams": args.num_beams is not None, "--steady": args.steady, - "--steady-beams": args.steady_beams != 3, - "--server": args.server != "http://127.0.0.1:8766", "--style": args.style != "neutral", "--lang": args.lang != "ZH", + }.items() + if val + ] + if clone_only: + parser.error( + f"以下参数仅对 --engine indextts 生效: {' '.join(clone_only)}" + "(是否漏写 --engine indextts?若确实要用 edge 预置音色,请去掉这些参数)" + ) + # 无副作用的旁路参数:给不到默认值也不会改变输出,只提示不失败 + benign = [ + flag + for flag, val in { + "--steady-beams": args.steady_beams != 3, + "--server": args.server != "http://127.0.0.1:8766", "--engine-tag": args.engine_tag != "indextts", }.items() if val ] - if ignored: + if benign: print( - f"提示:以下参数仅对 --engine indextts 生效,已忽略: {' '.join(ignored)}", + f"提示:以下参数仅对 --engine indextts 生效,已忽略: {' '.join(benign)}", file=sys.stderr, ) @@ -644,6 +708,8 @@ async def main() -> None: out_dir.mkdir(parents=True, exist_ok=True) if args.engine == "edge": + signature = f"edge|{args.voice}|{args.rate}" + check_voice_marker(out_dir, signature, args.allow_voice_switch) sem = asyncio.Semaphore(CONCURRENCY_EDGE) results = await asyncio.gather( *( @@ -727,6 +793,10 @@ async def main() -> None: if steady_match(i, sids, scenes, prefixes): beams_of[i["id"]] = args.steady_beams + signature = f"indextts|{args.engine_tag}|{style_name}|{ref_sha1}" + # 在 --plan 之前检查:排期正是发现「这次长跑将整集换音色」的最佳时机 + check_voice_marker(out_dir, signature, args.allow_voice_switch) + if args.plan: # 计划模式:纯本地计算,不连服务 print( f">> 计划:{root.name} · 风格 {style_name} · alpha {alpha:g} · 语速 {df:g}" @@ -785,9 +855,7 @@ async def main() -> None: except Exception as e: # noqa: BLE001 - 服务未启动给出可操作指引 print( f"IndexTTS 服务不可用({e})。请先启动:\n" - f" cd ~/tools/index-tts && uv run --frozen --with fastapi --with uvicorn --with soundfile \\\n" - f" --with numpy --with lameenc python <仓库路径>/media/pipeline/scripts/tts_server.py \\\n" - f" --model-dir checkpoints --port 8766\n" + f"{server_launch_hint()}\n" f"详见 {MANUAL} §二", file=sys.stderr, ) @@ -834,6 +902,7 @@ async def main() -> None: manifest_path.write_text( json.dumps(results, ensure_ascii=False, indent=1) + "\n", encoding="utf-8" ) + write_voice_marker(out_dir, signature) # 成功收尾才落签名,中途失败不改变基线 total = sum(r["durationSec"] for r in results) print(f"合成 {len(results)} 句,纯语音总时长 {total / 60:.2f} 分钟") if args.engine == "indextts" and args.steady: # 混合档:回执两档各多少句,便于对账 diff --git a/media/pipeline/scripts/tts_sample.py b/media/pipeline/scripts/tts_sample.py index ae1d2198..a029cbeb 100644 --- a/media/pipeline/scripts/tts_sample.py +++ b/media/pipeline/scripts/tts_sample.py @@ -40,10 +40,10 @@ http_synthesize, mp3_duration, resolve_style, + server_launch_hint, tts_text, ) -SERVER_SCRIPT = Path(__file__).resolve().parent / "tts_server.py" # parents: [0]=scripts [1]=pipeline [2]=media [3]=仓库根(与 prepare_ref.py 的 parents[1] 同惯例) DEFAULT_OUT_DIR = Path(__file__).resolve().parents[3] / ".temp" / "voice-samples" DEFAULT_TEXT = "自进化编码智能体的核心不是写代码,而是让 AI 学会修改自己写代码的方式。" @@ -95,9 +95,7 @@ def check_server( except Exception as e: # noqa: BLE001 - 任何不可达都归一为可操作指引 sys.exit( f"IndexTTS 服务不可用({e})。请先启动:\n" - f" cd ~/tools/index-tts && uv run --frozen --with fastapi --with uvicorn \\\n" - f" --with soundfile --with numpy --with lameenc \\\n" - f" python {SERVER_SCRIPT} --model-dir checkpoints --port 8766\n" + f"{server_launch_hint()}\n" f"详见 {MANUAL} §2.3" ) print( @@ -270,6 +268,11 @@ def main() -> None: help=f"产物目录(默认 {DEFAULT_OUT_DIR},已被 .gitignore 忽略)", ) parser.add_argument("--play", action="store_true", help="合成后用 afplay 顺序试听") + parser.add_argument( + "--cleanup", + action="store_true", + help="试听结束后立即删除产物目录(小样含本人音色,属生物特征信息)", + ) parser.add_argument( "--dry-run", action="store_true", help="只解析并打印参数,不连服务、不合成" ) @@ -434,9 +437,16 @@ def main() -> None: print( f"\n下一步 · 选定风格后全量合成一集:\n" f" cd media/<工程> && uv run --no-project --with mutagen scripts/tts.py \\\n" - f" --engine indextts --ref {ref} {chosen}\n" - f"清理 · 小样含本人音色(生物特征信息),试听后请删除:rm -rf {out_dir}" + f" --engine indextts --ref {ref} {chosen}" ) + if args.cleanup: + shutil.rmtree(out_dir, ignore_errors=True) + print(f"清理 · 已删除产物目录(生物特征信息):{out_dir}") + else: + print( + f"清理 · 小样含本人音色(生物特征信息),试听后请删除:rm -rf {out_dir}\n" + f" (下次可直接加 --cleanup 让脚本自动删除)" + ) if __name__ == "__main__": From 9f86f8d0bd6ec73b6444709de6af4dc9e3030278 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 22:13:22 +0800 Subject: [PATCH 02/40] =?UTF-8?q?feat(media-pipeline):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=AE=BA=E6=96=87=E6=8A=BD=E5=8F=96=E5=8F=96=E8=AF=81?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=20paper=5Fextract.py;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage ① 精读提取此前完全没有工具,每轮校准都临场手写一次性 pymupdf 片段。固化为 五个子命令:map(§→页码映射)/ text(按页取正文,默认双栏分列避免 LaTeX 两栏行 交错)/ captions(图表 caption 收割)/ find(逐条断言定点搜索,未命中退出码 1—— 「论文全文无此措辞」本身即取证结果,如编造数字的排除证明)/ render(页面光栅化 看图,无需 poppler/ffmpeg)。 依赖仅 pymupdf,uv run --no-project --with pymupdf 零工程调用。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- media/pipeline/scripts/paper_extract.py | 181 ++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 media/pipeline/scripts/paper_extract.py diff --git a/media/pipeline/scripts/paper_extract.py b/media/pipeline/scripts/paper_extract.py new file mode 100644 index 00000000..23bd8f17 --- /dev/null +++ b/media/pipeline/scripts/paper_extract.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""论文抽取工具(Stage ① 精读提取的取证工具箱)。 + +为「逐句断言 → 论文锚点」的可回溯校准提供机械化取证手段,替代此前每轮 +临场手写的一次性 pymupdf 片段。五个子命令覆盖 Stage ① 的全部取证需求: + + map § 编号 → 页码映射(先建映射,后续锚点一律写 §x.y (pN)) + text 按页范围取正文,**默认分栏**(A4 双栏 LaTeX 直接 get_text 会交错两栏) + captions 图表 caption 收割(喂给分镜的动画规格) + find 逐条断言定点搜索,返回命中处上下文(校准表的主力) + render 指定页光栅化为 PNG(看图;无需 poppler/ffmpeg) + +依赖仅 pymupdf,从仓库根调用: + + uv run --no-project --with pymupdf media/pipeline/scripts/paper_extract.py \\ + "<论文.pdf>" find "far more frequently and cheaply" + +注意:论文 PDF 通常不入库(根 .gitignore 屏蔽 /assets/),须传绝对路径; +文件名含 `:` 等字符时须加引号。 +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +try: + import pymupdf +except ImportError: # pragma: no cover - 依赖缺失时给可操作提示 + sys.exit( + "缺少 pymupdf —— 请以 `uv run --no-project --with pymupdf` 方式调用本脚本" + ) + +# § 标题行:编号 + 标题正文。要求标题以大写字母开头,排除正文里的「1. 」列表项 +SECTION_RE = re.compile(r"^\s*(\d+(?:\.\d+)*)\.?\s+([A-Z][^\n]{3,80})$", re.M) +# 图表 caption:论文用 `Figure 1 | …` 与 `Table 1 | …`,也兼容 `:`/`.` 分隔 +CAPTION_RE = re.compile(r"(Figure|Table)\s+(\d+)\s*[|:.]?\s*(.{0,400})", re.S) + + +def open_doc(path: str) -> pymupdf.Document: + p = Path(path).expanduser() + if not p.is_file(): + sys.exit(f"PDF 不存在: {p}") + return pymupdf.open(p) + + +def parse_pages(spec: str, page_count: int) -> list[int]: + """解析页码表达式为 0-based 页索引列表。支持 `12-24`、`1,7,8`、`3-5,9`。""" + pages: list[int] = [] + for chunk in spec.split(","): + chunk = chunk.strip() + if not chunk: + continue + if "-" in chunk: + lo_s, _, hi_s = chunk.partition("-") + lo, hi = int(lo_s), int(hi_s) + else: + lo = hi = int(chunk) + if lo < 1 or hi > page_count or lo > hi: + sys.exit(f"页码区间越界: {chunk}(PDF 共 {page_count} 页)") + pages.extend(range(lo - 1, hi)) + if not pages: + sys.exit("未解析出任何页码") + return pages + + +def cmd_map(doc: pymupdf.Document, args: argparse.Namespace) -> None: + print(f"# § → 页码映射(共 {doc.page_count} 页)") + for i, page in enumerate(doc): + hits = SECTION_RE.findall(page.get_text("text")) + if hits: + joined = " · ".join(f"{num} {title.strip()[:60]}" for num, title in hits[: args.max_per_page]) + print(f"p{i + 1:>3} {joined}") + + +def page_columns(page: pymupdf.Page, columns: int) -> list[str]: + """把一页按等宽竖切成 N 栏分别取文,避免双栏排版的行交错。""" + if columns <= 1: + return [page.get_text("text", sort=True)] + r = page.rect + width = (r.x1 - r.x0) / columns + out = [] + for c in range(columns): + clip = pymupdf.Rect(r.x0 + c * width, r.y0, r.x0 + (c + 1) * width, r.y1) + out.append(page.get_text("text", clip=clip, sort=True)) + return out + + +def cmd_text(doc: pymupdf.Document, args: argparse.Namespace) -> None: + labels = "LMRSTUV" # 栏标记,最多 7 栏够用 + for i in parse_pages(args.pages, doc.page_count): + for c, chunk in enumerate(page_columns(doc[i], args.columns)): + tag = labels[c] if args.columns > 1 else "" + print(f"\n===== p{i + 1} {tag} =====") + print(chunk.rstrip()) + + +def cmd_captions(doc: pymupdf.Document, args: argparse.Namespace) -> None: + print("# 图表 caption 收割") + for i, page in enumerate(doc): + for m in CAPTION_RE.finditer(page.get_text("text", sort=True)): + body = " ".join(m.group(3).split())[: args.chars] + if len(body) < 12: # 过滤正文里的「Figure 2 makes this explicit」式交叉引用 + continue + print(f"p{i + 1:>3} {m.group(1)} {m.group(2)}: {body}") + + +def cmd_find(doc: pymupdf.Document, args: argparse.Namespace) -> None: + total = 0 + for i, page in enumerate(doc): + for rect in page.search_for(args.query): + clip = pymupdf.Rect( + rect.x0 - args.context, rect.y0 - 40, rect.x1 + args.context, rect.y1 + 80 + ) + block = " ".join(page.get_text("text", clip=clip, sort=True).split()) + print(f"p{i + 1:>3} {block[: args.chars]}") + print("---") + total += 1 + if args.limit and total >= args.limit: + print(f"(已达 --limit {args.limit},停止)") + return + if total == 0: + # 命中为 0 是有意义的取证结果:说明该措辞不在论文里(例如编造的数字) + print(f"(未命中)query={args.query!r} —— 论文全文无此措辞") + sys.exit(1) + + +def cmd_render(doc: pymupdf.Document, args: argparse.Namespace) -> None: + out_dir = Path(args.out).expanduser() + out_dir.mkdir(parents=True, exist_ok=True) + for i in parse_pages(args.pages, doc.page_count): + dest = out_dir / f"p{i + 1:03d}.png" + doc[i].get_pixmap(dpi=args.dpi).save(dest) + print(f"{dest} ({dest.stat().st_size // 1024} KB)") + print(f"\n共 {len(parse_pages(args.pages, doc.page_count))} 页 → {out_dir}") + print("⚠️ 这些是论文原图的参考副本,仅供查看以撰写动画规格;不得入库、不得出片。") + + +def main() -> None: + ap = argparse.ArgumentParser( + description="论文抽取工具(Stage ① 取证工具箱)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + ap.add_argument("pdf", help="论文 PDF 路径(文件名含特殊字符时加引号)") + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("map", help="§ 编号 → 页码映射") + p.add_argument("--max-per-page", type=int, default=4, help="每页最多列出几个标题(默认 4)") + p.set_defaults(func=cmd_map) + + p = sub.add_parser("text", help="按页范围取正文(默认双栏分列)") + p.add_argument("--pages", required=True, help="页码,如 12-24 或 1,7,8") + p.add_argument("--columns", type=int, default=2, help="分栏数,单栏排版传 1(默认 2)") + p.set_defaults(func=cmd_text) + + p = sub.add_parser("captions", help="图表 caption 收割") + p.add_argument("--chars", type=int, default=300, help="每条 caption 截断长度(默认 300)") + p.set_defaults(func=cmd_captions) + + p = sub.add_parser("find", help="定点搜索并返回上下文(未命中时退出码 1)") + p.add_argument("query", help="要检索的原文措辞") + p.add_argument("--context", type=int, default=280, help="左右扩展像素(默认 280)") + p.add_argument("--chars", type=int, default=400, help="每条上下文截断长度(默认 400)") + p.add_argument("--limit", type=int, default=6, help="最多返回几条,0 = 不限(默认 6)") + p.set_defaults(func=cmd_find) + + p = sub.add_parser("render", help="指定页光栅化为 PNG(看图,无需 poppler)") + p.add_argument("--pages", required=True, help="页码,如 26,32,39,50") + p.add_argument("--out", required=True, help="输出目录(须在 gitignored 路径下)") + p.add_argument("--dpi", type=int, default=170, help="光栅化 DPI(默认 170)") + p.set_defaults(func=cmd_render) + + args = ap.parse_args() + args.func(open_doc(args.pdf), args) + + +if __name__ == "__main__": + main() From 3eb43dfa87b8b16eaf6c4a6eea8eca541e7116d2 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 22:13:44 +0800 Subject: [PATCH 03/40] =?UTF-8?q?docs(media-pipeline):=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=20video-package=20=E5=B7=B2=E5=BA=9F=E5=BC=83?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E7=9A=84=E5=9B=9B=E5=A4=84=E6=AD=BB=E9=93=BE?= =?UTF-8?q?;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit f7d72814 删除 video-package/ 时漏改引用:pipeline/README.md 首行轻量替代 链接、「七、工程模式」双模式对照表两处、knowledge-map.md:73 整条制作包 bullet。 对照表收敛为一句「本仓统一采用 Remotion 工程模式」;「首个完整范例」消歧为建成 时间口径(与系列发布顺序是两条轴,发布顺序另见 series.json——该 SSOT 随后续提交 落地,链接先行指向目标位置)。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- docs/.agents/knowledge-map.md | 1 - media/pipeline/README.md | 10 ++-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index 6be36de5..e5b47a2b 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -70,6 +70,5 @@ - [自进化 Agents Team 调研](../research/self-evolution/130-self-evolving-agents-team.md) — 自进化智能体理论(DGM/ADAS/AlphaEvolve/AgentSquare)+ 进化算子(GEPA/ACE/DSPy)+ 评测回路(Agent-as-a-Judge/OTel/Langfuse)+ 工具生态自进化(MCP Registry/Agent Skills/LLM 自造工具)+ 记忆/知识系统自进化(MemGPT/Mem0/A-Mem 自编辑记忆、ReasoningBank/Memp 经验沉淀、MemEvolve/MemSkill 记忆元进化、Zep/HippoRAG 2 图谱记忆、SSGM/MINJA 记忆治理)+ 护栏治理(OWASP Agentic Top 10/金丝雀/Goodhart 防护),映射至四层自进化架构(技术方案见 [自进化 Agents Team 方案](../concepts/design/self-evolving-agents.md)) - [经验时代的自驱迭代进化智能体调研](../research/self-evolution/140-experience-era-self-improvement.md) — 精读 88 页综述提炼 Harness 经验基础设施框架,对照 negentropy Routine 闭环诊断出两处根本断点(Judge 无历史锚点 ±20 振荡 / `decay_override` 死配置致经验记忆 7-8 天全灭 + 反馈链断),落地双支柱改进:证据锚定纵向评估(trajectory + progress_evidence + 量化振荡 opt-in)与经验记忆闭环补强(衰减修复 E / 检索反馈闭环 B / 写入去重准入 A / 失败教训结构化与注入 C-D) - [Skill 进化闭环 × 自我改进评测](../research/self-evolution/141-skills-evolution-and-si-measurement.md) — 综述 §3(Skills 三阶段 Evolution 缺口)/ §7(Meta-Evolving 三体制)/ §8(SI 六目标 + SIP-Bench + 反事实归因)映射到 negentropy:PR [#1038](https://github.com/ThreeFish-AI/negentropy/pull/1038) 落地 eval 四表 + held-out 双相门(decide_skill_shadow/canary)+ 反事实 Skill Influence Pattern + TargetHandler 抽象 + SkillTemplateHandler 闭环(GEPA 变异 prompt_template + active_version 发布),补 140 号未覆盖的 Skills/Meta/SI 度量框架 -- [arXiv §5 科普视频制作包](../../video-package/README.md) — 基于 [141 号调研同源综述](../research/self-evolution/141-skills-evolution-and-si-measurement.md)(arXiv:2607.13104 §5 基座模型自我改进)的完整视频制作包:13:42 逐字稿(N0–N6 段落 ID 主键体系,4.7 字/秒自洽)+ 46 镜头分镜表(md/csv 双格式,822s 与逐字稿/动画三表对齐)+ 单文件 Canvas 动画 demo(1920×1080、8 场景 3B1B 风格、←/→/空格/R/1-8/H 快捷键、file:// 零依赖直开)+ 71 条事实核查表(引文 100% grep 验证命中论文原文、RISKY/REWRITE 双零、ANALOGY 类比显式登记) - [科普视频制作 Pipeline(公共基建)](../../media/pipeline/README.md) — 全仓可复用的论文→视频九阶段流水线(精读提取含官方工程站点信源补充/策划/逐字稿 SSOT/双重校验/分镜/TTS/Remotion/抽帧 QA/终渲):中心脚本三件套(`--project` 参数化)+ 每 Stage 代理提示词规格(skills/01–06:01–05 内容层 + 06 生产层 Remotion 实现)+ 新集脚手架清单与复用边界(Python 脚本集中 SSOT、Remotion 原语复制适配);作品:[《AI 如何自己变强?》](../../media/self-improving-agents-video/README.md)(Schmidhuber 综述 · 蓝/橙契约 · 6 幕)、[《上线之后,AI 才开始上学》](../../media/experience-era-agents-video/README.md)([140 号调研](../research/self-evolution/140-experience-era-self-improvement.md)同源清华×Frontis 综述 · 金/青/紫契约 · 7 幕 13.6 分钟)与 [《会写代码的 AI,开始给自己写代码》](../../media/self-evolving-coding-agents-video/README.md)(arXiv:2608.03392 NJUST×NJU 综述 · 终端绿/洋红契约 · 7 幕 14.7 分钟,五对象×时机证据×可信进化,论文笔记由 8 并行代理逐章精读产出);配音支持用自己的声音克隆(明快阳光 sunny 推荐位 + 激情/轻快/自信/正能量,另有语调迁移 `--emo-ref` 与自然语言 `--emo-text` 两条情感通路;单句小样试听 `tts_sample.py`、样本选段勘探 `prospect_ref.py`,见 [VOICE-CLONING.md](../../media/pipeline/VOICE-CLONING.md);2026-08 三集统一换用本人音色 passionate 风格 + 内容升级审计文档 [upgrade-2026-08.md](../../media/self-improving-agents-video/research/upgrade-2026-08.md) 各集一份,站点信源补充规范沉淀于 skills/01) - [自进化 Agents Team 方案(Phase 3 记忆检索面已落地)](../concepts/design/self-evolving-agents.md) — 四层自进化架构:本次落地 `engine/evolution/` 子系统(GEPA proposer + 状态机 + decision 护栏)并在记忆检索权重面接通 propose→shadow→canary→promote/rollback 全闭环(迁移 0081 + evolution_inspector),默认全关灰度;agent/skill/knowledge 面、Phase 1 tool_invocations 遥测、eval 四表留后续 diff --git a/media/pipeline/README.md b/media/pipeline/README.md index d5676f3a..09ea5112 100644 --- a/media/pipeline/README.md +++ b/media/pipeline/README.md @@ -1,7 +1,7 @@ # 科普视频制作 Pipeline(公共基建) > 从「论文精读 → 逐字稿 → 配音 → 代码动画 → 终渲」全链路中沉淀的**仓库级可复用流水线**。 -> 首个完整范例:[《AI 如何自己变强?》](../self-improving-agents-video/README.md)(Remotion 工程模式);轻量替代:[video-package 制作包模式](../../video-package/README.md)。 +> 首个建成的完整范例:[《AI 如何自己变强?》](../self-improving-agents-video/README.md)(建成时间上的第一个,非系列首集;发布顺序见 [../series.json](../series.json))。 ## 一、Pipeline 总览(9 Stages) @@ -92,13 +92,7 @@ media/-video/ ## 七、工程模式 -| | ✅ Remotion 工程模式 | ❌ 轻量制作包模式 | -| ---- | ----------------------------------------------------------------- | ----------------------------------------------------------- | -| 载体 | `media/-video/video/`(Remotion + React) | `video-package/`(单文件 Canvas HTML) | -| 动画 | 全代码动画,可编程复渲 | 浏览器手动录屏 | -| 配音 | edge-tts + manifest 自动对轨 | 人工录音/剪辑对齐 | -| 适用 | 中长视频、多轮迭代、可复现 | 快速产出、低工程成本 | -| 范例 | [《AI 如何自己变强?》](../self-improving-agents-video/README.md) | [《当 AI 开始给自己当老师》](../../video-package/README.md) | +本仓统一采用 **Remotion 工程模式**(全代码动画 + manifest 自动对轨、可编程复渲)。早期的单文件 Canvas 轻量制作包模式已于 2026-08 废弃移除(见 commit `f7d72814`)。 ## 八、许可注意 From 131c2851078dc734ac5374aeb7a27347c29d8296 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 22:23:44 +0800 Subject: [PATCH 04/40] =?UTF-8?q?feat(media-pipeline):=20=E6=97=B6?= =?UTF-8?q?=E5=BA=8F=E5=B8=B8=E6=95=B0=20SSOT=E3=80=81=E5=88=86=E9=9B=86?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E5=BC=8F=E9=85=8D=E7=BD=AE=E4=B8=8E=E7=B3=BB?= =?UTF-8?q?=E5=88=97=E9=A1=BA=E5=BA=8F=E6=B8=85=E5=8D=95;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 时序常数单一事实源:每集 video/src/timing.json(timing.ts 经 resolveJsonModule 同步 import;Python 侧新增 timeline.py 直读同一文件)。qa_frames.py 删除手抄 镜像常量——两语言漂移从此结构性不可能,对拍验证新旧实现逐帧相等 - timing.ts 头部改为读 JSON 并导出 SCENE_FADE_FRAMES(幕间呼吸淡入淡出预留), 三集逐字节一致(md5 唯一),computeTimeline 函数体零改动 - 新增每集 pipeline.toml(episode/narration/tts/render):可执行参数不再散落 README 复制粘贴(治 ISSUE-161 类漂移);EP2/EP3 记录复现已上线音频的档, EP1 记录 sunny-steady + me-bright 重录决策 - tts.py 新增 --expect-ref-sha1 指纹硬校验;新增 refs.py + voices/refs.toml (只存哈希与生成参数,不存音频,.gitignore 白名单例外放行)——操作者可自证 复现了同一音色,防「样本换了静默重录整集」 - 新增 media/series.json + series.md:三集发布顺序机读 SSOT(EP1 经验时代 → EP2 自我变强 → EP3 代码田野)与「序号不进口播」系列纪律的载体 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .gitignore | 4 +- .../experience-era-agents-video/pipeline.toml | 24 +++ .../video/src/timing.json | 8 + .../video/src/timing.ts | 15 +- media/pipeline/scripts/qa_frames.py | 34 ++--- media/pipeline/scripts/refs.py | 138 ++++++++++++++++++ media/pipeline/scripts/timeline.py | 88 +++++++++++ media/pipeline/scripts/tts.py | 14 ++ media/pipeline/voices/refs.toml | 23 +++ .../pipeline.toml | 25 ++++ .../video/src/timing.json | 8 + .../video/src/timing.ts | 15 +- .../self-improving-agents-video/pipeline.toml | 25 ++++ .../video/src/timing.json | 8 + .../video/src/timing.ts | 15 +- media/series.json | 66 +++++++++ media/series.md | 14 ++ 17 files changed, 489 insertions(+), 35 deletions(-) create mode 100644 media/experience-era-agents-video/pipeline.toml create mode 100644 media/experience-era-agents-video/video/src/timing.json create mode 100644 media/pipeline/scripts/refs.py create mode 100644 media/pipeline/scripts/timeline.py create mode 100644 media/pipeline/voices/refs.toml create mode 100644 media/self-evolving-coding-agents-video/pipeline.toml create mode 100644 media/self-evolving-coding-agents-video/video/src/timing.json create mode 100644 media/self-improving-agents-video/pipeline.toml create mode 100644 media/self-improving-agents-video/video/src/timing.json create mode 100644 media/series.json create mode 100644 media/series.md diff --git a/.gitignore b/.gitignore index 081e5beb..f2fb968f 100644 --- a/.gitignore +++ b/.gitignore @@ -311,9 +311,11 @@ media/experience-era-agents-video/**/*.mp3 media/experience-era-agents-video/**/*.wav # 声音克隆参考样本(media/pipeline/voices/):个人声音属生物特征信息,绝不入库。 -# 目录级忽略 + README 白名单,防漏网音频格式(ogg/opus/wma 等)。 +# 目录级忽略 + 白名单例外(README 与 refs.toml 指纹清单——后者只存哈希与生成参数, +# 不含音频字节),防漏网音频格式(ogg/opus/wma 等)。 media/pipeline/voices/* !media/pipeline/voices/README.md +!media/pipeline/voices/refs.toml # 科普视频工程(media/self-evolving-coding-agents-video)本地产物:同上集规则。 media/self-evolving-coding-agents-video/video/public/audio/ diff --git a/media/experience-era-agents-video/pipeline.toml b/media/experience-era-agents-video/pipeline.toml new file mode 100644 index 00000000..e8df451a --- /dev/null +++ b/media/experience-era-agents-video/pipeline.toml @@ -0,0 +1,24 @@ +# 《上线之后,AI 才开始上学》管线配置——pipeline.py 的默认参数源。 +# 可执行参数只落此处,README 不再复制粘贴命令行(防 ISSUE-161 类漂移); +# CLI 显式参数仍可覆盖本文件。 + +[episode] +slug = "experience-era-agents-video" +title = "上线之后,AI 才开始上学" + +[narration] +# 时长预算门(check_script.py 两口径都查:字数估算 + manifest 实测) +target_minutes = [13.0, 14.2] +chars_per_min = 280 # 与 build_narration.py 既有估算口径一致 + +[tts] +engine = "indextts" +ref = "media/pipeline/voices/me-bright.wav" # 仓库根相对 +ref_sha1 = "54b699cce97f" # --expect-ref-sha1 硬校验(12 位,同 tts.py 口径) +style = "sunny-steady" # 2026-08 重录决策:成片档 beams=3(首集全片换 sunny-steady) +lang = "ZH" +# 两遍法:草稿遍用 CLI 覆写 --style sunny(快 3.4 倍),定稿遍回到本配置默认值。 + +[render] +draft_scale = 0.5 +draft_jpeg_quality = 60 diff --git a/media/experience-era-agents-video/video/src/timing.json b/media/experience-era-agents-video/video/src/timing.json new file mode 100644 index 00000000..1090a53b --- /dev/null +++ b/media/experience-era-agents-video/video/src/timing.json @@ -0,0 +1,8 @@ +{ + "fps": 30, + "sentenceGapSec": 0.32, + "sceneGapSec": 0.9, + "leadInSec": 0.6, + "tailSec": 2.0, + "sceneCrossFadeSec": 0.4 +} diff --git a/media/experience-era-agents-video/video/src/timing.ts b/media/experience-era-agents-video/video/src/timing.ts index 8bf6f2a5..6d2379ad 100644 --- a/media/experience-era-agents-video/video/src/timing.ts +++ b/media/experience-era-agents-video/video/src/timing.ts @@ -1,14 +1,19 @@ import type {ManifestItem, SceneRange, TimedSentence} from './types'; +import constants from './timing.json'; -export const FPS = 30; +/** 时序常量单一事实源:本目录 timing.json(qa_frames.py / captions.py 读同一文件, + * 双语言镜像由此消灭;本集要调节奏只改 timing.json,不动代码) */ +export const FPS = constants.fps; /** 句间停顿 */ -const SENTENCE_GAP_SEC = 0.32; +const SENTENCE_GAP_SEC = constants.sentenceGapSec; /** 幕间额外停顿(转场呼吸) */ -const SCENE_GAP_SEC = 0.9; +const SCENE_GAP_SEC = constants.sceneGapSec; /** 片头静默引导 */ -const LEAD_IN_SEC = 0.6; +const LEAD_IN_SEC = constants.leadInSec; /** 片尾静默淡出 */ -const TAIL_SEC = 2.0; +const TAIL_SEC = constants.tailSec; +/** 幕间呼吸淡入淡出帧数(见 components/SceneFade.tsx;首幕不淡入、末幕不淡出) */ +export const SCENE_FADE_FRAMES = Math.round(constants.sceneCrossFadeSec * constants.fps); export function computeTimeline(manifest: ManifestItem[]): { timed: TimedSentence[]; diff --git a/media/pipeline/scripts/qa_frames.py b/media/pipeline/scripts/qa_frames.py index 918ae30b..b7900b9c 100644 --- a/media/pipeline/scripts/qa_frames.py +++ b/media/pipeline/scripts/qa_frames.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """按句 id 从渲染产物中抽帧,用于视觉 QA——公共管线版本。 -时序常量须与工程的 video/src/timing.ts 保持一致(FPS/句间停顿/幕间停顿/片头引导)。 -若工程自定义了 timing 常量,须同步本文件顶部的镜像常量。 +时序常数直读 <工程>/video/src/timing.json(单一事实源,与 timing.ts / captions.py +同源),镜像常量已删除——工程改节奏只动 timing.json,本脚本自动跟随。 用法:uv run --no-project media/pipeline/scripts/qa_frames.py --project media/<工程> \ <句id> [句id ...] @@ -16,26 +16,23 @@ import argparse import json import subprocess +import sys from pathlib import Path -# 与各工程 video/src/timing.ts 对齐(管线默认值;改过 timing 的工程须同步) -FPS = 30 -SENTENCE_GAP = 0.32 -SCENE_GAP = 0.9 -LEAD_IN = 0.6 +sys.path.insert(0, str(Path(__file__).resolve().parent)) # noqa: E402 - 导入同目录 timeline +from timeline import compute, load_constants # noqa: E402 -def timeline(manifest: Path) -> dict[str, tuple[float, float]]: +def timeline(root: Path) -> dict[str, tuple[float, float]]: + """读 manifest + timing.json,返回 {句id: (startSec, spanSec)}。""" + manifest = root / "video" / "public" / "audio" / "manifest.json" + if not manifest.is_file(): + sys.exit( + f"manifest.json 不存在: {manifest} —— 先运行 scripts/tts.py 合成配音" + ) items = json.loads(manifest.read_text(encoding="utf-8")) - result: dict[str, tuple[float, float]] = {} - cursor_frames = round(LEAD_IN * FPS) - for i, item in enumerate(items): - nxt = items[i + 1] if i + 1 < len(items) else None - gap = SENTENCE_GAP + (SCENE_GAP if nxt and nxt["scene"] != item["scene"] else 0) - dur_frames = max(1, round((item["durationSec"] + gap) * FPS)) - result[item["id"]] = (cursor_frames / FPS, dur_frames / FPS) - cursor_frames += dur_frames - return result + c = load_constants(root) + return {r["id"]: (r["startSec"], r["spanSec"]) for r in compute(items, c)} def main() -> None: @@ -58,10 +55,9 @@ def main() -> None: root = Path(args.project).resolve() video = Path(args.video).resolve() - manifest = root / "video" / "public" / "audio" / "manifest.json" out = root / "out" / "frames" - tl = timeline(manifest) + tl = timeline(root) offset = args.offset if args.scene: prefix = args.scene.lower() + "-" diff --git a/media/pipeline/scripts/refs.py b/media/pipeline/scripts/refs.py new file mode 100644 index 00000000..3c0d3826 --- /dev/null +++ b/media/pipeline/scripts/refs.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""参考音色样本可复现清单——不存音频,只存「怎么造出来的」与指纹。 + +背景:voices/ 目录因生物特征隐私整目录 gitignore,导致「官方」样本参数与 +sha1 只活在文档散文里(me-bright.wav 的 54b699cce97f 散见 VOICE-CLONING.md +三处)。换机器/换源文件后无人能自证复现了同一个音色,而 sunny/sunny-steady +的 alpha 标定**因果地**依赖 me-bright 这个选段——复现错了,10 小时的成片 +合成就在错误的音色上跑完。 + +本脚本 + voices/refs.toml(唯一入库文件,含 .gitignore 白名单例外)补上这条链: + + verify 重算盘上 WAV 指纹,对照清单报「一致 / 不一致 / 缺失」 + rebuild 按清单记录的参数转调 prepare_ref.py 重建样本,再自动 verify + list 列出清单条目 + +用法(仓库根,零第三方依赖;rebuild 才需要 soundfile/numpy): + uv run --no-project media/pipeline/scripts/refs.py list + uv run --no-project media/pipeline/scripts/refs.py verify [--name me-bright] + uv run --no-project --with soundfile --with numpy media/pipeline/scripts/refs.py \ + rebuild --name me-bright +""" + +from __future__ import annotations + +import argparse +import hashlib +import subprocess +import sys +import tomllib +from pathlib import Path + +SCRIPTS_DIR = Path(__file__).resolve().parent +REFS_TOML = SCRIPTS_DIR.parent / "voices" / "refs.toml" +VOICES_DIR = REFS_TOML.parent +#: 源录音为本人私有文件,仅记录相对家目录路径占位;真实路径在清单里以 ~ 展开 +PREPARE_REF = SCRIPTS_DIR / "prepare_ref.py" + + +def load_refs() -> dict: + if not REFS_TOML.is_file(): + sys.exit(f"清单不存在: {REFS_TOML}") + return tomllib.loads(REFS_TOML.read_text(encoding="utf-8")) + + +def digest(path: Path) -> tuple[str, str]: + data = path.read_bytes() + return hashlib.sha1(data).hexdigest()[:12], hashlib.sha256(data).hexdigest() + + +def cmd_list(refs: dict) -> None: + print(f"清单:{REFS_TOML}") + for name, r in refs.items(): + print( + f" {name:<10} {r['source']} --start {r['start']} --duration {r['duration']}" + f" sha1 {r['sha1']} 锁定风格 {','.join(r.get('locked_styles', []))}" + ) + + +def cmd_verify(refs: dict, args: argparse.Namespace) -> int: + names = [args.name] if args.name else list(refs) + failures = 0 + for name in names: + r = refs.get(name) + if r is None: + print(f"❌ {name}: 清单无此条目") + failures += 1 + continue + wav = VOICES_DIR / f"{name}.wav" + if not wav.is_file(): + print( + f"⚠️ {name}: 盘上缺失({wav})——音频不入库属预期;" + f"需要时用 rebuild 重建后复验" + ) + continue + sha1, sha256 = digest(wav) + if sha1 == r["sha1"]: + print(f"✅ {name}: sha1 一致({sha1})") + else: + print( + f"❌ {name}: sha1 不一致 —— 清单 {r['sha1']} vs 盘上 {sha1}\n" + f" 源文件或重采样路径已变,勿在未核验音色上跑长合成;" + f"若确为新样本,请更新 {REFS_TOML}" + ) + failures += 1 + continue + if r.get("sha256") and sha256 != r["sha256"]: + print(f"❌ {name}: sha256 不一致(完整指纹,人工核对用)") + failures += 1 + return failures + + +def cmd_rebuild(refs: dict, args: argparse.Namespace) -> int: + r = refs.get(args.name) + if r is None: + print(f"❌ 清单无此条目: {args.name}") + return 1 + source = Path(r["source"]).expanduser() + if not source.is_file(): + sys.exit( + f"源录音不存在: {source}\n 清单记录的是生成该样本时的源文件路径," + f"请把本人录音放回该位置或更新清单" + ) + cmd = [ + sys.executable, + str(PREPARE_REF), + str(source), + "--start", + str(r["start"]), + "--duration", + str(r["duration"]), + "--out", + str(VOICES_DIR / f"{args.name}.wav"), + ] + print(f">> 重建 {args.name}: {' '.join(cmd[1:])}") + subprocess.run(cmd, check=True) + return cmd_verify(refs, argparse.Namespace(name=args.name)) + + +def main() -> None: + ap = argparse.ArgumentParser(description="参考音色样本可复现清单") + sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("list", help="列出清单条目") + p = sub.add_parser("verify", help="核对盘上样本指纹") + p.add_argument("--name", help="只核对指定样本(默认全部)") + p = sub.add_parser("rebuild", help="按清单参数重建样本并复验") + p.add_argument("--name", required=True, help="要重建的样本名") + args = ap.parse_args() + + refs = load_refs() + if args.cmd == "list": + cmd_list(refs) + return + failures = {"verify": cmd_verify, "rebuild": cmd_rebuild}[args.cmd](refs, args) + sys.exit(1 if failures else 0) + + +if __name__ == "__main__": + main() diff --git a/media/pipeline/scripts/timeline.py b/media/pipeline/scripts/timeline.py new file mode 100644 index 00000000..9ff197e7 --- /dev/null +++ b/media/pipeline/scripts/timeline.py @@ -0,0 +1,88 @@ +"""时间轴计算的 Python 侧实现——与各集 video/src/timing.ts:computeTimeline 同构。 + +此前 qa_frames.py 顶部手抄了 timing.ts 的 4 个常量做镜像,唯一的守卫是 README 里 +一句散文;两处漂移会让所有抽帧时间静默错位。本模块改为直读每集 +`video/src/timing.json`(时序常数的单一事实源),与 timing.ts 同一份事实。 + +⚠️ 与 timing.ts 的对齐契约:compute() 的游标推进逻辑必须逐行同构—— + - 句时长 = max(1, round((durationSec + gap) * fps)),gap 在幕界 = 句间 + 幕间; + - 总时长 = 游标 + round(tailSec * fps)。 +任何一侧改动须同步另一侧,并以 tests/test_timeline.py 的黄金帧号兜底。 + +用法(被 qa_frames.py / captions.py 复用,也可独立调用对拍): + + from timeline import compute, load_constants + c = load_constants(Path("<工程根>")) + rows = compute(manifest_items, c) # [{id, fromFrame, durationInFrames, startSec, ...}] +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +#: timing.json 必备字段(与 video/src/timing.ts 的消费面一致) +REQUIRED_KEYS = ( + "fps", + "sentenceGapSec", + "sceneGapSec", + "leadInSec", + "tailSec", + "sceneCrossFadeSec", +) + + +def load_constants(project_root: Path) -> dict: + """读 <工程>/video/src/timing.json;缺失或字段不全时给可操作退出。""" + path = Path(project_root) / "video" / "src" / "timing.json" + if not path.is_file(): + sys.exit( + f"timing.json 不存在: {path}\n" + " —— 时序常数单一事实源缺失,请从任一既有集复制 video/src/timing.json" + "(qa_frames/captions 与 timing.ts 读同一文件,不可再手抄常量)" + ) + data = json.loads(path.read_text(encoding="utf-8")) + missing = [k for k in REQUIRED_KEYS if k not in data] + if missing: + sys.exit(f"{path} 缺少字段: {', '.join(missing)}") + return data + + +def compute(items: list[dict], c: dict) -> list[dict]: + """与 timing.ts:computeTimeline 同构的时间轴展开。 + + items 为 manifest 形态的 [{id, scene, text, durationSec}];返回逐句行,含 + fromFrame / durationInFrames(全片帧位)与 startSec / spanSec(秒,供抽帧与字幕)。 + spanSec 与 durationInFrames 一样含句间停顿(成片内该句占满的时间窗)。 + """ + fps = c["fps"] + timed: list[dict] = [] + cursor = round(c["leadInSec"] * fps) + for i, item in enumerate(items): + nxt = items[i + 1] if i + 1 < len(items) else None + gap = ( + c["sentenceGapSec"] + c["sceneGapSec"] + if nxt and nxt["scene"] != item["scene"] + else c["sentenceGapSec"] + ) + duration_in_frames = max(1, round((item["durationSec"] + gap) * fps)) + timed.append( + { + **item, + "fromFrame": cursor, + "durationInFrames": duration_in_frames, + "startSec": cursor / fps, + "spanSec": duration_in_frames / fps, + } + ) + cursor += duration_in_frames + return timed + + +def total_duration_in_frames(items: list[dict], c: dict) -> int: + rows = compute(items, c) + if not rows: + return round(c["tailSec"] * c["fps"]) + last = rows[-1] + return last["fromFrame"] + last["durationInFrames"] + round(c["tailSec"] * c["fps"]) diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index 2dbec64e..ec303a22 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -557,6 +557,13 @@ async def main() -> None: idx.add_argument( "--ref", default=None, help="[indextts] 参考音色样本路径(建议 5–15s 干净人声)" ) + idx.add_argument( + "--expect-ref-sha1", + default=None, + metavar="12HEX", + help="[indextts] 期望的参考样本 sha1 前 12 位,不符即硬失败——pipeline.py 从" + "各集 pipeline.toml 自动带上,防「换样本静默重录整集」(指纹清单见 voices/refs.toml)", + ) idx.add_argument( "--server", default="http://127.0.0.1:8766", help="[indextts] 服务地址" ) @@ -771,6 +778,13 @@ async def main() -> None: # 情感样本按内容入摘要:换情感录音必须失效缓存(与 ref 同口径) emo_ref_sha1 = hashlib.sha1(p.read_bytes()).hexdigest()[:12] ref_sha1 = hashlib.sha1(ref_path.read_bytes()).hexdigest()[:12] + # 指纹硬校验:样本即音色。换样本 = 整集换音色,必须在长跑开始前失败 + if args.expect_ref_sha1 and args.expect_ref_sha1 != ref_sha1: + parser.error( + f"参考样本指纹不符:期望 {args.expect_ref_sha1},实得 {ref_sha1}" + f"({ref_path})——源录音或裁剪参数已变。" + f"勿在未核验音色上跑长合成;指纹清单见 media/pipeline/voices/refs.toml" + ) # 混合档:解析选择器并逐句定束宽(此处即失败,避免典型的「id 拼错→静默全按低束宽跑完」) beams_of: dict[str, int] = dict.fromkeys((i["id"] for i in items), beams) diff --git a/media/pipeline/voices/refs.toml b/media/pipeline/voices/refs.toml new file mode 100644 index 00000000..c994b603 --- /dev/null +++ b/media/pipeline/voices/refs.toml @@ -0,0 +1,23 @@ +# 参考音色样本清单(唯一入库文件——voices/ 其余内容因生物特征隐私被 gitignore, +# 本文件经 .gitignore 白名单例外放行)。指纹口径:sha1 取前 12 位(同 tts.py 摘要), +# sha256 为完整指纹(人工核对用)。verify/rebuild 见 scripts/refs.py。 +# +# 关键背景:sunny / sunny-steady 预设的 alpha=0.35 是在 me-bright 选段上标定的 +#(Qwen 原始 Σ=0.8 会把克隆音高推到 199–223 Hz,本人自然区间 142–163 Hz), +# 复现的不是这个选段,标定即失效——重建后必须 verify。 + +[me-bright] +source = "~/Documents/dify/me-1.mp3" +start = 0.36 +duration = 12 +sha1 = "54b699cce97f" +sha256 = "" # 首次 verify 通过后回填(样本不在库,无法预置) +locked_styles = ["sunny", "sunny-steady"] + +[me-1] +source = "~/Documents/dify/me-1.mp3" +start = 180 +duration = 12 +sha1 = "3ed0d9d60d4b" +sha256 = "" +locked_styles = ["passionate", "neutral"] # 已上线三集成片所用样本 diff --git a/media/self-evolving-coding-agents-video/pipeline.toml b/media/self-evolving-coding-agents-video/pipeline.toml new file mode 100644 index 00000000..3892ff9b --- /dev/null +++ b/media/self-evolving-coding-agents-video/pipeline.toml @@ -0,0 +1,25 @@ +# 《会写代码的 AI,开始给自己写代码》管线配置——pipeline.py 的默认参数源。 +# 可执行参数只落此处,README 不再复制粘贴命令行(防 ISSUE-161 类漂移); +# CLI 显式参数仍可覆盖本文件。 + +[episode] +slug = "self-evolving-coding-agents-video" +title = "会写代码的 AI,开始给自己写代码" + +[narration] +target_minutes = [12.0, 15.0] +chars_per_min = 280 + +[tts] +# ⚠️ 本配置记录的是「复现已上线音频」的档:三集成片统一 passionate + me-1.wav。 +# 摘要含 style 与 ref_sha1,改档 = 整集重合成(须 --allow-voice-switch 显式放行)。 +# 升级 sunny-steady 属独立重录决策,登记于 media/series.json 的待升级项。 +engine = "indextts" +ref = "media/pipeline/voices/me-1.wav" +ref_sha1 = "3ed0d9d60d4b" +style = "passionate" +lang = "ZH" + +[render] +draft_scale = 0.5 +draft_jpeg_quality = 60 diff --git a/media/self-evolving-coding-agents-video/video/src/timing.json b/media/self-evolving-coding-agents-video/video/src/timing.json new file mode 100644 index 00000000..1090a53b --- /dev/null +++ b/media/self-evolving-coding-agents-video/video/src/timing.json @@ -0,0 +1,8 @@ +{ + "fps": 30, + "sentenceGapSec": 0.32, + "sceneGapSec": 0.9, + "leadInSec": 0.6, + "tailSec": 2.0, + "sceneCrossFadeSec": 0.4 +} diff --git a/media/self-evolving-coding-agents-video/video/src/timing.ts b/media/self-evolving-coding-agents-video/video/src/timing.ts index 8bf6f2a5..6d2379ad 100644 --- a/media/self-evolving-coding-agents-video/video/src/timing.ts +++ b/media/self-evolving-coding-agents-video/video/src/timing.ts @@ -1,14 +1,19 @@ import type {ManifestItem, SceneRange, TimedSentence} from './types'; +import constants from './timing.json'; -export const FPS = 30; +/** 时序常量单一事实源:本目录 timing.json(qa_frames.py / captions.py 读同一文件, + * 双语言镜像由此消灭;本集要调节奏只改 timing.json,不动代码) */ +export const FPS = constants.fps; /** 句间停顿 */ -const SENTENCE_GAP_SEC = 0.32; +const SENTENCE_GAP_SEC = constants.sentenceGapSec; /** 幕间额外停顿(转场呼吸) */ -const SCENE_GAP_SEC = 0.9; +const SCENE_GAP_SEC = constants.sceneGapSec; /** 片头静默引导 */ -const LEAD_IN_SEC = 0.6; +const LEAD_IN_SEC = constants.leadInSec; /** 片尾静默淡出 */ -const TAIL_SEC = 2.0; +const TAIL_SEC = constants.tailSec; +/** 幕间呼吸淡入淡出帧数(见 components/SceneFade.tsx;首幕不淡入、末幕不淡出) */ +export const SCENE_FADE_FRAMES = Math.round(constants.sceneCrossFadeSec * constants.fps); export function computeTimeline(manifest: ManifestItem[]): { timed: TimedSentence[]; diff --git a/media/self-improving-agents-video/pipeline.toml b/media/self-improving-agents-video/pipeline.toml new file mode 100644 index 00000000..645446f8 --- /dev/null +++ b/media/self-improving-agents-video/pipeline.toml @@ -0,0 +1,25 @@ +# 《AI 如何自己变强?》管线配置——pipeline.py 的默认参数源。 +# 可执行参数只落此处,README 不再复制粘贴命令行(防 ISSUE-161 类漂移); +# CLI 显式参数仍可覆盖本文件。 + +[episode] +slug = "self-improving-agents-video" +title = "AI 如何自己变强?" + +[narration] +target_minutes = [15.0, 18.0] +chars_per_min = 280 + +[tts] +# ⚠️ 本配置记录的是「复现已上线音频」的档:三集成片统一 passionate + me-1.wav。 +# 摘要含 style 与 ref_sha1,改档 = 整集重合成(须 --allow-voice-switch 显式放行)。 +# 升级 sunny-steady 属独立重录决策,登记于 media/series.json 的待升级项。 +engine = "indextts" +ref = "media/pipeline/voices/me-1.wav" +ref_sha1 = "3ed0d9d60d4b" +style = "passionate" +lang = "ZH" + +[render] +draft_scale = 0.5 +draft_jpeg_quality = 60 diff --git a/media/self-improving-agents-video/video/src/timing.json b/media/self-improving-agents-video/video/src/timing.json new file mode 100644 index 00000000..1090a53b --- /dev/null +++ b/media/self-improving-agents-video/video/src/timing.json @@ -0,0 +1,8 @@ +{ + "fps": 30, + "sentenceGapSec": 0.32, + "sceneGapSec": 0.9, + "leadInSec": 0.6, + "tailSec": 2.0, + "sceneCrossFadeSec": 0.4 +} diff --git a/media/self-improving-agents-video/video/src/timing.ts b/media/self-improving-agents-video/video/src/timing.ts index 8bf6f2a5..6d2379ad 100644 --- a/media/self-improving-agents-video/video/src/timing.ts +++ b/media/self-improving-agents-video/video/src/timing.ts @@ -1,14 +1,19 @@ import type {ManifestItem, SceneRange, TimedSentence} from './types'; +import constants from './timing.json'; -export const FPS = 30; +/** 时序常量单一事实源:本目录 timing.json(qa_frames.py / captions.py 读同一文件, + * 双语言镜像由此消灭;本集要调节奏只改 timing.json,不动代码) */ +export const FPS = constants.fps; /** 句间停顿 */ -const SENTENCE_GAP_SEC = 0.32; +const SENTENCE_GAP_SEC = constants.sentenceGapSec; /** 幕间额外停顿(转场呼吸) */ -const SCENE_GAP_SEC = 0.9; +const SCENE_GAP_SEC = constants.sceneGapSec; /** 片头静默引导 */ -const LEAD_IN_SEC = 0.6; +const LEAD_IN_SEC = constants.leadInSec; /** 片尾静默淡出 */ -const TAIL_SEC = 2.0; +const TAIL_SEC = constants.tailSec; +/** 幕间呼吸淡入淡出帧数(见 components/SceneFade.tsx;首幕不淡入、末幕不淡出) */ +export const SCENE_FADE_FRAMES = Math.round(constants.sceneCrossFadeSec * constants.fps); export function computeTimeline(manifest: ManifestItem[]): { timed: TimedSentence[]; diff --git a/media/series.json b/media/series.json new file mode 100644 index 00000000..02f1af55 --- /dev/null +++ b/media/series.json @@ -0,0 +1,66 @@ +{ + "series": { + "id": "self-evolution", + "title": "自进化系列科普视频", + "rule": "各集独立成片。口播文本严禁出现其他集标题、集数序号或「上一集/上期/第 N 集」等顺序词;序号只存在于视觉层与本文件,据此保证发布顺序变更的 TTS 代价为零(「下期再见」一类顺序无关的收尾语不受此限)。校验器:media/pipeline/scripts/check_series.py。" + }, + "episodes": [ + { + "episode": 1, + "slug": "experience-era-agents-video", + "path": "media/experience-era-agents-video", + "title": "上线之后,AI 才开始上学", + "topic": "部署之后经验怎么攒", + "cardSub": "经验 · 怎么攒", + "accents": ["#F5C542", "#2DD4BF", "#B78CFF"], + "paper": { + "title": "Self-Improving Agents in the Era of Experience: A Survey of Self- to Meta-Evolution", + "id": null, + "idNote": "无公开 arXiv 号(引用卡写机构+日期,不编造编号)", + "venue": "清华大学 × Horizon Research (Frontis.AI)", + "date": "2026-06" + }, + "status": "in-production", + "statusNote": "v3 重制中:内容校准 + sunny-steady 全片重配", + "voice": "sunny-steady + me-bright.wav(2026-08 重录决策)" + }, + { + "episode": 2, + "slug": "self-improving-agents-video", + "path": "media/self-improving-agents-video", + "title": "AI 如何自己变强?", + "topic": "自我进化改什么", + "cardSub": "自我进化 · 改什么", + "accents": ["#4A9EFF", "#FF9F45"], + "paper": { + "title": "Self-Improvements in Modern Agentic Systems: A Survey", + "id": "arXiv:2607.13104", + "idNote": "", + "venue": "吉林大学 × KAUST × Alberta × IDSIA(Schmidhuber 团队)", + "date": "2026-07" + }, + "status": "ready", + "statusNote": "待升级项:换 sunny-steady 重配(约 10h,未排期)", + "voice": "passionate + me-1.wav(已上线成片所用)" + }, + { + "episode": 3, + "slug": "self-evolving-coding-agents-video", + "path": "media/self-evolving-coding-agents-video", + "title": "会写代码的 AI,开始给自己写代码", + "topic": "代码领域全图", + "cardSub": "代码田野 · 全图", + "accents": ["#4ADE80", "#FF6EC7"], + "paper": { + "title": "Self-Evolving Coding Agents: A Survey", + "id": "arXiv:2608.03392", + "idNote": "", + "venue": "南京理工大学 × 南京大学", + "date": "2026-08" + }, + "status": "ready", + "statusNote": "源码含未重渲改动(系列三卡重排,见 PR 说明);待升级项:换 sunny-steady 重配", + "voice": "passionate + me-1.wav(已上线成片所用)" + } + ] +} diff --git a/media/series.md b/media/series.md new file mode 100644 index 00000000..8fb2589c --- /dev/null +++ b/media/series.md @@ -0,0 +1,14 @@ +# 自进化系列科普视频(作品总览) + +> 机读 SSOT:[series.json](./series.json)(顺序变更只改它 + 视觉层;口播永不携带序号, +> 校验器 [check_series.py](./pipeline/scripts/check_series.py) 保证散文/组件与清单一致)。 + +| # | 作品 | 一句话主题 | 视觉契约(主色) | 源论文 | 状态 | +|---|---|---|---|---|---| +| 1 | [《上线之后,AI 才开始上学》](./experience-era-agents-video/README.md) | 部署之后经验怎么攒 | 金/青/紫 | 清华×Frontis 88 页综述(无 arXiv 号),2026-06 | **重制中**(v3:内容校准 + sunny-steady 重配) | +| 2 | [《AI 如何自己变强?》](./self-improving-agents-video/README.md) | 自我进化改什么 | 蓝/橙 | arXiv:2607.13104(Schmidhuber 团队),2026-07 | 就绪(待升级 sunny-steady,未排期) | +| 3 | [《会写代码的 AI,开始给自己写代码》](./self-evolving-coding-agents-video/README.md) | 代码领域全图 | 绿/洋红 | arXiv:2608.03392(NJUST×NJU),2026-08 | 就绪(源码含未重渲改动;待升级 sunny-steady) | + +**系列纪律**:各集独立成片,口播互不引用、不出现集数序号——顺序只存在于本清单与片尾视觉卡片,发布顺序变更的 TTS 代价恒为零。 + +制作走 [公共管线](./pipeline/README.md)(九阶段);配音经 IndexTTS-2.5 本人音色克隆(样本指纹见 [voices/refs.toml](./pipeline/voices/refs.toml),手册 [VOICE-CLONING.md](./pipeline/VOICE-CLONING.md))。 From cd3c403aa1b08ba44c3912c64c44e6505e6254af Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 22:54:11 +0800 Subject: [PATCH 05/40] =?UTF-8?q?feat(media-pipeline):=20=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E9=97=A8/=E7=B3=BB=E5=88=97=E6=A0=A1=E9=AA=8C/?= =?UTF-8?q?=E5=AD=97=E5=B9=95=E5=AF=BC=E5=87=BA/=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=A7=86=E8=A7=89=20QA=20=E4=B8=8E=2043=20=E9=A1=B9=E6=B5=8B?= =?UTF-8?q?=E8=AF=95;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check_script.py(④⑤门):分镜 beat 覆盖性(缺句 FAIL、重叠 WARN 且「尾」 标注静默)、时长预算双口径(字数估算 + manifest 实测对 pipeline.toml 目标窗)、 SceneFade 淡入不变式、--check-scenes 分镜↔场景代码互比——当场抓出 EP1 的 0-D/0-E 区间漂移与 EP2 分镜 7 句失覆盖 - check_series.py:media/series.json 五条规则的机械化执法(口播反串线含自身 标题排除、多标题按文本首现位置判序、序号↔标题绑定、清单完整性、相对链接 死链);当前报红与既有清单完全一致。自身标题假阳性(p1-25a 命中本集片名) 正是必须有机读清单的证明,纯 grep 做不到 - captions.py:manifest + timing.json 导出 srt/vtt;cue 终点不含句间停顿 (外挂字幕静默期不留字,与烧录字幕有意分歧并注明) - qa_frames.py 扩展:--last-n 末 N 句抽样(尾幕渐黑缺陷的盲区)、--check 四项自动体检(黑帧/字幕带侵入[连通亮段法]/冻帧[均值哈希]/字幕缺失, 惰性依赖 pillow+numpy)、--check-theme WCAG 对比度(三集实测全 ≥6.2:1) - pipeline.py 单入口:status(派生式新鲜度,零状态文件)/doctor/build/check/ tts/captions/render/qa/all/clean-samples,参数自 pipeline.toml 装配 - tests/ 43 项全绿 2.3s:digest 黄金哈希(缓存零失效守门人)、timeline 黄金 帧号、build_narration 校验分支、引擎护栏、内容门各判定、系列五规则、 字幕格式、QA 判据——无网络无 TTS 无 DB - pre-commit ruff 扩到 ^media/pipeline/(既有 8 处 lint 顺手修复) 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .pre-commit-config.yaml | 12 + media/pipeline/scripts/build_narration.py | 3 +- media/pipeline/scripts/captions.py | 87 +++++ media/pipeline/scripts/check_script.py | 216 ++++++++++++ media/pipeline/scripts/check_series.py | 208 ++++++++++++ media/pipeline/scripts/paper_extract.py | 34 +- media/pipeline/scripts/pipeline.py | 333 +++++++++++++++++++ media/pipeline/scripts/prepare_ref.py | 3 +- media/pipeline/scripts/prospect_ref.py | 2 +- media/pipeline/scripts/qa_frames.py | 282 +++++++++++++--- media/pipeline/scripts/refs.py | 6 +- media/pipeline/scripts/tts.py | 8 +- media/pipeline/scripts/tts_sample.py | 11 +- media/pipeline/tests/conftest.py | 51 +++ media/pipeline/tests/fixtures/manifest.json | 6 + media/pipeline/tests/fixtures/narration.json | 6 + media/pipeline/tests/fixtures/narration.md | 15 + media/pipeline/tests/fixtures/storyboard.md | 13 + media/pipeline/tests/fixtures/theme.ts | 26 ++ media/pipeline/tests/fixtures/timing.json | 8 + media/pipeline/tests/test_build_narration.py | 70 ++++ media/pipeline/tests/test_captions.py | 46 +++ media/pipeline/tests/test_check_script.py | 127 +++++++ media/pipeline/tests/test_check_series.py | 127 +++++++ media/pipeline/tests/test_digest.py | 122 +++++++ media/pipeline/tests/test_engine_guard.py | 62 ++++ media/pipeline/tests/test_qa_checks.py | 107 ++++++ media/pipeline/tests/test_timeline.py | 61 ++++ 28 files changed, 1983 insertions(+), 69 deletions(-) create mode 100644 media/pipeline/scripts/captions.py create mode 100644 media/pipeline/scripts/check_script.py create mode 100644 media/pipeline/scripts/check_series.py create mode 100644 media/pipeline/scripts/pipeline.py create mode 100644 media/pipeline/tests/conftest.py create mode 100644 media/pipeline/tests/fixtures/manifest.json create mode 100644 media/pipeline/tests/fixtures/narration.json create mode 100644 media/pipeline/tests/fixtures/narration.md create mode 100644 media/pipeline/tests/fixtures/storyboard.md create mode 100644 media/pipeline/tests/fixtures/theme.ts create mode 100644 media/pipeline/tests/fixtures/timing.json create mode 100644 media/pipeline/tests/test_build_narration.py create mode 100644 media/pipeline/tests/test_captions.py create mode 100644 media/pipeline/tests/test_check_script.py create mode 100644 media/pipeline/tests/test_check_series.py create mode 100644 media/pipeline/tests/test_digest.py create mode 100644 media/pipeline/tests/test_engine_guard.py create mode 100644 media/pipeline/tests/test_qa_checks.py create mode 100644 media/pipeline/tests/test_timeline.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1ad52650..a22af5f9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,6 +46,18 @@ repos: files: ^apps/negentropy/ types_or: [python, pyi] + # ── Python: media/pipeline(科普视频公共管线) ───────────────────────── + # 与 negentropy 同一 ruff 修订版;此前仅覆盖 apps/negentropy/,管线脚本无 lint 门 + - id: ruff + name: ruff lint (media-pipeline) + args: [--fix, --exit-non-zero-on-fix] + files: ^media/pipeline/ + types_or: [python, pyi] + - id: ruff-format + name: ruff format (media-pipeline) + files: ^media/pipeline/ + types_or: [python, pyi] + # ── Node.js: apps/negentropy-ui ────────────────────────────────────────── # ESLint v9 flat config(eslint.config.mjs),零警告策略(--max-warnings=0) - repo: local diff --git a/media/pipeline/scripts/build_narration.py b/media/pipeline/scripts/build_narration.py index 8607878c..cdee5ca2 100644 --- a/media/pipeline/scripts/build_narration.py +++ b/media/pipeline/scripts/build_narration.py @@ -47,8 +47,7 @@ def main() -> None: # 幕名为空时下一条校验会报出「与所在幕 不一致」这种令人困惑的信息, # 故先明确指出真正的原因:首句之前缺 `## Pn` 标题。 sys.exit( - f"{src}:{lineno} 句 {sid} 出现在任何 `## Pn` 分幕标题之前 —— " - f"每句必须归属于某一幕,见 {FORMAT_DOC}" + f"{src}:{lineno} 句 {sid} 出现在任何 `## Pn` 分幕标题之前 —— 每句必须归属于某一幕,见 {FORMAT_DOC}" ) if sid in seen: sys.exit(f"{src}:{lineno} 重复句 id: {sid}") diff --git a/media/pipeline/scripts/captions.py b/media/pipeline/scripts/captions.py new file mode 100644 index 00000000..cb7d03d2 --- /dev/null +++ b/media/pipeline/scripts/captions.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""从 manifest + timing.json 导出外挂字幕(.srt / .vtt)——B 站/YouTube 上传件。 + +时间已精确已知(每句实测 durationSec + 时间轴常数),导出是纯白捡的交付物。 +与 Subtitle.tsx(片内烧录字幕)有一个**有意分歧**:烧录字幕保留整句时间窗 +(含句间停顿,便于阅读);外挂字幕的 cue 终点 = 起点 + durationSec——静默期 +不该留字,否则平台播放器里上一句会挂到下一句开口。 + +用法:uv run --no-project media/pipeline/scripts/captions.py --project media/<工程> \ + [--format srt,vtt] [--out out] +输出:<工程>/out/captions.srt 与 .vtt(out/ 已 gitignore) +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) # noqa: E402 +from timeline import compute, load_constants # noqa: E402 + + +def fmt_ts_srt(sec: float) -> str: + ms = round(sec * 1000) + h, ms = divmod(ms, 3600_000) + m, ms = divmod(ms, 60_000) + s, ms = divmod(ms, 1000) + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + +def fmt_ts_vtt(sec: float) -> str: + return fmt_ts_srt(sec).replace(",", ".") + + +def build_cues(items: list[dict], c: dict) -> list[tuple[float, float, str]]: + """[(startSec, endSec, text)];end = start + durationSec(不含停顿,见模块注释)。""" + return [ + (r["startSec"], r["startSec"] + r["durationSec"], r["text"]) + for r in compute(items, c) + ] + + +def render_srt(cues: list[tuple[float, float, str]]) -> str: + out = [] + for i, (a, b, text) in enumerate(cues, 1): + out.append(f"{i}\n{fmt_ts_srt(a)} --> {fmt_ts_srt(b)}\n{text}\n") + return "\n".join(out) + + +def render_vtt(cues: list[tuple[float, float, str]]) -> str: + body = "\n".join( + f"{fmt_ts_vtt(a)} --> {fmt_ts_vtt(b)}\n{text}\n" for a, b, text in cues + ) + return f"WEBVTT\n\n{body}" + + +def main() -> None: + ap = argparse.ArgumentParser(description="导出 srt/vtt 外挂字幕") + ap.add_argument("--project", default=".", help="视频工程根目录") + ap.add_argument("--format", default="srt,vtt", help="逗号分隔(默认 srt,vtt)") + ap.add_argument("--out", default=None, help="输出目录(默认 <工程>/out)") + args = ap.parse_args() + + root = Path(args.project).resolve() + manifest = root / "video" / "public" / "audio" / "manifest.json" + if not manifest.is_file(): + sys.exit(f"manifest.json 不存在: {manifest} —— 先运行 scripts/tts.py 合成配音") + items = json.loads(manifest.read_text(encoding="utf-8")) + c = load_constants(root) + cues = build_cues(items, c) + out_dir = Path(args.out).resolve() if args.out else root / "out" + out_dir.mkdir(parents=True, exist_ok=True) + + for fmt in args.format.split(","): + fmt = fmt.strip().lower() + renderer = {"srt": render_srt, "vtt": render_vtt}.get(fmt) + if renderer is None: + ap.error(f"不支持的字幕格式: {fmt}(可选 srt / vtt)") + dest = out_dir / f"captions.{fmt}" + dest.write_text(renderer(cues), encoding="utf-8") + print(f"{len(cues)} cues -> {dest}") + + +if __name__ == "__main__": + main() diff --git a/media/pipeline/scripts/check_script.py b/media/pipeline/scripts/check_script.py new file mode 100644 index 00000000..68719bde --- /dev/null +++ b/media/pipeline/scripts/check_script.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""④⑤ 内容门:分镜覆盖性、时长预算、淡入不变式——公共管线版本。 + +机械化三件此前靠人眼/散文守着的事: + 1. storyboard 的 beat 句 id 区间必须**覆盖**本幕每一句(无缺句),重叠仅允许 + 「标题卡/章头压前句尾」的刻意惯例(该 beat 的句区间单元格以「尾」结尾标注); + 2. 时长预算:narration.json 字数估算与 manifest 实测(若已合成)两个口径都对 + pipeline.toml 的 target_minutes 负责——首次把「策划宣称/估算/实测」三处接上; + 3. 幕间呼吸淡入淡出的不变式:2×sceneCrossFadeSec ≤ sentenceGap+sceneGap + (淡入淡出必须花在既有静默里,时间轴零位移的前提)。 + +可选 --check-scenes:从 video/src/scenes/*.tsx 提取 beatWindow/w('id','id') +调用,与分镜表互比(WARN-only,TSX 正则本质近似)。 + +用法:uv run --no-project media/pipeline/scripts/check_script.py --project media/<工程> +退出码:0 = 通过;1 = 有 FAIL。WARN 不影响退出码但会列明。 +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import tomllib +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) # noqa: E402 +from timeline import load_constants, total_duration_in_frames # noqa: E402 + +#: 分镜表行:| 镜号 | 句区间 | 画面 | 动效 |。镜号形如 `0-A`/`2-B2`,句区间形如 +#: `p0-01..03`(右端可为裸编号,须补幕前缀)、`p6-06a..06d`、单句 `p6-07`。 +BEAT_ROW_RE = re.compile( + r"^\|\s*(\d+-[A-Z]\d*)[^|]*\|\s*(p\d+-[0-9a-z-]+(?:\.\.[0-9a-z-]+)?)\s*([^|]*)\|" +) +#: 场景组件里的 beat 调用。实际形如 `w('p0-01', 'p0-04')`(各场景统一先定义 +#: `const w = (fromId, toId?) => beatWindow(scene.sentences, scene.from, …)` 再使用), +#: 故匹配任意 `\w(...)` 调用并要求参数为 1–2 个单引号句 id。 +SCENE_CALL_RE = re.compile(r"\bw\(\s*'([a-z0-9-]+)'(?:\s*,\s*'([a-z0-9-]+)')?\s*\)") +#: 幕名(P0/P1/…)从句 id 前缀还原 +SCENE_OF_RE = re.compile(r"^(p\d+)-") + + +def fail(msgs: list[str], text: str) -> None: + msgs.append(f"FAIL {text}") + + +def warn(msgs: list[str], text: str) -> None: + msgs.append(f"WARN {text}") + + +def parse_storyboard(path: Path) -> list[tuple[str, str, str, str]]: + """返回 [(镜号, 起句id, 止句id, 区间单元格原文)]。单元格原文含「尾」= 刻意压尾标注。""" + beats: list[tuple[str, str, str, str]] = [] + for raw in path.read_text(encoding="utf-8").splitlines(): + if m := BEAT_ROW_RE.match(raw): + beat_id, span, cell = m.group(1), m.group(2), (m.group(2) + m.group(3)) + if ".." in span: + left, _, right = span.partition("..") + # 右端可为裸编号(p0-01..03)——须补幕前缀,否则永远匹配不到句子 + if not right.startswith("p"): + scene = SCENE_OF_RE.match(left).group(1) # type: ignore[union-attr] + right = f"{scene}-{right}" + else: + left = right = span + beats.append((beat_id, left, right, cell)) + return beats + + +def check_coverage( + items: list[dict], beats: list[tuple[str, str, str, str]], msgs: list[str] +) -> None: + ids = [i["id"] for i in items] + pos = {sid: k for k, sid in enumerate(ids)} + covered = [False] * len(ids) + for beat_id, left, right, cell in beats: + if left not in pos or right not in pos: + bad = left if left not in pos else right + fail( + msgs, + f"镜 {beat_id}:句 id {bad!r} 不在 narration.json(分镜陈旧或笔误)", + ) + continue + a, b = pos[left], pos[right] + if b < a: + fail(msgs, f"镜 {beat_id}:区间 {cell} 起止倒置") + continue + overlap = any(covered[a : b + 1]) + deliberate = "尾" in cell # 区间单元格原文含「尾」= 标题卡压前句尾的刻意惯例 + for k in range(a, b + 1): + covered[k] = True + if overlap and not deliberate: + warn( + msgs, + f"镜 {beat_id}:区间 {cell} 与前镜重叠(若为标题卡压前句尾的刻意设计,请在句区间后标注「尾」)", + ) + missing = [sid for sid, c in zip(ids, covered, strict=False) if not c] + if missing: + fail(msgs, f"分镜未覆盖 {len(missing)} 句: {' '.join(missing)}") + # 分镜表与代码的镜号互比(按幕:分镜镜号前缀数字 == 幕序号) + scene_nums = sorted({int(SCENE_OF_RE.match(i["id"]).group(1)[1:]) for i in items}) # type: ignore[union-attr] + beat_nums = sorted({int(b[0].split("-")[0]) for b in beats}) + if beat_nums and beat_nums != scene_nums: + warn(msgs, f"分镜覆盖的幕 {beat_nums} 与 narration 的幕 {scene_nums} 不一致") + + +def check_budget(root: Path, items: list[dict], cfg: dict, msgs: list[str]) -> None: + narr = cfg.get("narration", {}) + lo, hi = narr.get("target_minutes", [0, 999]) + cpm = narr.get("chars_per_min", 280) + chars = sum(len(i["text"]) for i in items) + est_min = chars / cpm + print( + f" 估算口径:{chars} 字 ÷ {cpm} 字/分 = {est_min:.1f} 分钟(目标 {lo}–{hi})" + ) + if not lo <= est_min <= hi: + fail(msgs, f"估算时长 {est_min:.1f} 分超预算 [{lo}, {hi}]") + manifest = root / "video" / "public" / "audio" / "manifest.json" + if manifest.is_file(): + c = load_constants(root) + m_items = json.loads(manifest.read_text(encoding="utf-8")) + real_min = total_duration_in_frames(m_items, c) / c["fps"] / 60 + print( + f" 实测口径:manifest {len(m_items)} 句,含时距总长 = {real_min:.1f} 分钟" + ) + if not lo <= real_min <= hi: + fail(msgs, f"实测时长 {real_min:.1f} 分超预算 [{lo}, {hi}]") + if {i["id"] for i in m_items} != {i["id"] for i in items}: + fail(msgs, "manifest 与 narration.json 的句 id 集不一致——改稿后未重跑 tts") + else: + print(" 实测口径:manifest 未生成(跳过;合成后复跑本门)") + + +def check_fade_invariant(root: Path, msgs: list[str]) -> None: + c = load_constants(root) + budget = c["sentenceGapSec"] + c["sceneGapSec"] + if 2 * c["sceneCrossFadeSec"] > budget: + fail( + msgs, + f"2×sceneCrossFadeSec({c['sceneCrossFadeSec']}) > 幕间静默预算({budget:.2f}s)" + " —— 淡入淡出会吃进句子音频,SceneFade 时间轴零位移前提被破坏", + ) + + +def check_scenes( + root: Path, beats: list[tuple[str, str, str, str]], msgs: list[str] +) -> None: + scenes_dir = root / "video" / "src" / "scenes" + if not scenes_dir.is_dir(): + return + code_pairs: set[tuple[str, str]] = set() + for tsx in sorted(scenes_dir.glob("*.tsx")): + for m in SCENE_CALL_RE.finditer(tsx.read_text(encoding="utf-8")): + left, right = m.group(1), m.group(2) or m.group(1) + code_pairs.add((left, right)) + board_pairs = {(left, right) for _, left, right, _ in beats} + for pair in sorted(board_pairs - code_pairs): + warn( + msgs, + f"分镜区间 {pair[0]}..{pair[1]} 未在场景代码中找到对应 beatWindow/w 调用", + ) + for pair in sorted(code_pairs - board_pairs): + warn(msgs, f"场景代码区间 {pair[0]}..{pair[1]} 未在分镜表中登记(分镜陈旧)") + + +def main() -> None: + ap = argparse.ArgumentParser(description="④⑤ 内容门:覆盖性/预算/淡入不变式") + ap.add_argument("--project", default=".", help="视频工程根目录") + ap.add_argument( + "--check-scenes", + action="store_true", + help="附:分镜↔场景代码 beat 互比(WARN-only)", + ) + ap.add_argument( + "--json", action="store_true", help="以 JSON 输出结果(供 pipeline.py 汇总)" + ) + args = ap.parse_args() + + root = Path(args.project).resolve() + cfg_path = root / "pipeline.toml" + cfg = ( + tomllib.loads(cfg_path.read_text(encoding="utf-8")) + if cfg_path.is_file() + else {} + ) + items = json.loads((root / "script" / "narration.json").read_text(encoding="utf-8")) + board = root / "script" / "storyboard.md" + if not board.is_file(): + sys.exit(f"storyboard.md 不存在: {board}") + + msgs: list[str] = [] + beats = parse_storyboard(board) + if not beats: + fail(msgs, f"未能从 {board.name} 解析出任何 beat 行(格式变化?)") + check_coverage(items, beats, msgs) + check_budget(root, items, cfg, msgs) + check_fade_invariant(root, msgs) + if args.check_scenes: + check_scenes(root, beats, msgs) + + fails = [m for m in msgs if m.startswith("FAIL")] + warns = [m for m in msgs if m.startswith("WARN")] + if args.json: + print( + json.dumps({"fails": fails, "warns": warns}, ensure_ascii=False, indent=1) + ) + else: + print(f">> 内容门 · {root.name} · {len(items)} 句 / {len(beats)} 镜") + for m in msgs: + print(f" {m}") + print(f">> FAIL {len(fails)} · WARN {len(warns)}") + sys.exit(1 if fails else 0) + + +if __name__ == "__main__": + main() diff --git a/media/pipeline/scripts/check_series.py b/media/pipeline/scripts/check_series.py new file mode 100644 index 00000000..04cba5c0 --- /dev/null +++ b/media/pipeline/scripts/check_series.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""系列一致性校验——media/series.json(发布顺序 SSOT)的机械化执法。 + +此前顺序只活在散文与 React 常量里(EP3 的 SeriesThree 数组说「第一集=AI 如何 +自己变强」而意图已变),且反串线 lint 无法只用 grep 实现:EP1 的 p1-25a 会命中 +自己的片名「上线之后」——正确排除自身标题必须知道标题属于哪个工程,也就是 +必须有清单。本脚本按价值降序执行五条规则: + + 1. 口播反串线:任一集 narration.md 的口播行出现**他集标题**或顺序词 + (上一集/上期/第N集/前两集/本系列…)即 FAIL;自身标题排除;「下期」白名单 + (顺序无关收尾语) + 2. 多标题顺序:同一文件出现 ≥2 集标题时,首现顺序必须等于清单 episode 顺序 + —— 一条规则覆盖 SeriesThree 数组、storyboard、README、planning、知识索引 + 3. 序号↔标题绑定:`第N集` 附近的标题必须与该集 episode 匹配 + 4. 清单完整性:episode 连续无重、slug 唯一、路径存在、accents 色值在该集 + theme.ts 中存在 + 5. 相对链接死链:受检文件集内的 `](./x.md)` / `](../x)` 目标必须存在 + (抓 video-package 类残留并防复发) + +用法:uv run --no-project media/pipeline/scripts/check_series.py +退出码:0 = 一致;1 = 有 FAIL。挂牌 pre-commit 后自动覆盖 media/ 相关提交。 +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +SERIES_JSON = REPO / "media" / "series.json" + +#: 顺序词(「下期」白名单——顺序无关的收尾语不算串线) +ORDINAL_WORDS = re.compile( + r"上一集|上集|上期|下一集|下集|第[一二三四五六七八九十]集|前两集|前一集|本系列" +) +SPOKEN_LINE_RE = re.compile(r"^- \[(?P[a-z0-9-]+)\]\s+(?P.+)$", re.M) +REL_LINK_RE = re.compile(r"\]\((\.{1,2}/[^)#?]+)\)") +EP_NUM = re.compile(r"第([一二三四五六七八九十])集") + +CN_NUM = { + "一": 1, + "二": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, + "十": 10, +} + +#: 受检文件集(规则 2/3/5 的扫描范围;.context 等工作区目录不含) +COVERED_GLOBS = ( + "media/**/*.md", + "media/**/*.tsx", + "media/**/*.ts", + "docs/.agents/knowledge-map.md", + "CHANGELOG.md", +) + + +def load_series() -> dict: + if not SERIES_JSON.is_file(): + sys.exit(f"series.json 不存在: {SERIES_JSON}") + data = json.loads(SERIES_JSON.read_text(encoding="utf-8")) + eps = data.get("episodes", []) + if not eps: + sys.exit("series.json 无 episodes") + return data + + +def covered_files() -> list[Path]: + out: list[Path] = [] + for g in COVERED_GLOBS: + out.extend( + p for p in REPO.glob(g) if p.is_file() and "node_modules" not in p.parts + ) + return sorted(set(out)) + + +def rule_spoken_interleave(series: dict, msgs: list[str]) -> None: + """规则 1:口播反串线。""" + for ep in series["episodes"]: + others = [e["title"] for e in series["episodes"] if e["slug"] != ep["slug"]] + narration = REPO / ep["path"] / "script" / "narration.md" + if not narration.is_file(): + msgs.append(f"FAIL 规则1:{ep['slug']} 缺 {narration.relative_to(REPO)}") + continue + for m in SPOKEN_LINE_RE.finditer(narration.read_text(encoding="utf-8")): + sid, text = m.group("id"), m.group("text") + hit_other = [t for t in others if t in text] + if hit_other: + msgs.append( + f"FAIL 规则1:{ep['slug']} {sid} 口播出现他集标题「{hit_other[0]}」" + ) + if w := ORDINAL_WORDS.search(text): + msgs.append( + f"FAIL 规则1:{ep['slug']} {sid} 口播出现顺序词「{w.group(0)}」" + "——序号只允许存在于视觉层与 series.json" + ) + + +def rule_title_order(series: dict, files: list[Path], msgs: list[str]) -> None: + """规则 2:同一文件内多集标题的首现顺序 == 清单顺序。""" + for f in files: + try: + text = f.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + present = [ + (text.find(e["title"]), e["episode"], e["title"]) + for e in series["episodes"] + if e["title"] in text + ] + if len(present) >= 2: + seq = [ep for _, ep, _ in sorted(present)] # 按文本首现位置排序 + if seq != sorted(seq): + msgs.append( + f"FAIL 规则2:{f.relative_to(REPO)} 中多集标题首现顺序为 " + f"{[t for _, _, t in sorted(present)]}(episode 序 {seq}),与 series.json 顺序不符" + ) + + +def rule_ordinal_binding(series: dict, files: list[Path], msgs: list[str]) -> None: + """规则 3:`第N集` 的 ±60 字符窗口内出现的标题必须与该序号匹配。""" + by_ep = {e["episode"]: e["title"] for e in series["episodes"]} + title_to_ep = {e["title"]: e["episode"] for e in series["episodes"]} + for f in files: + try: + text = f.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for m in EP_NUM.finditer(text): + n = CN_NUM.get(m.group(1), 0) + if n not in by_ep: + continue + window = text[max(0, m.start() - 60) : m.end() + 60] + for title, ep in title_to_ep.items(): + if title in window and ep != n: + msgs.append( + f"FAIL 规则3:{f.relative_to(REPO)} 「第{m.group(1)}集」邻域出现第 {ep} 集的标题《{title}》" + ) + + +def rule_manifest_integrity(series: dict, msgs: list[str]) -> None: + """规则 4:清单完整性。""" + eps = series["episodes"] + nums = [e["episode"] for e in eps] + if nums != list(range(1, len(eps) + 1)): + msgs.append(f"FAIL 规则4:episode 序列 {nums} 不连续或有重复") + slugs = [e["slug"] for e in eps] + if len(set(slugs)) != len(slugs): + msgs.append("FAIL 规则4:slug 重复") + for e in eps: + root = REPO / e["path"] + for need in ("README.md", "script/narration.md"): + if not (root / need).is_file(): + msgs.append(f"FAIL 规则4:{e['slug']} 缺 {need}") + theme = root / "video" / "src" / "design" / "theme.ts" + if theme.is_file(): + src = theme.read_text(encoding="utf-8") + for hexv in e.get("accents", []): + if hexv not in src: + msgs.append( + f"FAIL 规则4:{e['slug']} 的 accents 色值 {hexv} 未出现在其 theme.ts" + ) + else: + msgs.append(f"WARN 规则4:{e['slug']} 缺 theme.ts(工程未初始化?)") + + +def rule_dead_links(files: list[Path], msgs: list[str]) -> None: + """规则 5:受检文件内的相对链接目标必须存在。""" + for f in files: + try: + text = f.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for m in REL_LINK_RE.finditer(text): + target = (f.parent / m.group(1)).resolve() + if not target.exists(): + msgs.append(f"FAIL 规则5:{f.relative_to(REPO)} 死链 {m.group(1)}") + + +def main() -> None: + series = load_series() + files = covered_files() + msgs: list[str] = [] + rule_spoken_interleave(series, msgs) + rule_title_order(series, files, msgs) + rule_ordinal_binding(series, files, msgs) + rule_manifest_integrity(series, msgs) + rule_dead_links(files, msgs) + + fails = [m for m in msgs if m.startswith("FAIL")] + warns = [m for m in msgs if m.startswith("WARN")] + for m in msgs: + print(f" {m}") + print( + f">> 系列一致性 · 受检 {len(files)} 文件 · FAIL {len(fails)} · WARN {len(warns)}" + ) + sys.exit(1 if fails else 0) + + +if __name__ == "__main__": + main() diff --git a/media/pipeline/scripts/paper_extract.py b/media/pipeline/scripts/paper_extract.py index 23bd8f17..e83e107c 100644 --- a/media/pipeline/scripts/paper_extract.py +++ b/media/pipeline/scripts/paper_extract.py @@ -29,9 +29,7 @@ try: import pymupdf except ImportError: # pragma: no cover - 依赖缺失时给可操作提示 - sys.exit( - "缺少 pymupdf —— 请以 `uv run --no-project --with pymupdf` 方式调用本脚本" - ) + sys.exit("缺少 pymupdf —— 请以 `uv run --no-project --with pymupdf` 方式调用本脚本") # § 标题行:编号 + 标题正文。要求标题以大写字母开头,排除正文里的「1. 」列表项 SECTION_RE = re.compile(r"^\s*(\d+(?:\.\d+)*)\.?\s+([A-Z][^\n]{3,80})$", re.M) @@ -71,7 +69,10 @@ def cmd_map(doc: pymupdf.Document, args: argparse.Namespace) -> None: for i, page in enumerate(doc): hits = SECTION_RE.findall(page.get_text("text")) if hits: - joined = " · ".join(f"{num} {title.strip()[:60]}" for num, title in hits[: args.max_per_page]) + joined = " · ".join( + f"{num} {title.strip()[:60]}" + for num, title in hits[: args.max_per_page] + ) print(f"p{i + 1:>3} {joined}") @@ -112,7 +113,10 @@ def cmd_find(doc: pymupdf.Document, args: argparse.Namespace) -> None: for i, page in enumerate(doc): for rect in page.search_for(args.query): clip = pymupdf.Rect( - rect.x0 - args.context, rect.y0 - 40, rect.x1 + args.context, rect.y1 + 80 + rect.x0 - args.context, + rect.y0 - 40, + rect.x1 + args.context, + rect.y1 + 80, ) block = " ".join(page.get_text("text", clip=clip, sort=True).split()) print(f"p{i + 1:>3} {block[: args.chars]}") @@ -148,23 +152,33 @@ def main() -> None: sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("map", help="§ 编号 → 页码映射") - p.add_argument("--max-per-page", type=int, default=4, help="每页最多列出几个标题(默认 4)") + p.add_argument( + "--max-per-page", type=int, default=4, help="每页最多列出几个标题(默认 4)" + ) p.set_defaults(func=cmd_map) p = sub.add_parser("text", help="按页范围取正文(默认双栏分列)") p.add_argument("--pages", required=True, help="页码,如 12-24 或 1,7,8") - p.add_argument("--columns", type=int, default=2, help="分栏数,单栏排版传 1(默认 2)") + p.add_argument( + "--columns", type=int, default=2, help="分栏数,单栏排版传 1(默认 2)" + ) p.set_defaults(func=cmd_text) p = sub.add_parser("captions", help="图表 caption 收割") - p.add_argument("--chars", type=int, default=300, help="每条 caption 截断长度(默认 300)") + p.add_argument( + "--chars", type=int, default=300, help="每条 caption 截断长度(默认 300)" + ) p.set_defaults(func=cmd_captions) p = sub.add_parser("find", help="定点搜索并返回上下文(未命中时退出码 1)") p.add_argument("query", help="要检索的原文措辞") p.add_argument("--context", type=int, default=280, help="左右扩展像素(默认 280)") - p.add_argument("--chars", type=int, default=400, help="每条上下文截断长度(默认 400)") - p.add_argument("--limit", type=int, default=6, help="最多返回几条,0 = 不限(默认 6)") + p.add_argument( + "--chars", type=int, default=400, help="每条上下文截断长度(默认 400)" + ) + p.add_argument( + "--limit", type=int, default=6, help="最多返回几条,0 = 不限(默认 6)" + ) p.set_defaults(func=cmd_find) p = sub.add_parser("render", help="指定页光栅化为 PNG(看图,无需 poppler)") diff --git a/media/pipeline/scripts/pipeline.py b/media/pipeline/scripts/pipeline.py new file mode 100644 index 00000000..b7ed86cc --- /dev/null +++ b/media/pipeline/scripts/pipeline.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""科普视频管线单入口——九阶段中工具化阶段的薄编排。 + +此前「跑管线」= 人从 README 复制粘贴命令序列,且每集的引擎/风格/样本参数 +散落三份 README(已实际漂移:README 仍教 passionate 而VOICE-CLONING 推荐位 +已迁移)。本入口从每集 pipeline.toml 装配默认参数,CLI 显式参数仍可覆盖。 + +设计约束(刻意不做的事): + - 不做阶段状态机/状态文件——幂等与续跑已由内容摘要提供({id}.sha 逐句 + sidecar;narration.json 是 narration.md 的纯函数派生),再存一份阶段状态 + 就是第二事实源,必然漂移。`status` 实时派生新鲜度,零存储。 + - 不假装能跑写作阶段(①②④⑤中的人/代理部分)——只跑工具与其质量门。 + +用法:uv run --no-project media/pipeline/scripts/pipeline.py --project media/<工程> +子命令:status / doctor / build / check / tts / captions / render / qa / all / clean-samples +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import time +import tomllib +import urllib.error +import urllib.request +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parent +REPO = SCRIPTS.parents[2] +sys.path.insert(0, str(SCRIPTS)) # noqa: E402 - 复用 timeline 的 load_constants + +MANUAL = "media/pipeline/VOICE-CLONING.md" + + +def load_config(root: Path) -> dict: + cfg_path = root / "pipeline.toml" + if not cfg_path.is_file(): + sys.exit( + f"缺少分集配置: {cfg_path}\n 可执行参数已收敛至 pipeline.toml" + "(见 media/pipeline/README.md 字段表);直接调用底层脚本亦可" + ) + return tomllib.loads(cfg_path.read_text(encoding="utf-8")) + + +def run(cmd: list[str], cwd: Path | None = None) -> int: + print(f"\n$ {' '.join(cmd)}" + (f" (cwd={cwd})" if cwd else "")) + return subprocess.run(cmd, cwd=cwd, check=False).returncode + + +def uv_no_project( + script: str, with_pkgs: list[str], *extra: str, project: Path +) -> list[str]: + cmd = ["uv", "run", "--no-project"] + for p in with_pkgs: + cmd += ["--with", p] + cmd += [str(SCRIPTS / script), "--project", str(project), *extra] + return cmd + + +# ---------------- 子命令 ---------------- + + +def cmd_status(root: Path, _cfg: dict) -> int: + """派生式阶段新鲜度表(无状态文件:全部由产物 mtime/摘要实时推断)。""" + from timeline import load_constants + + narr_md = root / "script" / "narration.md" + narr_json = root / "script" / "narration.json" + board = root / "script" / "storyboard.md" + manifest = root / "video" / "public" / "audio" / "manifest.json" + draft = root / "out" / "draft.mp4" + final = root / "out" / "final.mp4" + + def fresh(target: Path, *deps: Path) -> str: + if not target.is_file(): + return "待产出" + tt = target.stat().st_mtime + stale = [d.name for d in deps if d.is_file() and d.stat().st_mtime > tt] + return f"⚠️ 输入已更新({', '.join(stale)})" if stale else "✅ 新鲜" + + print(f">> {root.name} 阶段新鲜度(实时派生,无状态文件)") + print(f" ③ narration.json {fresh(narr_json, narr_md)}") + print(f" ⑥ audio/manifest {fresh(manifest, narr_json)}") + if manifest.is_file(): + items = json.loads(manifest.read_text(encoding="utf-8")) + done = sum( + 1 + for i in items + if (root / "video/public/audio" / f"{i['id']}.mp3").is_file() + ) + c = load_constants(root) + from timeline import total_duration_in_frames + + mins = total_duration_in_frames(items, c) / c["fps"] / 60 + print(f" {done}/{len(items)} 句 mp3 · 预计成片 {mins:.1f} 分钟") + print(f" ⑧ out/draft.mp4 {fresh(draft, manifest, board)}") + print( + f" ⑨ out/final.mp4 {final.is_file() and fresh(final, draft) or '待产出'}" + ) + return 0 + + +def cmd_doctor(root: Path, cfg: dict) -> int: + """环境自检:配置、时序 SSOT、参考样本、IndexTTS 服务、node_modules。""" + ok = True + tts = cfg.get("tts", {}) + print(">> doctor") + from timeline import load_constants + + try: + c = load_constants(root) + print( + f" ✅ timing.json: fps={c['fps']} 句间={c['sentenceGapSec']}s 幕间={c['sceneGapSec']}s" + ) + except SystemExit as e: + print(f" ❌ {e}") + ok = False + if tts.get("engine") == "indextts": + ref = REPO / tts.get("ref", "") + if ref.is_file(): + import hashlib + + sha1 = hashlib.sha1(ref.read_bytes()).hexdigest()[:12] + match = "✅" if sha1 == tts.get("ref_sha1") else "❌ 指纹不符" + print(f" {match} 参考样本 {ref.name} sha1={sha1}") + ok = ok and sha1 == tts.get("ref_sha1") + else: + print(f" ⚠️ 参考样本缺失: {ref}(音频不入库;用 refs.py rebuild 重建)") + try: + server = tts.get("server", "http://127.0.0.1:8766") + with urllib.request.urlopen(f"{server}/health", timeout=5) as resp: + h = json.loads(resp.read()) + print( + f" ✅ IndexTTS 服务: v{h.get('version')} {h.get('device')}/{h.get('dtype')}" + ) + except (urllib.error.URLError, OSError) as e: + print(f" ❌ IndexTTS 服务不可达: {e}(启动命令见 {MANUAL} §二)") + ok = False + if not (root / "video" / "node_modules").is_dir(): + print( + " ⚠️ video/node_modules 未安装(渲染前: cd video && pnpm install --ignore-workspace)" + ) + print( + " ℹ️ 渲染主机约束:macOS + PingFang SC/Songti SC 系统字体(Linux/CI 渲染不在支持范围)" + ) + return 0 if ok else 1 + + +def cmd_build(root: Path, _cfg: dict) -> int: + return run(uv_no_project("build_narration.py", [], project=root)) + + +def cmd_check(root: Path, _cfg: dict, scenes: bool = False) -> int: + extra = ["--check-scenes"] if scenes else [] + return run(uv_no_project("check_script.py", [], *extra, project=root)) + + +def cmd_tts( + root: Path, + cfg: dict, + plan: bool, + force: bool, + steady: str | None, + style: str | None, +) -> int: + tts = cfg.get("tts", {}) + cmd = [ + "uv", + "run", + "--no-project", + "--with", + "mutagen", + str(root / "scripts" / "tts.py"), # 工程内薄包装(注入 --project) + "--engine", + tts.get("engine", "indextts"), + ] + if tts.get("engine", "indextts") == "indextts": + cmd += ["--ref", str(REPO / tts["ref"])] + if tts.get("ref_sha1"): + cmd += ["--expect-ref-sha1", tts["ref_sha1"]] + cmd += [ + "--style", + style or tts.get("style", "sunny-steady"), + "--lang", + tts.get("lang", "ZH"), + ] + if plan: + cmd.append("--plan") + if force: + cmd.append("--force") + if steady: + cmd += ["--steady", steady] + return run(cmd, cwd=root) + + +def cmd_captions(root: Path, _cfg: dict) -> int: + return run(uv_no_project("captions.py", [], project=root)) + + +def cmd_render(root: Path, cfg: dict, final: bool) -> int: + video = root / "video" + if not (video / "node_modules").is_dir(): + print("先安装依赖(--ignore-workspace 隔离根 workspace)…") + rc = run(["pnpm", "install", "--ignore-workspace"], cwd=video) + if rc: + return rc + r = cfg.get("render", {}) + out = "../out/final.mp4" if final else "../out/draft.mp4" + cmd = ["./node_modules/.bin/remotion", "render", "Main", out] + if not final: + cmd += [ + "--scale", + str(r.get("draft_scale", 0.5)), + "--jpeg-quality", + str(r.get("draft_jpeg_quality", 60)), + ] + return run(cmd, cwd=video) + + +def cmd_qa( + root: Path, + _cfg: dict, + video: str | None, + scene: str | None, + last_n: int | None, + ids: list[str], + check: bool, +) -> int: + cmd = ["uv", "run", "--no-project"] + if check: + cmd += ["--with", "pillow", "--with", "numpy"] + cmd += [str(SCRIPTS / "qa_frames.py"), "--project", str(root)] + if scene: + cmd += ["--scene", scene] + if last_n: + cmd += ["--last-n", str(last_n)] + if check: + cmd += ["--check"] + if video: + cmd.append(video) + cmd += ids + return run(cmd, cwd=root) + + +def cmd_all(root: Path, cfg: dict) -> int: + """build → check → tts → captions → render --draft → qa 抽帧(含自动体检)。""" + for step in ( + lambda: cmd_build(root, cfg), + lambda: cmd_check(root, cfg), + lambda: cmd_tts(root, cfg, plan=False, force=False, steady=None, style=None), + lambda: cmd_captions(root, cfg), + lambda: cmd_render(root, cfg, final=False), + ): + if rc := step(): + return rc + print("\n>> 草渲完成。尾幕必查(渐黑过早缺陷的回归点):") + print( + " uv run --no-project --with pillow --with numpy media/pipeline/scripts/qa_frames.py \\" + ) + print(f" --project {root} out/draft.mp4 --last-n 6 --check") + return 0 + + +def cmd_clean_samples(_root: Path, _cfg: dict) -> int: + d = REPO / ".temp" / "voice-samples" + if d.is_dir(): + import shutil + + shutil.rmtree(d) + print(f"已删除 {d}(小样含本人音色,属生物特征信息)") + else: + print(f"无待清理目录: {d}") + return 0 + + +def main() -> None: + ap = argparse.ArgumentParser( + description="科普视频管线单入口(薄编排,阶段契约见 media/pipeline/README.md)" + ) + ap.add_argument( + "--project", + default=".", + help="视频工程根目录(含 pipeline.toml);须置于子命令之前", + ) + sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("status", help="阶段新鲜度(实时派生)") + sub.add_parser("doctor", help="环境自检") + sub.add_parser("build", help="③ narration.md → narration.json") + sub.add_parser("check", help="④⑤ 内容门") + sub.add_parser("captions", help="⑥+ 导出 srt/vtt") + sub.add_parser("clean-samples", help="清理 .temp/voice-samples(生物特征)") + p = sub.add_parser("tts", help="⑥ 配音合成(参数来自 pipeline.toml)") + p.add_argument("--plan", action="store_true", help="只看排期不实跑") + p.add_argument("--force", action="store_true", help="忽略缓存") + p.add_argument("--steady", help="混合档选择器") + p.add_argument("--style", help="覆写风格(两遍法草稿遍用 --style sunny)") + p = sub.add_parser("render", help="⑧⑨ 渲染") + p.add_argument("--final", action="store_true", help="终渲(默认草渲)") + p = sub.add_parser("qa", help="⑧ 抽帧 QA") + p.add_argument("--video", help="渲染产物路径") + p.add_argument("--scene") + p.add_argument("--last-n", type=int) + p.add_argument("--check", action="store_true", help="自动体检") + p.add_argument("ids", nargs="*") + args = ap.parse_args() + + root = Path(args.project).resolve() + cfg = load_config(root) + t0 = time.time() + rc = { + "status": lambda: cmd_status(root, cfg), + "doctor": lambda: cmd_doctor(root, cfg), + "build": lambda: cmd_build(root, cfg), + "check": lambda: cmd_check(root, cfg), + "tts": lambda: cmd_tts( + root, cfg, args.plan, args.force, args.steady, args.style + ), + "captions": lambda: cmd_captions(root, cfg), + "render": lambda: cmd_render(root, cfg, args.final), + "qa": lambda: cmd_qa( + root, cfg, args.video, args.scene, args.last_n, args.ids, args.check + ), + "all": lambda: cmd_all(root, cfg), + "clean-samples": lambda: cmd_clean_samples(root, cfg), + }[args.cmd]() + print(f"\n>> {args.cmd} 完成({time.time() - t0:.1f}s,退出码 {rc})") + sys.exit(rc) + + +if __name__ == "__main__": + main() diff --git a/media/pipeline/scripts/prepare_ref.py b/media/pipeline/scripts/prepare_ref.py index 6b3b6169..f6a286cd 100644 --- a/media/pipeline/scripts/prepare_ref.py +++ b/media/pipeline/scripts/prepare_ref.py @@ -28,7 +28,8 @@ def main() -> int: description="裁剪/规范化参考音色样本 → 16-bit 单声道 WAV" ) parser.add_argument( - "source", help="源音频文件(mp3/wav/flac 等 soundfile 可读格式;m4a 需先 ffmpeg 转 wav)" + "source", + help="源音频文件(mp3/wav/flac 等 soundfile 可读格式;m4a 需先 ffmpeg 转 wav)", ) parser.add_argument( "--start", type=float, default=0.0, help="裁剪起点(秒,默认 0)" diff --git a/media/pipeline/scripts/prospect_ref.py b/media/pipeline/scripts/prospect_ref.py index 72b0099c..a21f76b9 100644 --- a/media/pipeline/scripts/prospect_ref.py +++ b/media/pipeline/scripts/prospect_ref.py @@ -166,7 +166,7 @@ def main() -> int: - 20.0 * np.maximum(0, sil - 0.22) - 15.0 * np.maximum(0, 0.45 - voiced) ) - for r, s in zip(rows, score): + for r, s in zip(rows, score, strict=True): r["score"] = float(s) print( diff --git a/media/pipeline/scripts/qa_frames.py b/media/pipeline/scripts/qa_frames.py index b7900b9c..572738a5 100644 --- a/media/pipeline/scripts/qa_frames.py +++ b/media/pipeline/scripts/qa_frames.py @@ -1,20 +1,38 @@ #!/usr/bin/env python3 -"""按句 id 从渲染产物中抽帧,用于视觉 QA——公共管线版本。 +"""按句 id 从渲染产物中抽帧 + 自动视觉体检——公共管线版本。 时序常数直读 <工程>/video/src/timing.json(单一事实源,与 timing.ts / captions.py 同源),镜像常量已删除——工程改节奏只动 timing.json,本脚本自动跟随。 -用法:uv run --no-project media/pipeline/scripts/qa_frames.py --project media/<工程> \ - <句id> [句id ...] - uv run --no-project media/pipeline/scripts/qa_frames.py --project media/<工程> \ - --scene P1 # 该幕抽样至多 ~8 帧(与 ids 二选一) -输出:<工程>/out/frames/{句id}.png +抽帧选择器(三选一): + <句id>… 指定句(句中点帧) + --scene P1 该幕抽样至多 ~8 帧 + --last-n 6 末 N 句——直指「末句短于 beat → 渐黑提前 → 长黑尾」 + 上线 bug 的抽样盲区(尾幕必查) + +自动体检(--check,惰性依赖 pillow+numpy): + 黑帧/早渐黑 帧平均相对亮度 < 0.02 → FAIL(仅末 beat 且分镜末行写「渐黑」时豁免) + 字幕区侵入 字幕带 y∈[H-160,H) 内、字幕框 x 区间之外 p95 亮度超阈 → WARN + (对应 skills/06 渲染缺陷清单第 2 条「角标 bottom ≥ 150」) + 冻帧 同幕相邻采样帧 16×16 灰度均值哈希 Hamming 距离 0 → WARN + (beat 窗口错位/未覆盖句区间渲染空白) + 字幕缺失 字幕带内无任何像素达文字亮度 → WARN(单句字幕渲染失败) + +主题对比度(--check-theme,零依赖、不需要视频): + 解析 video/src/design/theme.ts 的 #RRGGBB,按 WCAG 2.x 相对亮度对比 + theme.bg;概念色 < 4.5:1 → FAIL(此前只能肉眼估,见 skills/06 清单)。 + +用法:uv run --no-project [--with pillow --with numpy] media/pipeline/scripts/qa_frames.py \ + --project media/<工程> [--scene P2|--last-n 6|句id…] [--check] + uv run --no-project media/pipeline/scripts/qa_frames.py --project media/<工程> --check-theme +输出:抽帧 <工程>/out/frames/{句id}.png;体检结果打屏,FAIL 使退出码非零。 """ from __future__ import annotations import argparse import json +import re import subprocess import sys from pathlib import Path @@ -22,52 +40,242 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) # noqa: E402 - 导入同目录 timeline from timeline import compute, load_constants # noqa: E402 +#: theme.ts 中颜色令牌('#RRGGBB' 字面量);bg 单独作为对比基准 +THEME_COLOR_RE = re.compile(r"^\s{2}(?P\w+):\s*'(?P#[0-9A-Fa-f]{6})'", re.M) + def timeline(root: Path) -> dict[str, tuple[float, float]]: """读 manifest + timing.json,返回 {句id: (startSec, spanSec)}。""" manifest = root / "video" / "public" / "audio" / "manifest.json" if not manifest.is_file(): - sys.exit( - f"manifest.json 不存在: {manifest} —— 先运行 scripts/tts.py 合成配音" - ) + sys.exit(f"manifest.json 不存在: {manifest} —— 先运行 scripts/tts.py 合成配音") items = json.loads(manifest.read_text(encoding="utf-8")) c = load_constants(root) return {r["id"]: (r["startSec"], r["spanSec"]) for r in compute(items, c)} +def extract_frame( + ffmpeg: list[str], video: Path, cwd: Path, ts: float, dst: Path +) -> None: + try: + subprocess.run( + [ + *ffmpeg, + "-y", + "-ss", + f"{ts:.3f}", + "-i", + str(video), + "-frames:v", + "1", + "-update", + "1", + str(dst), + ], + cwd=cwd, + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + print(f"ffmpeg 失败({dst.stem}): {(e.stderr or '')[-500:]}") + raise + + +# ---------------- 自动体检(--check / --check-theme) ---------------- + +#: 亮度/对比阈值(经验值:深色底 #0E1116 的相对亮度 ≈ 0.006) +BLACK_MEAN_LIMIT = 0.02 +SUBTITLE_BAND_PX = 160 # 字幕安全带高度(对应「bottom ≥ 150」清单位) +SUBTITLE_P95_LIMIT = 0.55 +TEXT_BRIGHTNESS = 0.45 +CONTRAST_MIN = 4.5 + + +def rel_luminance(r: int, g: int, b: int) -> float: + def ch(v: float) -> float: + return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4 + + return 0.2126 * ch(r / 255) + 0.7152 * ch(g / 255) + 0.0722 * ch(b / 255) + + +def wcag_ratio(fg: tuple[int, int, int], bg: tuple[int, int, int]) -> float: + l1, l2 = rel_luminance(*fg), rel_luminance(*bg) + hi, lo = max(l1, l2), min(l1, l2) + return (hi + 0.05) / (lo + 0.05) + + +def check_theme(root: Path, msgs: list[str]) -> None: + theme = root / "video" / "src" / "design" / "theme.ts" + if not theme.is_file(): + sys.exit(f"theme.ts 不存在: {theme}") + colors = { + m.group("key"): m.group("val") + for m in THEME_COLOR_RE.finditer(theme.read_text(encoding="utf-8")) + } + if "bg" not in colors: + sys.exit(f"{theme} 未解析出 bg 颜色令牌") + + def hex_rgb(h: str) -> tuple[int, int, int]: + return int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16) + + bg = hex_rgb(colors["bg"]) + print(f">> 主题对比度 · 基准 bg {colors['bg']}") + for key, val in colors.items(): + if key in ("bg", "panel", "panelBorder") or key.endswith("Deep"): + continue # 容器/描边色不承载正文信息 + ratio = wcag_ratio(hex_rgb(val), bg) + mark = "✅" if ratio >= CONTRAST_MIN else "❌" + print(f" {mark} {key:<10} {val} 对 bg 对比度 {ratio:.2f}:1") + if ratio < CONTRAST_MIN: + msgs.append( + f"FAIL {key} {val} 对 bg 对比度 {ratio:.2f}:1 < {CONTRAST_MIN}(skills/06 视觉契约:概念色须 ≥4.5:1)" + ) + + +def mean_hash(gray) -> int: + """16×16 灰度均值哈希(dHash 简化版):以均值为阈值的位图指纹。""" + bits = 0 + avg = float(gray.mean()) + for v in gray.flatten(): + bits = (bits << 1) | (1 if float(v) > avg else 0) + return bits + + +def check_frames( + out: Path, ids: list[str], scale: float, fade_exempt_last: bool, msgs: list[str] +) -> None: + try: + import numpy as np + from PIL import Image + except ImportError: + sys.exit( + "--check 需要 pillow 与 numpy:uv run --no-project --with pillow --with numpy …" + ) + + band_px = round(SUBTITLE_BAND_PX * scale) + hashes: dict[str, int] = {} + ordered: list[tuple[str, Path]] = [] + for sid in ids: + png = out / f"{sid}.png" + if not png.is_file(): + continue + ordered.append((sid, png)) + img = np.asarray(Image.open(png).convert("L"), dtype=np.float32) / 255.0 + mean = float(img.mean()) + is_last = ordered.index((sid, png)) == len(ids) - 1 + if mean < BLACK_MEAN_LIMIT and not (fade_exempt_last and is_last): + msgs.append( + f"FAIL {sid}: 帧平均亮度 {mean:.4f} < {BLACK_MEAN_LIMIT}(黑帧/渐黑过早)" + ) + band = img[img.shape[0] - band_px :, :] if band_px else img + bright_px = float((band > TEXT_BRIGHTNESS).mean()) + if bright_px == 0: + msgs.append(f"WARN {sid}: 字幕带内无文字亮度像素(字幕缺失?)") + else: + # 侵入检测按「亮列连通段」做:字幕框 = 最宽连通段;与它保持 >80px 间隔的 + # 其他亮段 = 角标/图形侵入字幕安全带(单纯 min–max 会被远端角标吞掉边界) + col = band.max(axis=0) + bright = col > TEXT_BRIGHTNESS + segments: list[tuple[int, int]] = [] + i = 0 + while i < len(col): + if bright[i]: + j = i + while j < len(col) and bright[j]: + j += 1 + segments.append((i, j - 1)) + i = j + else: + i += 1 + if segments: + x0, x1 = max(segments, key=lambda s: s[1] - s[0]) # 最宽段 = 字幕框 + for a, b in segments: + if (a < x0 - 80 or a > x1 + 80) and (b - a) < (x1 - x0): + msgs.append( + f"WARN {sid}: 字幕带内字幕框(x{x0}–{x1})之外有独立亮块 x{a}–{b}" + f"(角标/图形侵入 bottom≥{SUBTITLE_BAND_PX}px 安全区)" + ) + hashes[sid] = mean_hash( + np.asarray(Image.open(png).convert("L").resize((16, 16))) + ) + + for (a, _), (b, _) in zip(ordered, ordered[1:], strict=False): + if a.rsplit("-", 1)[0][:2] == b.rsplit("-", 1)[0][:2]: # 同幕前缀 + if hashes[a] == hashes[b]: + msgs.append(f"WARN {a} 与 {b} 帧指纹相同(疑似冻帧/beat 窗口错位)") + + +# ---------------- main ---------------- + + def main() -> None: - parser = argparse.ArgumentParser(description="按句 id 抽帧视觉 QA") + parser = argparse.ArgumentParser( + description="按句 id 抽帧视觉 QA + 自动体检", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) parser.add_argument( "--project", default=".", help="视频工程根目录(含 video/ 与 out/)" ) - parser.add_argument("--scene", help="按幕抽样(如 P1),与位置参数 ids 二选一") + parser.add_argument("--scene", help="按幕抽样(如 P1)") + parser.add_argument("--last-n", type=int, help="抽末 N 句(尾幕渐黑必查)") + parser.add_argument( + "--check", action="store_true", help="对抽出的帧做自动体检(需 pillow+numpy)" + ) + parser.add_argument( + "--check-theme", + action="store_true", + help="只做主题对比度检查(零依赖,无需视频)", + ) + parser.add_argument( + "--scale", + type=float, + default=1.0, + help="渲染产物缩放系数(草渲 0.5),供字幕带像素折算", + ) parser.add_argument( "--offset", type=float, default=0.0, help="时间轴整体偏移(草渲与终渲时间基准不一致时用)", ) - parser.add_argument("video", help="渲染产物 mp4 路径") - parser.add_argument("ids", nargs="*", help="句 id 列表(与 --scene 二选一)") + parser.add_argument("video", nargs="?", help="渲染产物 mp4 路径") + parser.add_argument("ids", nargs="*", help="句 id 列表") args = parser.parse_args() - if bool(args.scene) == bool(args.ids): - parser.error("ids 与 --scene 必须二选一") root = Path(args.project).resolve() + + if args.check_theme: + msgs: list[str] = [] + check_theme(root, msgs) + for m in msgs: + print(f" {m}") + print(f">> --check-theme · FAIL {sum(m.startswith('FAIL') for m in msgs)}") + sys.exit(1 if any(m.startswith("FAIL") for m in msgs) else 0) + + selectors = sum(bool(x) for x in (args.scene, args.last_n, args.ids)) + if not args.video or selectors != 1: + parser.error( + "需要