164 lines
10 KiB
Python
164 lines
10 KiB
Python
"""编译真人版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()
|