"""从现行逐章/分集稿生成阅读合订本,并同步EP02—12的录制表。 保留既有台词ID、录制状态、take与实测秒;新台词从Fountain读取。 运行:python tools/build-reading-editions.py --date 2026-10-30 """ import argparse import csv import io import os from pathlib import Path import re ROOT = Path(__file__).resolve().parent.parent NOVEL = ROOT / '05_小说创作' SCRIPTS = ROOT / '06_AI动漫制作/01_改编开发/03_动画剧本' EPISODES = SCRIPTS / 'S01E02-E12_完整剧本_V01' def read(path): return path.read_text(encoding='utf-8-sig') def write(path, text): path.write_text(text, encoding='utf-8', newline='\n') def read_table(path): with path.open(encoding='utf-8-sig', newline='') as handle: reader = csv.DictReader(handle, delimiter='\t') return reader.fieldnames, list(reader) def write_table(path, fields, rows): out = io.StringIO(newline='') writer = csv.DictWriter(out, fieldnames=fields, delimiter='\t', lineterminator='\n') writer.writeheader() writer.writerows(rows) write(path, out.getvalue()) def dialogue(path): scene = None action = '' pending_id = None blocks = re.split(r'\n\s*\n', read(path).strip()) for block in blocks: scene_match = re.search(r'#(E\d{2}-\d{3})#', block) if block.startswith('.') and scene_match: scene = scene_match[1] action = '' elif re.fullmatch(r'\[\[E\d{2}-D\d{3}\]\]', block): pending_id = block[2:-2] elif block.startswith('@'): lines = block.splitlines() speaker = lines[0][1:] text_lines = [line for line in lines[1:] if not re.fullmatch(r'\(.*\)', line)] if pending_id is None: raise ValueError(f'缺台词ID:{path.name}: {speaker}') text = '\n'.join(text_lines) yield pending_id, scene, speaker, text, action pending_id = None elif not (block.startswith('[[') or block.startswith('SUPER:') or block.startswith('Title:') or block.startswith('>') or block.startswith('#')): action = block.replace('\n', ' ') def relative_link(path, destination): return os.path.relpath(path, destination.parent).replace('\\', '/') def rebase_markdown(text, source, destination): def replace(match): label, target = match.groups() if re.match(r'^(?:https?://|mailto:|codex:|#)', target): return match.group(0) path_part, separator, anchor = target.partition('#') resolved = source.parent / path_part return f'[{label}]({relative_link(resolved, destination)}{separator}{anchor})' return re.sub(r'\[([^\]]*)\]\(([^)]+)\)', replace, text) def render_reading(fountain, destination, edition_date): """按Fountain播出顺序重建中文阅读稿;保留稳定场号和台词ID。""" title = re.search(r'^Title: (.+)$', read(fountain), re.M)[1] parts = [f'# {title}|完整剧本阅读稿', f'> REVIEW|{edition_date}。来源为同名Fountain;正文1290秒、OP90秒、ED60秒仅为预算。稳定场号按播出顺序阅读,编号大小不代表先后。'] seen = set() for block in re.split(r'\n\s*\n', read(fountain).strip()): if block.startswith('Title:'): continue scene = re.fullmatch(r'\.(.+) #(E\d{2}-\d{3})#', block) if scene: parts.append(f'## {scene[2]}|{scene[1]}') continue origin = re.fullmatch(r'\[\[来源 (E\d{2}-S\d+);([ABC])线;([^;]+);单元合计(\d+)秒,拆场不重复计时\]\]', block) if origin: unit, line, period, seconds = origin.groups() position = '接续场' if unit in seen else '起始场' seen.add(unit) parts.append(f'*{line}线 · {period} · {unit}{position};单元合计{seconds}秒,拆场不重复计时*') continue if block.startswith('SUPER: '): parts.append('**字幕:' + block.removeprefix('SUPER: ').strip('"') + '**') elif block.startswith('@'): lines = block.splitlines() parts.append('**' + lines[0][1:] + '** ' + '\n'.join(lines[1:])) elif block.startswith('[[声音导演:'): parts.append('*声音:' + block[len('[[声音导演:'):-2] + '*') elif block.startswith('[[OP '): parts.append('---\n\n*片头OP:90秒。*\n\n---') elif block.startswith('[[ED '): parts.append('---\n\n*片尾ED:60秒。*') elif block.startswith('[[') or block.startswith('>'): continue else: parts.append(block) prefix = fountain.name[:6] parts.append(f'[本集录制表](./{prefix}_台词与录制表.tsv) · [本集接续表](./{prefix}_场景接续表.md) · [返回整包](./README.md)') write(destination, '\n\n'.join(parts) + '\n') def refresh_episode_metadata(prefix, fountain): """由同一场序同步人物需求、声音覆盖与接续表,保留既有制作状态。""" scenes = [] for block in re.split(r'\n\s*\n', read(fountain).strip()): slug = re.fullmatch(r'\.(.+) #(E\d{2}-\d{3})#', block) if slug: scenes.append(dict(id=slug[2], place=slug[1], roles=[], subtitles=[], action='')) continue if not scenes: continue current = scenes[-1] origin = re.fullmatch(r'\[\[来源 (E\d{2}-S\d+);([ABC])线;([^;]+);单元合计(\d+)秒,拆场不重复计时\]\]', block) if origin: current.update(zip(('unit', 'line', 'period', 'seconds'), origin.groups())) elif block.startswith('SUPER: '): current['subtitles'].append(block.removeprefix('SUPER: ').strip('"')) elif block.startswith('@'): role = re.sub(r'\s*\((?:V\.O\.|O\.S\.)\)', '', block.splitlines()[0][1:]) if role not in current['roles']: current['roles'].append(role) elif not current['action'] and not block.startswith(('[', '>', '#', 'Title:')): current['action'] = block.replace('\n', ' ') if any('unit' not in scene for scene in scenes): raise ValueError(f'{prefix}有场缺少来源单元') scene_prefix = prefix[3:] + '-' actor_path = EPISODES / '人物出场与画面需求.tsv' fields, rows = read_table(actor_path) previous = {row['物理场号']: row for row in rows} updated = [] for scene in scenes: row = dict(previous.get(scene['id'], {key: '' for key in fields})) row.update({'物理场号': scene['id'], '来源单元': scene['unit'], '线': scene['line'], '年份阶段': scene['period'], '场地': scene['place'], '有台词角色': '、'.join(scene['roles']), '入场动作与表情起点': scene['action'], '画面字幕': ';'.join(scene['subtitles'])}) if scene['id'] not in previous: row['场景资产状态'] = '文字需求已写;图像与动作未制作' updated.append(row) first = next(i for i, row in enumerate(rows) if row['物理场号'].startswith(scene_prefix)) write_table(actor_path, fields, rows[:first] + updated + [row for row in rows[first:] if not row['物理场号'].startswith(scene_prefix)]) sound_path = EPISODES / '环境声与音效提示.tsv' fields, rows = read_table(sound_path) by_unit = {} for scene in scenes: by_unit.setdefault(scene['unit'], []).append(scene['id']) for row in rows: if row['来源单元'] in by_unit: row['覆盖物理场号'] = '、'.join(by_unit[row['来源单元']]) write_table(sound_path, fields, rows) continuation = EPISODES / (prefix + '_场景接续表.md') text = read(continuation) changes = {} table_lines = text.splitlines(keepends=True) last_data_end = 0 offset = 0 for line in table_lines: if re.search(r'\|\s*' + re.escape(scene_prefix) + r'\d{3}\s*\|', line): cells = [cell.strip() for cell in line.strip().strip('|').split('|')] unit = next(cell for cell in cells if re.fullmatch(r'E\d{2}-S\d+', cell)) changes[unit] = cells[-1].strip() last_data_end = offset + len(line) offset += len(line) design_dir = ROOT / '06_AI动漫制作/01_改编开发/02_分集大纲/S01E02-E12_分集设计_V05' design = next(design_dir.glob(prefix + '_*_分场设计.md'), None) if design: for line in read(design).splitlines(): if line.startswith('| ' + scene_prefix[:3] + '-S'): cells = [cell.strip() for cell in line.strip('|').split('|')] changes[cells[0]] = cells[-1] head = text[:text.index('\n|') + 1] tail = text[last_data_end:].strip() serial = '| 播出序 |' in text table = [('| 播出序 ' if serial else '') + '| 物理场号 | 来源单元 | 线 | 年份 | 场地与时段 | 来源单元秒 | 叙事变化 |', ('|---:' if serial else '') + '|---|---|---|---|---|---:|---|'] for index, scene in enumerate(scenes, 1): values = [scene['id'], scene['unit'], scene['line'], scene['period'], scene['place'], scene['seconds'], changes.get(scene['unit'], '见本集分场设计')] if serial: values.insert(0, str(index)) table.append('| ' + ' | '.join(values) + ' |') write(continuation, head + '\n'.join(table) + '\n\n' + tail + '\n') def build_novel_edition(chapters, destination, title, scope, edition_date): parts = ['# ' + title, f'> {edition_date}|{scope};阅读初稿,非出版定稿。', '本文件由逐章稿汇集。修改逐章源稿后可用 `tools/build-reading-editions.py` 重建。'] for path in chapters: chapter_title = read(path).splitlines()[0].lstrip('# ') parts.append(f'- [{chapter_title}]({relative_link(path, destination)})') count = 0 for path in chapters: source = read(path) count += len(re.findall(r'[\u3400-\u9fff]', source)) parts.append('\n---\n') if path.name.startswith('00_'): parts.append('# 第一卷 边境教堂') elif path.name.startswith('25_'): parts.append('# 第二卷 黑腹热') elif path.name.startswith('49_'): parts.append('# 第三卷 无神罚的异端') parts.append(re.sub(r'^(#+) ', r'\1# ', source, flags=re.M).rstrip()) write(destination, '\n\n'.join(parts) + '\n') return count def main(): parser = argparse.ArgumentParser() parser.add_argument('--date', required=True) parser.add_argument('--refresh-reading', help='由Fountain重建指定分集阅读稿,如S01E04;同时更新该集前置动作提示') args = parser.parse_args() global_path = EPISODES / '全季_台词与录制总表.tsv' global_fields, global_rows = read_table(global_path) previous_global = {row['台词ID']: row for row in global_rows} all_rows = [] episode_counts = [] for episode in range(2, 13): prefix = f'S01E{episode:02d}' fountain = next(EPISODES.glob(prefix + '_*_V01.fountain')) reading = next(EPISODES.glob(prefix + '_*_V01_阅读稿.md')) table = EPISODES / (prefix + '_台词与录制表.tsv') if args.refresh_reading == prefix: render_reading(fountain, reading, args.date) refresh_episode_metadata(prefix, fountain) fields, rows = read_table(table) previous = {row['台词ID']: row for row in rows} parsed = list(dialogue(fountain)) ids = [item[0] for item in parsed] if len(ids) != len(set(ids)): raise ValueError(f'{prefix}台词ID重复') reading_text = re.findall(r'^\*\*[^*]+\*\* (.+)$', read(reading), re.M) if reading_text != [item[3] for item in parsed]: raise ValueError(f'{prefix}阅读稿与Fountain台词不同步') updated = [] for line_id, scene, speaker, spoken, action in parsed: row = dict(previous.get(line_id, {key: '' for key in fields})) role = re.sub(r'\s*\((?:V\.O\.|O\.S\.)\)', '', speaker) row.update({'台词ID': line_id, '物理场号': scene, '角色': role, '朗读文本': spoken}) if args.refresh_reading == prefix: row['前置动作与接话提示_不要朗读'] = action or '承接上句;按现场对象接话' if line_id not in previous: row.update({'类型': '对白', '前置动作与接话提示_不要朗读': action, '录制状态': '未录', '选定take': '未定', '实测秒': ''}) updated.append(row) global_row = dict(previous_global.get(line_id, {key: '' for key in global_fields})) global_row.update(row) if line_id not in previous_global: same_scene = next((old for old in previous_global.values() if old['物理场号'] == scene and old['角色'] == role), None) aliases = {'欧伦遗孀': 'E04-013_家属'} global_row['配音身份键'] = aliases.get(role, same_scene['配音身份键'] if same_scene else role) global_row['推荐输出文件名'] = line_id + '_t01.wav' all_rows.append(global_row) write_table(table, fields, updated) episode_counts.append((episode, len(updated))) write_table(global_path, global_fields, all_rows) novels = sorted((NOVEL / '03_正文/第一卷').glob('[0-9][0-9]_*.md')) novels += sorted((NOVEL / '03_正文/第二卷').glob('[0-9][0-9]_*.md')) novels += sorted((NOVEL / '03_正文/第三卷').glob('[0-9][0-9]_*.md')) numbers = [int(path.name[:2]) for path in novels] if numbers != list(range(69)): raise ValueError('应为独立序章00及正文01—68,三卷章号连续') novel_out = NOVEL / '前30章_阅读合订本.md' build_novel_edition([path for path in novels if int(path.name[:2]) <= 30], novel_out, '《寂静诸神:炉火》前30章阅读合订本', '独立序章+正文1—30章的阅读节选', args.date) full_out = NOVEL / '前48章_阅读合订本.md' build_novel_edition([path for path in novels if int(path.name[:2]) <= 48], full_out, '《寂静诸神:炉火》前两卷阅读合订本', '独立序章+正文1—48章;第一、二卷各24章完整初稿', args.date) volume_out = NOVEL / '第二卷_黑腹热_阅读合订本.md' build_novel_edition([path for path in novels if 25 <= int(path.name[:2]) <= 48], volume_out, '第二卷《黑腹热》阅读合订本', '总第25—48章,共24章,872春至夏末', args.date) full_out = NOVEL / '前68章_阅读合订本.md' novel_cjk = build_novel_edition(novels, full_out, '《寂静诸神:炉火》前三卷阅读合订本', '独立序章+正文1—68章;第一、二卷各24章,第三卷20章完整初稿', args.date) volume_out = NOVEL / '第三卷_无神罚的异端_阅读合订本.md' volume_cjk = build_novel_edition([path for path in novels if int(path.name[:2]) >= 49], volume_out, '第三卷《无神罚的异端》阅读合订本', '总第49—68章,共20章,872秋至873春', args.date) scripts = [SCRIPTS / 'S01E01_留给你的那一碗_V05_阅读稿.md'] scripts += [next(EPISODES.glob(f'S01E{ep:02d}_*_V01_阅读稿.md')) for ep in range(2, 13)] script_out = SCRIPTS / '第一季12集_阅读合订本.md' fountains = [SCRIPTS / 'S01E01_留给你的那一碗_V05.fountain'] fountains += [next(EPISODES.glob(f'S01E{ep:02d}_*_V01.fountain')) for ep in range(2, 13)] scene_total = sum(len(re.findall(r'(?m)^(?:\.|INT\.|EXT\.|INT\./EXT\.)[^\n]*#(?:01-\d{2}|E\d{2}-\d{3})#\s*$', read(path))) for path in fountains) parts = ['# 《寂静诸神:炉火》第一季12集阅读合订本', f'> {args.date}|现行EP01 V05+EP02—12 V01修订稿。完整对白初稿,共{scene_total}个物理场;24分钟为单集目标,尚未试配计时。', '男主线到875年秋首收,勇者线到878年矮人同行。各线顺时,跨年以字幕区分。'] for path in scripts: title = read(path).splitlines()[0].lstrip('# ') parts.append(f'- [{title}]({relative_link(path, script_out)})') for path in scripts: source = rebase_markdown(read(path), path, script_out) parts.extend(['\n---\n', re.sub(r'^(#+) ', r'\1# ', source, flags=re.M).rstrip()]) write(script_out, '\n\n'.join(parts) + '\n') print(f'正文:68章+序章;汉字约{novel_cjk:,};第三卷20章约{volume_cjk:,}汉字(含标题及原有稿次说明)') print(f'EP02—12录制台词:{len(all_rows)};各集:{episode_counts}') print('已核对11集Fountain与阅读稿台词逐条一致。') print(full_out) print(volume_out) print(script_out) if __name__ == '__main__': main()