Files
the-silent-gods-hearthfire/tools/build-live-action-package.py
T

164 lines
10 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.
"""编译真人版12集改编母稿与制作索引;不修改动漫源文件。"""
import csv
import io
import json
import os
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parent.parent
LIVE = ROOT / '08_真人AI短剧'
ANIME = ROOT / '06_AI动漫制作/01_改编开发/03_动画剧本'
DATA = json.loads((LIVE / '00_开发/真人改编增补.json').read_text(encoding='utf-8'))
SPEAKERS = {'ELIAN':'埃利安','COWORKER':'前世工友','MATTHIAS':'马提亚斯','ADELE':'阿黛尔',
'NINO':'尼诺','LIA':'莉娅','MARA':'玛拉','LUCIAN':'卢西安','ADA':'艾达',
'CLERIC':'成年寻伴神官','RAYMOND':'雷蒙德','EMON':'埃蒙','VIOLA':'维奥拉',
'TOK':'托克','GRAINCLERK':'粮站书记'}
def read(path):
return path.read_text(encoding='utf-8-sig')
def write(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding='utf-8', newline='\n')
def table(path, fields, rows):
out = io.StringIO(newline='')
writer = csv.DictWriter(out, fields, delimiter='\t', lineterminator='\n')
writer.writeheader()
writer.writerows(rows)
write(path, out.getvalue())
def link(path, dest):
return os.path.relpath(path, dest.parent).replace('\\', '/')
def parse_source(path, episode):
"""按实际播出顺序读完整母稿,不以场号数字排序。"""
text = read(path)
header = re.search(r'^Title: (.+)$', text, re.M)[1]
title = (re.search(r'《([^》]+)》', text)[1] if episode == 1 else header.split(' ', 3)[-1])
title = path.stem.split('_')[1] if episode > 1 else title
scenes = []
pending = ''
for block in re.split(r'\n\s*\n', text.strip()):
slug = re.fullmatch(r'((?:INT\./EXT\.|INT\.|EXT\.|\.).+) #([^#]+)#', block.strip())
if slug:
place = slug[1].lstrip('.')
place = place.replace('INT./EXT. ', '内外·').replace('INT. ', '内·').replace('EXT. ', '外·').replace(' - ', '·')
scenes.append(dict(source=slug[2], place=place, blocks=[], year='', line='', speakers=[]))
continue
if not scenes:
continue
sc = scenes[-1]
origin = re.fullmatch(r'\[\[来源 (E\d+-S\d+);([ABC])线;([^;]+);单元合计\d+秒,拆场不重复计时\]\]', block)
if origin:
sc.update(line=origin[2], year=origin[3])
continue
old_id = re.fullmatch(r'\[\[(E\d+-D\d+)\]\]', block)
if old_id:
pending = old_id[1]
continue
if block.startswith(('[[OP ', '[[ED ', '> 本集结束', '/*', '[[V01', '[[第三卷')):
continue
if block.startswith('@'):
lines = block.splitlines()
speaker = lines[0][1:]
sc['blocks'].append(('dialogue', speaker, '\n'.join(lines[1:]), pending))
sc['speakers'].append(speaker)
pending = ''
elif block.splitlines()[0].split(' (')[0] in SPEAKERS:
lines = block.splitlines()
token = lines[0].split(' (')[0]
speaker = SPEAKERS[token] + lines[0][len(token):]
sc['blocks'].append(('dialogue', speaker, '\n'.join(lines[1:]), ''))
sc['speakers'].append(speaker)
elif block.startswith('[[声音导演:'):
sc['blocks'].append(('sound', block[len('[[声音导演:'):-2]))
elif block.startswith('[['):
continue
else:
sc['blocks'].append(('action', block))
if episode == 1:
for sc in scenes:
n = int(sc['source'][-2:])
sc['year'] = '前世' if n <= 3 else '855' if n <= 6 else '868回忆' if n == 17 else '870春'
sc['line'] = 'B' if 8 <= n <= 11 else 'C' if n == 13 else 'A'
return title, scenes
def main():
totals = []; mapping = []; dialogues = []; scene_rows = []; compilation = []; coverage = []
for addition in DATA['episodes']:
ep = addition['episode']; prefix = f'S01E{ep:02d}'
source = ANIME / 'S01E01_留给你的那一碗_V05.fountain' if ep == 1 else next((ANIME/'S01E02-E12_完整剧本_V01').glob(prefix+'_*_V01.fountain'))
title, scenes = parse_source(source, ep)
new = dict(source='NEW-LIVE', place=addition['title'], year=addition['year'], line=addition['line'], blocks=[], speakers=[], addition=True)
for block in re.split(r'\n\s*\n', addition['body']):
if block.startswith('@'):
lines=block.splitlines();new['blocks'].append(('dialogue',lines[0][1:],'\n'.join(lines[1:]),''));new['speakers'].append(lines[0][1:])
else:
new['blocks'].append(('action',block))
at=next(i for i,sc in enumerate(scenes) if sc['source']==addition['before'])
scenes.insert(at,new)
out = LIVE/'01_剧本'/f'{prefix}_{title}_真人V01.fountain'
reading = out.with_name(out.stem+'_阅读稿.md')
fp=[f'Title: 寂静诸神:炉火 — {prefix} {title} 真人版', 'Credit: 真人AI奇幻短剧改编初稿',
'Author: 薇星幻海;AI辅助改编', 'Draft date: 2026-10-30 / LIVE-V01 REVIEW',
'[[独立真人版;继承原稿人物与因果。8—10分钟为开发目标,实际片长尚未试读;旧1290秒预算不适用。]]']
md=[f'# {prefix}《{title}》真人版完整阅读稿',
'> LIVE-V01 REVIEW|2026-10-30。由完整既有剧本改编;全场保留,增加世界行动与生活回报。8—10分钟是弹性开发目标,尚未试读计时。']
n=0
for i,sc in enumerate(scenes,1):
sid=f'L{ep:02d}-SC{i:03d}'
fp.extend([f'.{sc["place"]} #{sid}#',f'[[年代 {sc["year"]};{sc["line"]}线;源场 {sc["source"]}]]'])
md.extend([f'## {sid}|{sc["place"]}',f'*{sc["year"]} · {sc["line"]}线*'])
if sc.get('addition'):
caption=f'SUPER: "圣约历{sc["year"]}。"';fp.append(caption);md.append('**字幕:圣约历'+sc['year']+'。**')
first_action='';active=[]
for block in sc['blocks']:
kind=block[0]
if kind=='dialogue':
_,speaker,spoken,old=block;n+=1;did=f'L{ep:02d}-D{n:03d}'
fp.extend([f'[[{did}]]','@'+speaker+'\n'+spoken]);md.append('**'+speaker+'** '+spoken)
row={'台词ID':did,'物理场号':sid,'角色':speaker,'朗读文本':spoken,'源台词ID':old,'改编状态':'真人新增' if sc.get('addition') else '继承后待试读','录制状态':'未录','实测秒':''}
dialogues.append(row);active.append(speaker)
elif kind=='sound':
fp.append('[[声音导演:'+block[1]+']]');md.append('*声音:'+block[1]+'*')
else:
action=block[1];fp.append(action)
if action.startswith('SUPER: '):md.append('**字幕:'+action.removeprefix('SUPER: ').strip('"')+'**')
else:
md.append(action)
if not first_action:first_action=action.replace('\n',' ')
mapping.append({'真人场号':sid,'动漫源场':sc['source'],'动漫源文件':str(source.relative_to(ROOT)).replace('\\','/'),'年代':sc['year'],'线':sc['line'],'场地':sc['place'],'改编类型':'新增' if sc.get('addition') else '继承','真人文件':str(out.relative_to(ROOT)).replace('\\','/')})
scene_rows.append({'真人场号':sid,'年代':sc['year'],'线':sc['line'],'场地':sc['place'],'有台词角色':'、'.join(dict.fromkeys(active)),'入场行动':first_action,'源场':sc['source'],'导演重点':addition['purpose'] if sc.get('addition') else '按正文动作和对白建立关系、保留选择与后果;逐镜表另审','状态':'场级文字需求;未生成镜头'})
fp.append('> 本集结束 <');write(out,'\n\n'.join(fp)+'\n');write(reading,'\n\n'.join(md)+'\n')
compilation.extend(['\n---\n',re.sub(r'^(#+) ',r'\1# ','\n\n'.join(md),flags=re.M)])
totals.append({'集':prefix,'集名':title,'物理场':len(scenes),'台词':n,'阅读稿':reading,'新增场':[r['真人场号'] for r in mapping if r['动漫源场']=='NEW-LIVE'][-1]})
coverage.append((prefix,source,len(scenes)-1,len(scenes),addition['purpose']))
table(LIVE/'01_剧本/真人_逐句录制表.tsv',list(dialogues[0]),dialogues)
table(LIVE/'01_剧本/动漫到真人_场号对照.tsv',list(mapping[0]),mapping)
table(LIVE/'03_分镜/第一季_场景执行总表.tsv',list(scene_rows[0]),scene_rows)
joined=LIVE/'01_剧本/第一季12集_真人阅读合订本.md'
intro=['# 第一季12集真人AI短剧阅读合订本','> LIVE-V01 REVIEW|2026-10-30。完整继承208个既有物理场并新增12场,共220场;8—10分钟是真人版弹性开发目标,未试读计时。',
'动漫与真人稿分别维护,人物设定和事件年代共用;源场映射与新增内容见同目录场号对照。']
intro += [f'- [{r["集"]} {r["集名"]}]({link(r["阅读稿"],joined)})' for r in totals]
write(joined,'\n\n'.join(intro+compilation)+'\n')
index=LIVE/'01_剧本/README.md'
rows=['# 真人版完整剧本目录','> 12集可通读改编初稿;不是分镜完成、片长锁定或视频完成。','| 集 | 阅读稿 | 场 | 台词 | 新增场 |','|---|---|---:|---:|---|']
rows += [f'| {r["集"]} | [{r["集名"]}]({link(r["阅读稿"],index)}) | {r["物理场"]} | {r["台词"]} | {r["新增场"]} |' for r in totals]
rows += ['','[12集合订本](./第一季12集_真人阅读合订本.md) · [逐句录制表](./真人_逐句录制表.tsv) · [场号映射](./动漫到真人_场号对照.tsv)',
'继承场保留原完整动作与对白,新增场由真人改编增补文件维护;这是第一次真人改编,问询重复、文书画面化与自然表演时长仍需审修。']
write(index,'\n\n'.join(rows[:2])+'\n\n'+'\n'.join(rows[2:])+'\n')
print('真人剧本:',len(totals),'集;',len(mapping),'场;',len(dialogues),'条台词。')
print([(r['集'],r['物理场'],r['台词']) for r in totals])
if __name__=='__main__':
main()