feat: 完成前三卷与动漫真人双版本制作包

This commit is contained in:
2026-10-31 02:15:18 +08:00
parent f900237d96
commit 9b7ce1d4b5
954 changed files with 62898 additions and 1116 deletions
+328
View File
@@ -0,0 +1,328 @@
"""从现行逐章/分集稿生成阅读合订本,并同步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()