360 lines
19 KiB
Python
360 lines
19 KiB
Python
"""从现行逐章/分集稿生成阅读合订本,并同步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('# 第三卷 无神罚的异端')
|
||
elif path.name.startswith('69_'):
|
||
parts.append('# 第四卷 无证人的长路')
|
||
parts.append(re.sub(r'^(#+) ', r'\1# ', source, flags=re.M).rstrip())
|
||
write(destination, '\n\n'.join(parts) + '\n')
|
||
return count
|
||
|
||
|
||
def build_four_volume_editions(edition_date):
|
||
"""只汇集小说,不改动漫源稿、配音表或此前范围固定的阅读本。"""
|
||
chapters = []
|
||
for volume in ('第一卷', '第二卷', '第三卷', '第四卷'):
|
||
chapters.extend(sorted((NOVEL / '03_正文' / volume).glob('[0-9][0-9]_*.md')))
|
||
if [int(p.name[:2]) for p in chapters] != list(range(89)):
|
||
raise ValueError('四卷应为序章00和正文01—88,无缺号或重复')
|
||
fourth = [p for p in chapters if 69 <= int(p.name[:2]) <= 88]
|
||
combined = NOVEL / '前88章_阅读合订本.md'
|
||
fourth_out = NOVEL / '第四卷_无证人的长路_阅读合订本.md'
|
||
full_count = build_novel_edition(chapters, combined,
|
||
'《寂静诸神:炉火》前四卷阅读合订本',
|
||
'独立序章+正文1—88章;前两卷各24章,第三、四卷各20章完整初稿', edition_date)
|
||
fourth_count = build_novel_edition(fourth, fourth_out,
|
||
'第四卷《无证人的长路》阅读合订本',
|
||
'总第69—88章,共20章;873春末—874初秋,埃利安18→19岁', edition_date)
|
||
print(f'小说88章+序章;约{full_count:,}汉字;第四卷20章约{fourth_count:,}汉字(含标题)')
|
||
print(combined)
|
||
print(fourth_out)
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument('--date', required=True)
|
||
parser.add_argument('--refresh-reading', help='由Fountain重建指定分集阅读稿,如S01E04;同时更新该集前置动作提示')
|
||
parser.add_argument('--novel-only', action='store_true', help='只汇集当前前四卷阅读本,不更新影像剧本与录制资料')
|
||
args = parser.parse_args()
|
||
if args.novel_only:
|
||
if args.refresh_reading:
|
||
parser.error('--novel-only不能与--refresh-reading同时使用')
|
||
build_four_volume_editions(args.date)
|
||
return
|
||
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:,}汉字(含标题及原有稿次说明)')
|
||
if (NOVEL / '03_正文/第四卷/88_只到中立站.md').is_file():
|
||
build_four_volume_editions(args.date)
|
||
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()
|