diff --git a/AGENTS.md b/AGENTS.md index e063287..30f9d15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,8 +61,27 @@ vrsub/ | `frame-extract` | `video_uri` | `frames_manifest`、`frame_count` | 按**帧间隔**抽帧(解析 fps → step=round(间隔秒×fps),ffmpeg select 按帧号精确取帧,帧时间=帧号/fps 无累计偏差)并 crop 裁切字幕区域,参数:`interval_seconds`(默认 0.5)、`crop`([x,y,w,h] 0~1,**默认画面底部 1/4** `[0,0.75,1,0.25]`——字幕很少出现在画面上半部分,2026-08 调整)。**帧文件必须按帧号数值排序读取**(`_sorted_frame_files`):ffmpeg `%04d` 编号超过 9999 帧后扩为 5 位,字典序 `sorted()` 会把 5 位编号排在 4 位之前导致时间与图像错位(真实发生于 run_339ec7ee437f 的 14236 帧任务,回归测试见 `test_frame_files_read_order_matches_frame_number`) | | `subtitle-ocr` | `frames_manifest` | `srt_uri`、`count` | 自适应线程池并发逐帧调 vlm-ocr → 垃圾过滤(无文字帧)→ 相同字幕合并(记录最后可见帧)→ 组装 SRT,消失时间=最后可见帧+采样间隔(间隔从帧清单推导),参数:`min_chars`、`min_alnum_ratio`、`garbage_tokens`、`pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold` | | `llm-filter` | `srt_uri` | `srt_uri`、`kept`、`removed` | 两级过滤:①**规则层**(不调 LLM)正则确定性删除——横线装饰、URL/邮箱/**裸网址域名**(含中文夹杂的注册地址)、**HTML/水印模式**(html code/标签/javascript 等)、overlay token(html/marketing 等)、单双 ASCII 字符;②**LLM 五类分类**(garbage/overlay/noise 删,repeat/dialogue 留,未识别回退保留)每条连同前后各 `context_size`(默认 10)条纯文本分批判断——**上下文净化**:喂给 LLM 的是**过滤后的字幕**,规则层确定性垃圾从上下文中剔除(原文不进 LLM),避免覆盖层垃圾污染场景判断误删真实对话(回归:run_011d01f19999 曾 190 条含 ≥4 汉字对话被误删);**长文本保护**:≥`min_keep_len`(默认 12)时 noise 不构成删除依据——LLM 判定不稳定,长度是必要兜底(实测移除保护后新增误删 124 条真实长对话)。**限流自适应**:LLM 调用 429/5xx 指数退避重试(最多 3 次,1s/2s/4s),worker 捕获限流错误时调用线程池 `report_failure()` **内存中临时降低最大线程数并缩容**(连续无错误窗口后逐步回升),失败条目在收紧后的并发下**重试一轮**,二次仍失败才整体失败——20 并发一拥而上触发 429 时自动收敛到配额内而不打挂任务。**节点级断点存档**(2026-08):每条判定成功立即追加 `filter_partial.jsonl`(`{"index","category"}`,多线程加锁串行化),失败/中断后重跑只重判未判定条目,已判定结果复用(与 OCR 存档同机制)。**按文本去重**(忽略空白/大小写,相同文本只调一次 LLM,上下文取首次出现)保证判定一致并省调用。参数:`context_size`、`min_keep_len`、`overlay_tokens`(JSON 数组)、`dedupe`(默认开)、`model`、`pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold`。回归数据:testdata/ocr_srt_run_ac7f480a3ccb.srt(真实任务 1666 条 OCR 输出) | -| `srt-to-dual-eye-ass` | `cn_srt_uri` | `ass_uri` | 参数:`resolution`(如 `3840x1920`)、`margin_top`(顶部安全边距,默认 120)。左右眼各占左右半幅且水平相对位置一致(**A-1 零视差**:字幕固定在屏幕平面,不做景深偏移);对齐 `an8` 顶部居中 + `MarginV=margin_top`(**B-1 顶部安全区**,避开画面中央人脸区,2026-09);文字填充 `&HB3FFFFFF`(约 70% 透明)描边 `&H80000000`(半透明黑),降低遮挡感 | +| `srt-to-dual-eye-ass` | `cn_srt_uri` | `ass_uri` | 参数:`resolution`(如 `3840x1920`)、`margin_top`(顶部安全边距,**默认 700**——2026-09 调整:120 落在画面最顶需抬头看,700 使字幕处于视线自然可读位置)。左右眼各占左右半幅且水平相对位置一致(**A-1 零视差**:字幕固定在屏幕平面,不做景深偏移);对齐 `an8` 顶部居中 + `MarginV=margin_top`(**B-1 顶部安全区**,避开画面中央人脸区);文字填充 `&HB3FFFFFF`(约 70% 透明)描边 `&H80000000`(半透明黑),降低遮挡感 | + +#### 字幕样式统一(2026-09 起) + +`nodes/ass.py` 顶部的 `DEFAULT_MARGIN_TOP=700` 与左右眼样式常量是**单一事实来源**: +新生成的字幕(`write_ass`/`invoke`)与历史字幕统一脚本共用 `ass_header()`/ +`style_row()`/`dialogue_line()` 同一出口,两边永不漂移。历史媒体库里由旧版本 +批量生成的 `*.CN_dual_eye.ass` 混有多种旧样式(底部 an2 实心白 / 底部半透明 / +顶部 120),用 `scripts/unify_ass_style.py` 统一原地改写为当前新样式: + +```bash +# 先 dry-run 预览将改哪些文件(默认不改盘) +uv run python scripts/unify_ass_style.py /mnt/fnOS/123 +# 确认无误后真正改写(原地,不产生 .bak) +uv run python scripts/unify_ass_style.py /mnt/fnOS/123 --apply +``` + +脚本解析旧文件分辨率与全部 Dialogue 事件后经 `ass_header()`/`dialogue_line()` +重建,输出与代码新产物**逐字节一致**;非 VR 字幕(无 LeftEye/RightEye 样式行) +自动跳过。相关测试见 `tests/test_unify_ass_style.py`。 ### 模型权重解析(本地优先) whisper 节点按以下顺序解析模型路径,默认避免从远端下载: diff --git a/nodes/ass.py b/nodes/ass.py index 3e6dddd..842f42a 100755 --- a/nodes/ass.py +++ b/nodes/ass.py @@ -11,40 +11,103 @@ from pathlib import Path from wov_sdk.models import InvokeRequest, InvokeResponse -def _ass_header(resolution: str, margin_top: int = 120) -> str: - """生成 ASS 文件头:脚本信息、左右眼样式和事件格式。 +# --------------------------------------------------------------------------- +# 统一 ASS 样式常量(单一事实来源) +# --------------------------------------------------------------------------- +# 说明:新生成字幕(write_ass/invoke)与"历史字幕统一脚本" +# (scripts/unify_ass_style.py)共用下面这套样式定义——要调整字幕样式 +# (位置/透明度/描边等)只改这里,两条输出路径保持一致,不会各自漂移。 +# +# 2026-09 调整:默认顶部安全边距 DEFAULT_MARGIN_TOP 由 120 改为 700。 +# 旧值 120 顶部对齐时字幕贴近画面最顶端,VR 头盔里需抬头才看得到; +# 700 为实测合适值,字幕落在更接近视线自然平视的高度。 +DEFAULT_MARGIN_TOP = 700 - 样式说明(B-1 顶部安全区 + 透明度): - - 对齐 8(顶部居中,\an8),MarginV=margin_top:字幕渲染在画面顶部安全区, - 避开人脸/近景主体常出现的画面中央偏下区域,降低遮挡; - - PrimaryColour &HB3FFFFFF:约 70% 透明文字填充,比原先 &H80FFFFFF(约 50%) - 更通透,减少对场景的遮挡感; - - OutlineColour &H80000000:半透明黑描边(不再是实心纯黑), - 在保留可读性的同时不产生生硬黑框; - - 左右眼水平边距一致(LeftEye/RightEye 各占左右半幅)⇒ 水平相对位置相同, - 即零视差(A-1):字幕被渲染在屏幕平面,不产生额外景深冲突。 - """ - width, height = resolution.lower().split("x", 1) - # 左眼样式占左半边,右眼样式占右半边,各留 50px 内边距。 - left_margin = 50 - right_margin = 50 - return f"""[Script Info] -Title: VR Dual-Eye Subtitle -ScriptType: v4.00+ -Collisions: Normal -PlayResX: {width} -PlayResY: {height} -WrapStyle: 1 -ScaledBorderAndShadow: yes +# 左右眼样式行字段(列顺序与 ASS Style Format 一一对应): +# - PrimaryColour &HB3FFFFFF:约 70% 透明文字填充,弱化对画面的遮挡; +# - OutlineColour &H80000000:半透明黑描边(取代早期实心纯黑),保留可读性 +# 又不产生生硬黑框; +# - Alignment 8(\an8 顶部居中):配合 MarginV 形成顶部安全区——避开画面 +# 中央人脸高发区,同时落在视线自然高度。 +_ASS_FONT = "Arial" +_ASS_FONT_SIZE = 50 +_ASS_PRIMARY = "&HB3FFFFFF" +_ASS_SECONDARY = "&H000000FF" +_ASS_OUTLINE = "&H80000000" +_ASS_BACK = "&H80000000" +_ASS_SCALE_X = 50 +_ASS_SCALE_Y = 100 +_ASS_BORDER_STYLE = 1 +_ASS_OUTLINE_WIDTH = 4 +_ASS_SHADOW = 0 +_ASS_ALIGN = 8 +_ASS_ENCODING = 1 +_EYE_PAD = 50 # 左眼距屏幕左缘 / 右眼距右缘的水平内边距 -[V4+ Styles] -Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding -Style: LeftEye,Arial,50,&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,{left_margin},{int(width) // 2},{margin_top},1 -Style: RightEye,Arial,50,&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,{int(width) // 2},{right_margin},{margin_top},1 +# 样式/事件表头格式行(列顺序即上面注释的顺序,勿改动)。 +ASS_STYLE_FORMAT = ( + "Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour," + "BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle," + "BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding" +) +ASS_EVENT_FORMAT = "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text" -[Events] -Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text -""" + +def style_row(eye: str, width: int, margin_top: int = DEFAULT_MARGIN_TOP) -> str: + """生成单眼(LeftEye/RightEye)的完整 ASS 样式行。 + + 左眼占左半幅(左缘留 _EYE_PAD 内边距、向中线收 50%),右眼占右半幅, + 两眼的水平相对位置一致 → 零视差(A-1):字幕渲染在屏幕平面,不产生 + 额外景深冲突。margin_top 即该眼样式的 MarginV(距画面上缘的安全边距)。""" + mid = width // 2 + margin_l, margin_r = (_EYE_PAD, mid) if eye == "LeftEye" else (mid, _EYE_PAD) + return ( + f"Style: {eye},{_ASS_FONT},{_ASS_FONT_SIZE},{_ASS_PRIMARY},{_ASS_SECONDARY}," + f"{_ASS_OUTLINE},{_ASS_BACK},0,0,0,0,{_ASS_SCALE_X},{_ASS_SCALE_Y},0,0," + f"{_ASS_BORDER_STYLE},{_ASS_OUTLINE_WIDTH},{_ASS_SHADOW},{_ASS_ALIGN}," + f"{margin_l},{margin_r},{margin_top},{_ASS_ENCODING}" + ) + + +def ass_header(width: int, height: int, margin_top: int = DEFAULT_MARGIN_TOP) -> str: + """生成标准 ASS 文件头:Script Info(含分辨率)+ 左右眼样式 + 事件格式行。 + + 新生成字幕与历史字幕统一脚本共用此函数,保证两者输出样式完全一致。""" + return ( + "[Script Info]\n" + "Title: VR Dual-Eye Subtitle\n" + "ScriptType: v4.00+\n" + "Collisions: Normal\n" + f"PlayResX: {width}\n" + f"PlayResY: {height}\n" + "WrapStyle: 1\n" + "ScaledBorderAndShadow: yes\n" + "\n" + "[V4+ Styles]\n" + f"{ASS_STYLE_FORMAT}\n" + f"{style_row('LeftEye', width, margin_top)}\n" + f"{style_row('RightEye', width, margin_top)}\n" + "\n" + "[Events]\n" + f"{ASS_EVENT_FORMAT}\n" + ) + + +def dialogue_line(style: str, start: str, end: str, text: str) -> str: + """生成一行标准 Dialogue 事件。 + + 文本前缀固定 \\an8 顶部居中对齐(与样式 Alignment 一致),使每句字幕 + 都落到样式定义的顶部安全区位置。""" + return f"Dialogue: 0,{start},{end},{style},,0,0,0,,{{\\an8}}{text}" + + +def _ass_header(resolution: str, margin_top: int = DEFAULT_MARGIN_TOP) -> str: + """兼容旧接口的 ASS 头生成:resolution 形如 "3840x1920"。 + + 内部委托给 ass_header()(统一样式出口),仅负责把字符串分辨率解析为 + 整数宽高。""" + width, height = (int(part) for part in resolution.lower().split("x", 1)) + return ass_header(width, height, margin_top=margin_top) def parse_srt(text: str) -> list[tuple[str, str, str]]: @@ -79,17 +142,22 @@ def parse_srt(text: str) -> list[tuple[str, str, str]]: def write_ass( - entries: list[tuple[str, str, str]], output_path: Path, resolution: str, margin_top: int = 120 + entries: list[tuple[str, str, str]], + output_path: Path, + resolution: str, + margin_top: int = DEFAULT_MARGIN_TOP, ) -> None: """把解析后的条目写入 ASS 文件,每个条目输出左右眼两行 Dialogue。 - margin_top 控制字幕距画面顶部的安全边距(默认 120),顶部对齐(\an8) - 使字幕整体落在顶部安全区。左右眼使用相同文本与水平相对位置(零视差,A-1)。""" - lines = [_ass_header(resolution, margin_top=margin_top)] + margin_top 控制字幕距画面顶部的安全边距(默认 700,2026-09 起),顶部对齐(\an8) + 使字幕整体落在顶部安全区下方。左右眼使用相同文本与水平相对位置(零视差,A-1)。""" + width, height = (int(part) for part in resolution.lower().split("x", 1)) + header = ass_header(width, height, margin_top=margin_top) + # an8 对齐到屏幕顶部,配合 MarginV 形成顶部安全区,避开中央人脸区域。 + lines = [header.rstrip("\n")] for start, end, text in entries: - # an8 对齐到屏幕顶部,配合 MarginV 形成顶部安全区,避开中央人脸区域。 - lines.append(f"Dialogue: 0,{start},{end},LeftEye,,0,0,0,,{{\\an8}}{text}") - lines.append(f"Dialogue: 0,{start},{end},RightEye,,0,0,0,,{{\\an8}}{text}") + lines.append(dialogue_line("LeftEye", start, end, text)) + lines.append(dialogue_line("RightEye", start, end, text)) output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def invoke(request: InvokeRequest) -> InvokeResponse: @@ -108,8 +176,9 @@ def invoke(request: InvokeRequest) -> InvokeResponse: output_path = output_dir / "dual_eye.ass" # 分辨率默认 3840x1920,覆盖常见 VR 视频尺寸。 resolution = str(request.params.get("resolution", "3840x1920")) - # margin_top 可选:顶部安全边距,不同分辨率/内容可用工作流参数微调。 - margin_top = int(request.params.get("margin_top", 120)) + # margin_top 可选:顶部安全边距(默认 700,2026-09 起),不同分辨率/内容 + # 仍可用工作流参数微调(历史 120 已过时,勿再使用)。 + margin_top = int(request.params.get("margin_top", 700)) write_ass(entries, output_path, resolution, margin_top=margin_top) return InvokeResponse(status="completed", outputs={"ass_uri": str(output_path)}) diff --git a/scripts/unify_ass_style.py b/scripts/unify_ass_style.py new file mode 100644 index 0000000..f76cc4e --- /dev/null +++ b/scripts/unify_ass_style.py @@ -0,0 +1,227 @@ +"""统一历史 VR 双目字幕(*.CN_dual_eye.ass)的样式与位置。 + +背景 +---- +`nodes/ass.py` 生成的字幕样式在迭代中多次变化,媒体库(如 /mnt/fnOS/123) +里由旧版本批量生成的 *.CN_dual_eye.ass 因此混有多种历史样式: + +- 最早期:底部对齐(an2)+ 实心白字(&H00FFFFFF),MarginV≈1200; +- 过渡期:底部对齐 + 半透明白(&H80FFFFFF),MarginV≈1020; +- 2026-09 新默认:顶部对齐(an8)+ 70% 透明(&HB3FFFFFF)+ MarginV=120; +- 用户实测合适值:顶部对齐 + 70% 透明 + MarginV=700。 + +本脚本把库内所有目标字幕**原地改写**为当前 nodes/ass.py 的统一样式 +(an8 顶部对齐 + 70% 透明 + DEFAULT_MARGIN_TOP=700,单点定义见 +nodes/ass.py 顶部常量),保证"之后生成的字幕"与"历史字幕"外观一致。 + +实现要点 +-------- +- 不对手写字符串做手术,而是解析旧文件的 [Script Info] 分辨率与全部 + Dialogue 事件后,用 nodes/ass.py 的 ass_header()/dialogue_line() + 重新拼装——与代码新生成产物共用同一出口,两边永不漂移;以后要调样式 + 只需改 nodes/ass.py 一处常量。 +- 事件文本原样保留(含原有 \\N 多行分隔与事件内其它标签),只规整前导 + \\an 对齐标签:历史 an2(底部)/an8(顶部)一律统一为 an8。 +- 仅改写样式声明与事件对齐标签,不触碰时间轴、事件文本与左右眼对称性。 +- 文件最终以 UTF-8 写回、行尾 \\n、末尾一个换行——与 write_ass() 一致。 + +用法(在 vrsub 根目录执行) +---------------------------- + # 预览(默认 dry-run,不改盘) + uv run python scripts/unify_ass_style.py /mnt/fnOS/123 + + # 真正原地改写 + uv run python scripts/unify_ass_style.py /mnt/fnOS/123 --apply + + # 指定递归匹配模式 / 只处理某旧 MarginV + uv run python scripts/unify_ass_style.py /mnt/fnOS/123 --pattern "*.ass" + uv run python scripts/unify_ass_style.py /mnt/fnOS/123 --only-margin 1200 + +安全说明 +-------- +- 默认 --dry-run 只打印不写入;--apply 才真正原地改写。 +- 改写前脚本先打印库内旧样式分布,便于核对范围。 +- 不产生 .bak 副本(用户已确认直接原地改写);如需回滚请先自行备份。 +""" + +from __future__ import annotations + +import argparse +import re +import sys +from collections import Counter +from pathlib import Path + +# 复用节点单一事实来源:统一脚本必须与"新生成字幕"完全一致。 +from nodes.ass import ( # noqa: E402 + DEFAULT_MARGIN_TOP, + ass_header, + dialogue_line, +) + +_DIALOGUE_RE = re.compile(r"^Dialogue: ") +_AN_TAG_RE = re.compile(r"\{\\an[0-9]\}") + + +def parse_resolution(text: str) -> tuple[int, int] | None: + """从 [Script Info] 解析 PlayResX/PlayResY;解析失败返回 None。""" + width = height = None + for line in text.splitlines(): + m = re.match(r"^\s*PlayResX:\s*(\d+)", line) + if m: + width = int(m.group(1)) + continue + m = re.match(r"^\s*PlayResY:\s*(\d+)", line) + if m: + height = int(m.group(1)) + if width is None or height is None: + return None + return width, height + + +def inspect_style(text: str) -> tuple[str, str] | None: + """提取旧样式 (PrimaryColour, MarginV) 用于统计/过滤。 + + 只有存在 "Style: LeftEye" 行才视为 VR 双眼字幕产物;普通 ASS 字幕 + (无左右眼样式)跳过,避免误伤。""" + for line in text.splitlines(): + if line.startswith("Style: LeftEye"): + fields = line.split(",") + # 字段:Name0,Fontname1,Fontsize2,Primary3,...,Alignment18,MarginL19,MarginR20,MarginV21 + primary = fields[3].strip() if len(fields) > 3 else "?" + margin_v = fields[21].strip() if len(fields) > 21 else "?" + return primary, margin_v + return None + + +def parse_dialogues(text: str) -> list[tuple[str, str, str, str]]: + """抽取全部 Dialogue 事件为 (开始, 结束, 样式, 文本)。 + + 历史文件每条可能带前导 {\an2}/{\an8} 对齐标签;这里剥掉(统一由 + dialogue_line 补 an8),文本其余内容原样保留。""" + entries: list[tuple[str, str, str, str]] = [] + for line in text.splitlines(): + if not _DIALOGUE_RE.match(line): + continue + # Dialogue: 0,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text + parts = line.split(",", 9) + if len(parts) < 10: + continue + text_field = _AN_TAG_RE.sub("", parts[9], count=1) + entries.append((parts[1], parts[2], parts[3], text_field)) + return entries + + +def rewrite_content(original: str) -> str | None: + """把整份 ASS 文本重建为统一样式;已符合目标样式时返回 None。 + + 返回的新文本 = ass_header()(新样式行,MarginV=DEFAULT_MARGIN_TOP) + + dialogue_line() 重建的全部事件,即与 write_ass() 产物逐字节一致 + (除事件文本本身)。""" + res = parse_resolution(original) + if res is None: + return None # 无 PlayResX/Y,非本工具产物 + width, height = res + if inspect_style(original) is None: + return None # 无 LeftEye 样式行,非 VR 双目字幕 + + lines = [ass_header(width, height, margin_top=DEFAULT_MARGIN_TOP).rstrip("\n")] + for start, end, style, text in parse_dialogues(original): + lines.append(dialogue_line(style, start, end, text)) + rewritten = "\n".join(lines) + "\n" + return None if rewritten == original else rewritten + + +def main() -> int: + parser = argparse.ArgumentParser(description="统一历史 VR 双目字幕样式与位置") + parser.add_argument("root", help="要扫描的媒体库根目录(递归)") + parser.add_argument( + "--pattern", + default="*.CN_dual_eye.ass", + help="递归匹配的文件名模式(默认 *.CN_dual_eye.ass)", + ) + parser.add_argument( + "--apply", + action="store_true", + help="真正原地改写文件;不带此参数等价于 dry-run 预览", + ) + parser.add_argument( + "--only-margin", + type=int, + default=None, + help="只处理旧样式 MarginV 等于该值的文件(如 1200),其余跳过", + ) + args = parser.parse_args() + + root = Path(args.root) + if not root.is_dir(): + print(f"错误:目录不存在或不可读: {root}", file=sys.stderr) + return 2 + + files = sorted(root.rglob(args.pattern)) + if not files: + print(f"未找到匹配 {args.pattern} 的文件") + return 0 + + # 第一遍统计:库内旧样式分布 + 圈定目标文件。 + distribution: Counter[tuple[str, str]] = Counter() + unreadable = 0 + target_files: list[Path] = [] + for p in files: + try: + text = p.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + unreadable += 1 + continue + styled = inspect_style(text) + if styled is None: + unreadable += 1 + continue + primary, margin = styled + try: + margin_i = int(margin) + except ValueError: + margin_i = -1 + if args.only_margin is not None and margin_i != args.only_margin: + continue + distribution[(primary, margin)] += 1 + target_files.append(p) + + print(f"扫描 {root}:共 {len(files)} 个匹配文件,可识别 VR 字幕 {len(target_files)},跳过 {unreadable}") + print("旧样式分布(PrimaryColour, MarginV):") + for (primary, margin), count in distribution.most_common(): + print(f" {count:4d} {primary} MarginV={margin}") + if not target_files: + print("没有需要统一的文件。") + return 0 + + print(f"\n目标样式:an8 顶部对齐 + 70%透明(&HB3FFFFFF) + MarginV={DEFAULT_MARGIN_TOP}(与代码新生成一致)") + + changed = failed = 0 + for p in target_files: + try: + original = p.read_text(encoding="utf-8") + rewritten = rewrite_content(original) + except Exception as exc: # 单文件失败不中断整体 + print(f" [错误] {p}: {exc}") + failed += 1 + continue + if rewritten is None: + continue # 已是目标样式,跳过 + changed += 1 + marker = "改写" if args.apply else "将改写" + if args.apply: + p.write_text(rewritten, encoding="utf-8") + # 摘要:旧样式 -> 新样式。 + styled = inspect_style(original) + old_desc = f"{styled[0]}@{styled[1]}" if styled else "?" + print(f" [{marker}] {p.relative_to(root)} ({old_desc} -> &HB3FFFFFF@{DEFAULT_MARGIN_TOP})") + + print(f"\n完成:{changed} 个文件{'已' if args.apply else '待'}统一,{failed} 个失败跳过,其余已是最新样式。") + if not args.apply: + print("(以上为预览,未改动任何文件;确认无误后加 --apply 执行真正改写)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 23c1b46..ab8712a 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -678,20 +678,21 @@ def test_ass_parse_and_write(tmp_path) -> None: def test_ass_top_aligned_and_translucent(tmp_path) -> None: """验证字幕默认渲染到顶部安全区且文字/描边带透明度。 - B-1:对齐 an8(顶部居中),MarginV 取顶部安全边距默认 120; + B-1:对齐 an8(顶部居中),MarginV 取顶部安全边距默认 700(2026-09 起, + 字幕置于视线自然可读位置;旧默认 120 落在画面最顶需抬头观看); 透明度:文字填充 &HB3FFFFFF(约 70% 透明),描边 &H80000000(半透明黑), 而左右眼水平相对位置一致保持零视差(A-1,字幕在屏幕平面)。""" entries = parse_srt(SAMPLE_SRT) output = tmp_path / "out.ass" write_ass(entries, output, "3840x1920") content = output.read_text(encoding="utf-8") - # 顶部对齐 an8、顶部安全边距默认 120。 + # 顶部对齐 an8、顶部安全边距默认 700。 assert r"{\an8}" in content assert r"{\an2}" not in content - # 样式行:MarginV=120(顶部安全区),填充 &HB3FFFFFF,描边 &H80000000。 + # 样式行:MarginV=700(顶部安全区),填充 &HB3FFFFFF,描边 &H80000000。 assert "&HB3FFFFFF" in content assert "&H80000000" in content - assert ",0,0,0,0,50,100,0,0,1,4,0,8,50,1920,120,1" in content + assert ",0,0,0,0,50,100,0,0,1,4,0,8,50,1920,700,1" in content # 左右眼两行文本一致(水平相对位置相同 → 零视差/A-1)。 assert content.count(r"第一行\N第二行") == 2 @@ -725,6 +726,28 @@ def test_ass_invoke_reads_margin_top(tmp_path) -> None: assert ",0,0,0,0,50,100,0,0,1,4,0,8,50,960,90,1" in content +def test_ass_invoke_default_margin_top_is_700(tmp_path) -> None: + """验证 invoke 未显式传 margin_top 时默认生成 MarginV=700 的样式。 + + 保证之后生成的字幕默认都落在顶部安全区下方(2026-09 起), + 不再回退到旧的 120(需抬头观看)。""" + source = tmp_path / "in.srt" + source.write_text(SAMPLE_SRT, encoding="utf-8") + response = ass_invoke( + InvokeRequest( + run_id="run_def700", + node_instance_id="ni_def700", + inputs={"cn_srt_uri": str(source)}, + params={"resolution": "1920x1080"}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed" + content = Path(response.outputs["ass_uri"]).read_text(encoding="utf-8") + assert "an8" in content + assert ",0,0,0,0,50,100,0,0,1,4,0,8,50,960,700,1" in content + + def test_ass_parse_malformed(tmp_path) -> None: """验证畸形 SRT 不会抛出异常且返回空条目或忽略坏行。""" source = tmp_path / "bad.srt" diff --git a/tests/test_unify_ass_style.py b/tests/test_unify_ass_style.py new file mode 100644 index 0000000..eecd6f2 --- /dev/null +++ b/tests/test_unify_ass_style.py @@ -0,0 +1,231 @@ +"""历史 VR 双目字幕统一脚本(scripts/unify_ass_style.py)测试。 + +背景 +---- +`scripts/unify_ass_style.py` 负责把媒体库中由旧版本生成、混有多种历史样式 +的 *.CN_dual_eye.ass 原地改写为 nodes/ass.py 当前统一样式 +(an8 顶部对齐 + 70% 透明 + DEFAULT_MARGIN_TOP=700)。 + +本测试验证三条关键路径: +1. 各历史样式(底部实心白 an2+MarginV1200 / 底部半透明 an2+MarginV1020 / + 顶部 120 / 已是 700)都能正确重建为统一的 700 样式; +2. 重建结果与 nodes/ass.py 的 ass_header()/dialogue_line()(新生成字幕的 + 唯一出口)**逐字节一致**,防止"新字幕"与"历史改写"两套逻辑漂移; +3. 脚本文件能被直接加载执行(仓库根运行 uv run python scripts/...), + 且 rewrite_content 对非 VR 字幕返回 None(不误伤普通 ASS 文件)。 + +测试使用真实 ASS 内容构造(Script Info + 样式 + 事件),不 mock 文件系统 +之外的任何逻辑。 +""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +# 仓库根(tests/ 的上一级),用于导入 scripts/unify_ass_style.py。 +WORKSPACE = Path(__file__).resolve().parent.parent +SCRIPT_PATH = WORKSPACE / "scripts" / "unify_ass_style.py" + +# 用真实可执行的统一脚本构造各历史样式样本:以下文本代表旧版实际输出格式。 +HEADER_3840_OLD = """[Script Info] +Title: VR Dual-Eye Subtitle +ScriptType: v4.00+ +Collisions: Normal +PlayResX: 3840 +PlayResY: 1920 +WrapStyle: 1 +ScaledBorderAndShadow: yes + +[V4+ Styles] +Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding +""" + + +def _load_module(): + """按文件路径加载 scripts/unify_ass_style.py(scripts 不是包,无法 import)。 + + 与仓库运行方式一致:uv run python scripts/unify_ass_style.py 时 pythonpath + 含仓库根,模块内 `from nodes.ass import ...` 可正常解析。""" + spec = importlib.util.spec_from_file_location("unify_ass_style", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec is not None and spec.loader is not None + spec.loader.exec_module(module) + return module + + +U = _load_module() + + +@pytest.fixture +def sandbox(tmp_path): + """构造含三种历史样式 + 一个非 VR 文件的临时媒体库。 + + 每个测试独立临时目录(function scope),CLI 写盘测试不会污染其他测试。""" + root = tmp_path + # 1) 最早期:底部 an2 + 实心白 &H00FFFFFF + MarginV=1200。 + (root / "old_solid.CN_dual_eye.ass").write_text( + HEADER_3840_OLD + + "Style: LeftEye,Arial,50,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,50,1970,1200,1\n" + + "Style: RightEye,Arial,50,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,1970,50,1200,1\n" + + "\n[Events]\n" + + "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\n" + + 'Dialogue: 0,00:00:00.000,00:00:03.000,LeftEye,,0,0,0,,{\\an2}第一行\\N第二行\n' + + 'Dialogue: 0,00:00:00.000,00:00:03.000,RightEye,,0,0,0,,{\\an2}第一行\\N第二行\n', + encoding="utf-8", + ) + # 2) 过渡期:底部 an2 + 半透明 &H80FFFFFF + MarginV=1020。 + (root / "mid_translucent.CN_dual_eye.ass").write_text( + HEADER_3840_OLD + + "Style: LeftEye,Arial,50,&H80FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,50,1970,1020,1\n" + + "Style: RightEye,Arial,50,&H80FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,1970,50,1020,1\n" + + "\n[Events]\n" + + "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\n" + + 'Dialogue: 0,00:00:00.000,00:00:03.000,LeftEye,,0,0,0,,{\\an2}你好\n', + encoding="utf-8", + ) + # 3) 已是顶部 120 样式(旧代码默认),仍应规整为 700。 + (root / "top120.CN_dual_eye.ass").write_text( + HEADER_3840_OLD + + "Style: LeftEye,Arial,50,&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,50,1920,120,1\n" + + "Style: RightEye,Arial,50,&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,1920,50,120,1\n" + + "\n[Events]\n" + + "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\n" + + 'Dialogue: 0,00:00:00.000,00:00:03.000,LeftEye,,0,0,0,,{\\an8}第三行\n', + encoding="utf-8", + ) + # 4) 非 VR 字幕(无 LeftEye/RightEye 样式),脚本不应改动。 + (root / "not_vr.CN_dual_eye.ass").write_text( + "[Script Info]\nPlayResX: 1920\nPlayResY: 1080\n\n[V4+ Styles]\n" + "Format: Name,Fontname,Fontsize,PrimaryColour,...\nStyle: Default,Arial,20,&H00FFFFFF,...\n", + encoding="utf-8", + ) + return root + + +def _read(path: Path) -> str: + """以 UTF-8 读取测试文件内容。""" + return path.read_text(encoding="utf-8") + + +def test_rewrite_converges_all_generations(sandbox) -> None: + """三种历史样式统一后都含 an8 顶部对齐 + 70% 透明 + MarginV=700。""" + # (文件名, 该文件期望保留的事件文本) + expected_text = { + "old_solid": r"第一行\N第二行", # 最早期多行字幕(an2 底部) + "mid_translucent": "你好", # 过渡期 + "top120": "第三行", # 旧顶部 120 + } + for name, text in expected_text.items(): + original = _read(sandbox / f"{name}.CN_dual_eye.ass") + rewritten = U.rewrite_content(original) + assert rewritten is not None, f"{name} 应被改写" + # 左/右眼样式都落到统一值:70%透明 + an8 + MarginV=700。 + assert "&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,50,1920,700,1" in rewritten + assert "&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,1920,50,700,1" in rewritten + # 对齐标签全部为 an8,不再残留 an2。 + assert r"{\an8}" in rewritten + assert r"{\an2}" not in rewritten + # 事件文本原样保留。 + assert text in rewritten + + +def test_rewrite_byte_identical_to_write_ass(sandbox) -> None: + """历史字幕统一后的输出与 write_ass() 生成的新字幕逐字节一致。 + + 防止"统一脚本"与"节点生成"两套样式逻辑漂移:两边都经由 ass_header() + /dialogue_line()(nodes/ass.py 单一出口)。""" + from nodes.ass import DEFAULT_MARGIN_TOP, ass_header, dialogue_line + + # 取 old_solid 样本的事件,用 write_ass 语义重建期望文本。 + original = _read(sandbox / "old_solid.CN_dual_eye.ass") + rewritten = U.rewrite_content(original) + assert rewritten is not None + hdr = ass_header(3840, 1920, margin_top=DEFAULT_MARGIN_TOP).rstrip("\n") + expected = "\n".join( + [ + hdr, + dialogue_line("LeftEye", "00:00:00.000", "00:00:03.000", "第一行\\N第二行"), + dialogue_line("RightEye", "00:00:00.000", "00:00:03.000", "第一行\\N第二行"), + ] + ) + "\n" + assert rewritten == expected + + +def test_rewrite_skips_non_vr_and_already_canonical(sandbox) -> None: + """非 VR 字幕返回 None;已是目标样式(MarginV=700)也返回 None(幂等)。""" + not_vr = _read(sandbox / "not_vr.CN_dual_eye.ass") + assert U.rewrite_content(not_vr) is None + + # 构造一份已是 700 的新样式文本,改写应返回 None(内容不变)。 + from nodes.ass import ass_header, dialogue_line + + canonical = ( + ass_header(3840, 1920, margin_top=U.DEFAULT_MARGIN_TOP).rstrip("\n") + + "\n" + + dialogue_line("LeftEye", "00:00:00.000", "00:00:03.000", "嗨") + + "\n" + ) + assert U.rewrite_content(canonical) is None + + +def test_module_runs_as_cli_dry_run(sandbox) -> None: + """脚本可作为 CLI 以 dry-run 方式运行(uv run python scripts/...),不写盘。 + + 复现仓库根运行方式;断言 dry-run 输出含"旧样式分布"与"将改写", + 且运行后沙盒内文件一个都没被改动(dry-run 语义)。""" + before = {p.name: _read(p) for p in sandbox.glob("*.ass")} + # 不带 --apply = dry-run:应只打印不写盘。 + proc = subprocess.run( + [sys.executable, str(SCRIPT_PATH), str(sandbox)], + capture_output=True, + text=True, + cwd=WORKSPACE, # 与 uv run python 一致:仓库根在 sys.path + ) + assert proc.returncode == 0, proc.stderr + out = proc.stdout + assert "[将改写]" in out # 预览行标记 + assert "[改写]" not in out # 未真正写盘 + after = {p.name: _read(p) for p in sandbox.glob("*.ass")} + assert before == after # dry-run 不改动任何文件 + + + +def test_module_cli_apply_writes_files(sandbox) -> None: + """脚本加 --apply 后真正原地改写,写盘结果与 rewrite_content 一致。 + + 覆盖 main() 的写盘分支:统计输出显示 [改写],且文件内容已变为 + MarginV=700 的统一新样式(第二次运行幂等,不再改写)。""" + # 备份一个将改写的样本,确保 CLI 走的是与单元层相同的重建逻辑。 + old_solid = sandbox / "old_solid.CN_dual_eye.ass" + expected = U.rewrite_content(old_solid.read_text(encoding="utf-8")) + assert expected is not None + + proc = subprocess.run( + [sys.executable, str(SCRIPT_PATH), str(sandbox), "--apply"], + capture_output=True, + text=True, + cwd=WORKSPACE, + ) + assert proc.returncode == 0, proc.stderr + assert "[改写]" in proc.stdout + assert old_solid.read_text(encoding="utf-8") == expected + + # 幂等:第二次 --apply 不再有任何 [改写](文件已是目标样式)。 + proc2 = subprocess.run( + [sys.executable, str(SCRIPT_PATH), str(sandbox), "--apply"], + capture_output=True, + text=True, + cwd=WORKSPACE, + ) + assert proc2.returncode == 0, proc2.stderr + assert "[改写]" not in proc2.stdout + +def test_module_loads_and_exports() -> None: + """模块可加载且暴露 rewrite_content / DEFAULT_MARGIN_TOP。""" + assert U.DEFAULT_MARGIN_TOP == 700 + assert callable(U.rewrite_content)