Files
the-silent-gods-hearthfire/tools/build-reading-editions.py
T

329 lines
17 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""从现行逐章/分集稿生成阅读合订本,并同步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()