refactor(tree): subtitle 迁入 L3Card/L2Card + 建树管线修正
- L3Card/L2Card 新增 subtitle: str 字段(L1Card 不加) - L3Node 移除 subtitle 字段(数据迁入 Card) - assign_subtitles_voronoi 改写 Card.subtitle + L2 聚合 - _collect_card_strings 增加 skip_fields 排除 subtitle - _node_full_text/_node_anchored_text 保持 字幕:/[sN] 语义 - get_subtitle 读 Card.subtitle(L2/L3) - verify.py/synthesizer.py: l3.subtitle → l3.card.subtitle - 迁移脚本 tools/migrate_subtitle_to_card.py(幂等,300 棵树已迁移) - 9→6 个测试文件适配(3 个无需改动) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"""字幕数据迁移脚本:L3Node.subtitle → L3Card.subtitle + L2 聚合。
|
||||
|
||||
幂等操作——已迁移的树文件不会被重复修改。
|
||||
|
||||
用法:
|
||||
conda activate Video-Tree-TRM & python tools/migrate_subtitle_to_card.py [--dry-run]
|
||||
|
||||
迁移逻辑:
|
||||
1. 扫描 store/videos/*/tree.json
|
||||
2. 对每棵树:
|
||||
- L3: 将节点级 subtitle 移入 card["subtitle"],删除节点级 key
|
||||
- L2: 聚合子 L3 的 card["subtitle"] 写入 L2 card["subtitle"]
|
||||
- 处理边界: None → ""、空字符串、已迁移(card 内已有 subtitle)
|
||||
3. 回写 JSON
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def _migrate_tree_dict(tree_dict: dict) -> bool:
|
||||
"""原地迁移单棵树的 dict 结构,返回是否发生了修改。
|
||||
|
||||
参数:
|
||||
tree_dict: tree.json 加载后的字典。
|
||||
|
||||
返回:
|
||||
True 表示发生了修改,需要回写。
|
||||
"""
|
||||
modified = False
|
||||
|
||||
for l1 in tree_dict.get("roots", []):
|
||||
for l2 in l1.get("children", []):
|
||||
l3_subtitles: list[str] = []
|
||||
|
||||
for l3 in l2.get("children", []):
|
||||
card = l3.get("card", {})
|
||||
|
||||
# Phase 1: 迁移 L3 节点级 subtitle → card.subtitle
|
||||
node_subtitle = l3.get("subtitle") # 旧位置
|
||||
card_subtitle = card.get("subtitle", "") # 新位置
|
||||
|
||||
if node_subtitle is not None and not card_subtitle:
|
||||
# 旧格式:节点级有值,card 内无值 → 迁移
|
||||
card["subtitle"] = node_subtitle if node_subtitle else ""
|
||||
modified = True
|
||||
elif "subtitle" not in card:
|
||||
# 既无节点级也无 card 级 → 初始化为空
|
||||
card["subtitle"] = ""
|
||||
modified = True
|
||||
|
||||
# Phase 2: 清理节点级 subtitle key
|
||||
if "subtitle" in l3:
|
||||
del l3["subtitle"]
|
||||
modified = True
|
||||
|
||||
# 收集 L3 字幕用于 L2 聚合
|
||||
final_subtitle = card.get("subtitle", "")
|
||||
if final_subtitle:
|
||||
l3_subtitles.append(final_subtitle)
|
||||
|
||||
# Phase 3: L2 字幕聚合
|
||||
l2_card = l2.get("card", {})
|
||||
existing_l2_subtitle = l2_card.get("subtitle", "")
|
||||
aggregated = "\n".join(l3_subtitles)
|
||||
|
||||
if "subtitle" not in l2_card or existing_l2_subtitle != aggregated:
|
||||
l2_card["subtitle"] = aggregated
|
||||
modified = True
|
||||
|
||||
return modified
|
||||
|
||||
|
||||
def migrate_all(
|
||||
videos_dir: Path,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> tuple[int, int, int]:
|
||||
"""扫描并迁移所有树文件。
|
||||
|
||||
参数:
|
||||
videos_dir: store/videos 目录路径。
|
||||
dry_run: 仅检查不写入。
|
||||
|
||||
返回:
|
||||
(total, migrated, skipped) 计数元组。
|
||||
"""
|
||||
tree_files = sorted(videos_dir.glob("*/tree.json"))
|
||||
total = len(tree_files)
|
||||
migrated = 0
|
||||
skipped = 0
|
||||
|
||||
for tree_path in tree_files:
|
||||
video_id = tree_path.parent.name
|
||||
try:
|
||||
with open(tree_path, encoding="utf-8") as f:
|
||||
tree_dict = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("跳过损坏文件: {} ({})", tree_path, exc)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
changed = _migrate_tree_dict(tree_dict)
|
||||
|
||||
if changed:
|
||||
if dry_run:
|
||||
logger.info("[dry-run] 需要迁移: {}", video_id)
|
||||
else:
|
||||
with open(tree_path, "w", encoding="utf-8") as f:
|
||||
json.dump(tree_dict, f, ensure_ascii=False, indent=2)
|
||||
logger.info("已迁移: {}", video_id)
|
||||
migrated += 1
|
||||
else:
|
||||
logger.debug("无需迁移: {}", video_id)
|
||||
|
||||
return total, migrated, skipped
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI 入口。"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="将 L3Node.subtitle 迁移到 L3Card.subtitle + L2 聚合",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="仅检查不写入",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--videos-dir",
|
||||
type=Path,
|
||||
default=Path("store/videos"),
|
||||
help="视频树目录 (默认: store/videos)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.videos_dir.is_dir():
|
||||
logger.error("目录不存在: {}", args.videos_dir)
|
||||
sys.exit(1)
|
||||
|
||||
total, migrated, skipped = migrate_all(
|
||||
args.videos_dir,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
action = "需要迁移" if args.dry_run else "已迁移"
|
||||
logger.info(
|
||||
"迁移完成: 总计={}, {}={}, 跳过={}",
|
||||
total,
|
||||
action,
|
||||
migrated,
|
||||
skipped,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user