d3be9b1322
- 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>
163 lines
4.8 KiB
Python
163 lines
4.8 KiB
Python
"""字幕数据迁移脚本: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()
|