"""编译真人版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()