Ethscriptions commited on
Commit
69bc167
·
verified ·
1 Parent(s): 2d50f96

wolf-coming: 多角色配音 + 角色与 14 场 + 配乐音效 + 网页

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +5 -0
  2. wolf-coming/README.md +8 -4
  3. wolf-coming/scripts/build_bundle.sh +3 -0
  4. wolf-coming/scripts/tts_multi.py +61 -0
  5. wolf-coming/video/audio/v_01_1_n.mp3 +0 -0
  6. wolf-coming/video/audio/v_02_1_n.mp3 +0 -0
  7. wolf-coming/video/audio/v_03_1_n.mp3 +0 -0
  8. wolf-coming/video/audio/v_04_1_n.mp3 +0 -0
  9. wolf-coming/video/audio/v_04_2_boy.mp3 +0 -0
  10. wolf-coming/video/audio/v_05_1_n.mp3 +0 -0
  11. wolf-coming/video/audio/v_05_2_farmer.mp3 +0 -0
  12. wolf-coming/video/audio/v_06_1_boy.mp3 +0 -0
  13. wolf-coming/video/audio/v_06_2_n.mp3 +0 -0
  14. wolf-coming/video/audio/v_07_1_n.mp3 +0 -0
  15. wolf-coming/video/audio/v_07_2_boy.mp3 +0 -0
  16. wolf-coming/video/audio/v_07_3_n.mp3 +0 -0
  17. wolf-coming/video/audio/v_08_1_boy.mp3 +0 -0
  18. wolf-coming/video/audio/v_08_2_farmer.mp3 +0 -0
  19. wolf-coming/video/audio/v_09_1_n.mp3 +0 -0
  20. wolf-coming/video/audio/v_10_1_n.mp3 +0 -0
  21. wolf-coming/video/audio/v_10_2_boy.mp3 +0 -0
  22. wolf-coming/video/audio/v_11_1_n.mp3 +0 -0
  23. wolf-coming/video/audio/v_11_2_farmer.mp3 +0 -0
  24. wolf-coming/video/audio/v_11_3_n.mp3 +0 -0
  25. wolf-coming/video/audio/v_12_1_n.mp3 +0 -0
  26. wolf-coming/video/audio/v_13_1_n.mp3 +0 -0
  27. wolf-coming/video/audio/v_14_1_n.mp3 +0 -0
  28. wolf-coming/video/film.bundle.js +0 -0
  29. wolf-coming/video/film/fonts/chars.txt +1 -0
  30. wolf-coming/video/film/fonts/mashanzheng.woff2 +3 -0
  31. wolf-coming/video/film/fonts/wenkai.woff2 +3 -0
  32. wolf-coming/video/film/voice.mp3 +3 -0
  33. wolf-coming/video/index.html +1 -0
  34. wolf-coming/video/out/subs.srt +259 -0
  35. wolf-coming/video/storyboard.json +30 -0
  36. wolf-coming/video/timeline.json +1134 -0
  37. wolf-coming/video/wolf/voice.mp3 +3 -0
  38. wolf-coming/web/public/film/fonts/chars.txt +1 -1
  39. wolf-coming/web/public/film/fonts/mashanzheng.woff2 +2 -2
  40. wolf-coming/web/public/film/fonts/wenkai.woff2 +2 -2
  41. wolf-coming/web/public/wolf/voice.mp3 +3 -0
  42. wolf-coming/web/src/App.tsx +19 -11
  43. wolf-coming/web/src/components/Covers.tsx +10 -8
  44. wolf-coming/web/src/components/ExportPanel.tsx +3 -3
  45. wolf-coming/web/src/components/LivePhotos.tsx +5 -5
  46. wolf-coming/web/src/components/Making.tsx +5 -11
  47. wolf-coming/web/src/components/Player.tsx +2 -2
  48. wolf-coming/web/src/components/PublishCopy.tsx +4 -3
  49. wolf-coming/web/src/film/audio.ts +39 -214
  50. wolf-coming/web/src/film/film.ts +15 -9
.gitattributes CHANGED
@@ -160,3 +160,8 @@ wolf-coming/web/public/film/live/06-fly.jpg filter=lfs diff=lfs merge=lfs -text
160
  wolf-coming/web/public/film/qa/sheet_landscape.jpg filter=lfs diff=lfs merge=lfs -text
161
  wolf-coming/web/public/film/qa/sheet_portrait.jpg filter=lfs diff=lfs merge=lfs -text
162
  wolf-coming/web/public/film/voice.mp3 filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
160
  wolf-coming/web/public/film/qa/sheet_landscape.jpg filter=lfs diff=lfs merge=lfs -text
161
  wolf-coming/web/public/film/qa/sheet_portrait.jpg filter=lfs diff=lfs merge=lfs -text
162
  wolf-coming/web/public/film/voice.mp3 filter=lfs diff=lfs merge=lfs -text
163
+ wolf-coming/video/film/fonts/mashanzheng.woff2 filter=lfs diff=lfs merge=lfs -text
164
+ wolf-coming/video/film/fonts/wenkai.woff2 filter=lfs diff=lfs merge=lfs -text
165
+ wolf-coming/video/film/voice.mp3 filter=lfs diff=lfs merge=lfs -text
166
+ wolf-coming/video/wolf/voice.mp3 filter=lfs diff=lfs merge=lfs -text
167
+ wolf-coming/web/public/wolf/voice.mp3 filter=lfs diff=lfs merge=lfs -text
wolf-coming/README.md CHANGED
@@ -60,12 +60,16 @@
60
  - 每帧只由 t 决定;`vibe det` 校验。
61
 
62
  ## 4. 步骤
63
- 1. 写本 README → 推送 HF
64
- 2. 分镜 + 多角色配音 → 时间轴 → 推送
65
- 3. 引擎多片化(《丑小鸭》画面逐像素不变)+ 本片角色与 14 场 → 接触表 / 静帧 / det → 推送
66
- 4. 配乐音效混音 + 网页 → 推送
67
  5. 封面 + 四平台文案 + 实况图 → 推送
68
  6. 全片渲染 + `vibe qa` → 修复 → 部署 `/wolf-coming/` → 最终推送
69
 
70
  ## 5. 进度日志
71
  - **2026-10-18** 写下本计划。沿用上一部确认过的风格与交付要求。
 
 
 
 
 
60
  - 每帧只由 t 决定;`vibe det` 校验。
61
 
62
  ## 4. 步骤
63
+ 1. ✅ 写本 README → 推送 HF
64
+ 2. ✅ 分镜 + 多角色配音 → 时间轴 → 推送
65
+ 3. ✅ 引擎多片化(《丑小鸭》画面逐像素不变)+ 本片角色与 14 场 → 接触表 / 静帧 / det → 推送
66
+ 4. ✅ 配乐音效混音 + 网页 → 推送
67
  5. 封面 + 四平台文案 + 实况图 → 推送
68
  6. 全片渲染 + `vibe qa` → 修复 → 部署 `/wolf-coming/` → 最终推送
69
 
70
  ## 5. 进度日志
71
  - **2026-10-18** 写下本计划。沿用上一部确认过的风格与交付要求。
72
+ - **2026-10-18** 分镜 14 场(`video/storyboard.json`),23 句台词三种声音:旁白晓晓、放羊娃云夏、农民云健。`scripts/tts_multi.py` 逐句合成 → 按角色统一响度 → 拼接人声轨 + `timeline.json`(与 vibe 同格式,另含 lines/who),全片 137.9 秒。
73
+ - **2026-10-18** 引擎多片化:`web/src/film/` 只放共用引擎(宣纸 / 毛笔 / 合成器 / 混音器 / 导出 / 封面 / 实况图),每部片子在 `web/src/films/<片名>/` 提供时间轴、场景、配乐、文案。改造后《丑小鸭》11 个时间点逐帧 MD5 与改造前完全一致;音轨差异 ≤1 LSB(与同一份旧代码两次渲染之间的差异相同,来自浏览器混响多线程)。
74
+ - **2026-10-18** 本片角色:放羊娃(IK 骨架:站、走、躺、喊、笑、怕、坐抱小羊;冲天辫、红褂、表情系统)、农民 ×4(头巾 / 草帽 / 发髻,锄地、扛锄奔跑、叉腰、指责、摆手)、羊(卷毛、吃草、奔逃)、狼(潜行、奔跑、龇牙、叼羊、树丛里的绿眼睛);景物:梯田、茅草屋炊烟、山石皴法、大树、灌木、乌云;「狼来了」朱红大字从嘴边喊出、第三次被风吹散成墨点。
75
+ - **2026-10-18** 配乐音效:调皮主题(拨弦跳音 + 木鱼)、田园笛、狼来时低音颤奏 + 心跳 + 不协和重音、羊群奔逃的急促颤奏、夕阳下的笛声哀歌;音效含合成羊叫、山谷回声(把放羊娃的喊声再放三遍、越来越远)、锄头落地(与画面锄地动作逐拍对齐)、脚步、喘气、雷声、狼嚎、低吼、羊群奔逃、蟋蟀。响度 −14.0 LUFS。
wolf-coming/scripts/build_bundle.sh ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ #!/bin/sh
2
+ # 打包无头渲染入口(两个 vibe 项目共用同一份 bundle;狼来了的 index.html 自动带 ?film=wolf)
3
+ cd /workspaces/frontend && ./node_modules/.bin/esbuild src/film/standalone.ts --bundle --format=iife --outfile=/workspaces/ugly-duckling/video/film.bundle.js --loader:.json=json --log-level=error && cp /workspaces/ugly-duckling/video/film.bundle.js /workspaces/wolf-coming/video/film.bundle.js
wolf-coming/scripts/tts_multi.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """多角色配音 → timeline.json(与 vibe-voice tts.py 同格式,另含 lines/who)+ audio/voice.wav + out/subs.srt
3
+ 每个场景的台词按顺序合成(edge-tts,不同角色不同音色),句间留气口;场景时长 = max(min_dur, 前导 + 台词 + 气口 + 尾巴)。"""
4
+ import asyncio, json, re, subprocess, sys
5
+ from pathlib import Path
6
+ import edge_tts
7
+ proj = Path(sys.argv[1]).resolve(); sb = json.loads((proj / 'storyboard.json').read_text(encoding='utf-8'))
8
+ V = sb['voices']; fps = sb['format']['fps']; LEAD, TAIL, GAP, GAP2 = 0.5, 0.9, 0.22, 0.38
9
+ ad = proj / 'audio'; ad.mkdir(exist_ok=True); (proj / 'out').mkdir(exist_ok=True)
10
+ def dur(p): return float(subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', str(p)], capture_output=True, text=True).stdout.strip())
11
+ def loud(p):
12
+ e = subprocess.run(['ffmpeg', '-nostats', '-i', str(p), '-filter_complex', 'ebur128', '-f', 'null', '-'], capture_output=True, text=True).stderr
13
+ m = re.findall(r'I:\s+(-?[\d.]+) LUFS', e); return float(m[-1]) if m else -20.0
14
+ TARGET = {'n': -17.0, 'boy': -16.0, 'farmer': -16.5} # 各角色台词统一响度(喊叫稍响)
15
+ def split(text, maxlen=16):
16
+ parts = [x.strip() for x in re.split(r'(?<=[,。!?;:])', text) if x.strip()]
17
+ out = []
18
+ for p in parts:
19
+ p = p.rstrip(',。;:')
20
+ while len(p) > maxlen: out.append(p[:maxlen]); p = p[maxlen:]
21
+ if p: out.append(p)
22
+ return out
23
+ async def say(text, v, path):
24
+ for k in range(3):
25
+ try: await edge_tts.Communicate(text, v['voice'], rate=v['rate'], pitch=v['pitch']).save(str(path)); return
26
+ except Exception as e:
27
+ if k == 2: raise
28
+ t = 0.0; scenes = []; clips = []; srt = []
29
+ for i, sc in enumerate(sb['scenes']):
30
+ start = t; cur = start + LEAD; lines = []; prev = None
31
+ for j, ln in enumerate(sc['lines']):
32
+ f = ad / f"v_{i+1:02}_{j+1}_{ln['who']}.mp3"
33
+ if not f.exists(): asyncio.run(say(ln['text'], V[ln['who']], f))
34
+ d = dur(f)
35
+ if prev is not None: cur += GAP2 if prev != ln['who'] else GAP
36
+ subs = split(ln['text']); tot = sum(len(s) for s in subs); x = cur; sub_items = []
37
+ for s in subs:
38
+ dd = d * len(s) / tot; sub_items.append({'t0': round(x, 3), 't1': round(x + dd, 3), 'text': s, 'who': ln['who']}); x += dd
39
+ lines.append({'who': ln['who'], 'text': ln['text'], 't0': round(cur, 3), 'dur': round(d, 3), 'subs': sub_items})
40
+ clips.append((f, cur, loud(f), ln['who'])); cur += d; prev = ln['who']
41
+ dd = max(sc.get('min_dur', 2), cur + TAIL - start); dd = round(dd * fps) / fps
42
+ subs_all = [s for l in lines for s in l['subs']]
43
+ scenes.append({'id': sc['id'], 'start': round(start, 4), 'dur': dd, 'voice_start': lines[0]['t0'], 'voice_dur': round(cur - lines[0]['t0'], 3), 'lines': lines, 'subs': subs_all})
44
+ print(f"{sc['id']:8} {start:7.2f}s +{dd:5.2f}s " + ' / '.join(f"{l['who']}:{l['text'][:10]}" for l in lines))
45
+ t = start + dd
46
+ total = round(t * fps) / fps
47
+ tl = {'title': sb['title'], 'style': sb['style'], 'w': sb['format']['w'], 'h': sb['format']['h'], 'fps': fps, 'total': total, 'frames': int(round(total * fps)), 'voice': {k: v['voice'] for k, v in V.items()}, 'scenes': scenes}
48
+ (proj / 'timeline.json').write_text(json.dumps(tl, ensure_ascii=False, indent=1), encoding='utf-8')
49
+ # 拼接人声轨:每句按时间放置
50
+ args = ['ffmpeg', '-loglevel', 'error', '-y']
51
+ for c in clips: args += ['-i', str(c[0])]
52
+ fl = ''.join(f"[{k}:a]aresample=48000,volume={TARGET[who] - L:.2f}dB,adelay={int(st*1000)}|{int(st*1000)}[a{k}];" for k, (_, st, L, who) in enumerate(clips))
53
+ fl += ''.join(f"[a{k}]" for k in range(len(clips))) + f"amix=inputs={len(clips)}:normalize=0:dropout_transition=0,apad,atrim=0:{total}[out]"
54
+ subprocess.run(args + ['-filter_complex', fl, '-map', '[out]', '-ac', '2', str(ad / 'voice.wav')], check=True)
55
+ def ts(x): ms = int(round(x * 1000)); return f"{ms//3600000:02}:{ms//60000%60:02}:{ms//1000%60:02},{ms%1000:03}"
56
+ n = 0
57
+ for s in scenes:
58
+ for c in s['subs']:
59
+ n += 1; srt.append(f"{n}\n{ts(c['t0'])} --> {ts(c['t1'])}\n{c['text']}\n")
60
+ (proj / 'out/subs.srt').write_text('\n'.join(srt), encoding='utf-8')
61
+ print(f'总时长 {total:.2f}s,{len(clips)} 句')
wolf-coming/video/audio/v_01_1_n.mp3 ADDED
Binary file (15.4 kB). View file
 
wolf-coming/video/audio/v_02_1_n.mp3 ADDED
Binary file (55 kB). View file
 
wolf-coming/video/audio/v_03_1_n.mp3 ADDED
Binary file (68.5 kB). View file
 
wolf-coming/video/audio/v_04_1_n.mp3 ADDED
Binary file (20.2 kB). View file
 
wolf-coming/video/audio/v_04_2_boy.mp3 ADDED
Binary file (24.9 kB). View file
 
wolf-coming/video/audio/v_05_1_n.mp3 ADDED
Binary file (40.6 kB). View file
 
wolf-coming/video/audio/v_05_2_farmer.mp3 ADDED
Binary file (19.6 kB). View file
 
wolf-coming/video/audio/v_06_1_boy.mp3 ADDED
Binary file (22.9 kB). View file
 
wolf-coming/video/audio/v_06_2_n.mp3 ADDED
Binary file (22.8 kB). View file
 
wolf-coming/video/audio/v_07_1_n.mp3 ADDED
Binary file (19.3 kB). View file
 
wolf-coming/video/audio/v_07_2_boy.mp3 ADDED
Binary file (17.4 kB). View file
 
wolf-coming/video/audio/v_07_3_n.mp3 ADDED
Binary file (15.7 kB). View file
 
wolf-coming/video/audio/v_08_1_boy.mp3 ADDED
Binary file (14.1 kB). View file
 
wolf-coming/video/audio/v_08_2_farmer.mp3 ADDED
Binary file (23.2 kB). View file
 
wolf-coming/video/audio/v_09_1_n.mp3 ADDED
Binary file (63.5 kB). View file
 
wolf-coming/video/audio/v_10_1_n.mp3 ADDED
Binary file (19.2 kB). View file
 
wolf-coming/video/audio/v_10_2_boy.mp3 ADDED
Binary file (26.5 kB). View file
 
wolf-coming/video/audio/v_11_1_n.mp3 ADDED
Binary file (25.8 kB). View file
 
wolf-coming/video/audio/v_11_2_farmer.mp3 ADDED
Binary file (16 kB). View file
 
wolf-coming/video/audio/v_11_3_n.mp3 ADDED
Binary file (12.2 kB). View file
 
wolf-coming/video/audio/v_12_1_n.mp3 ADDED
Binary file (35.9 kB). View file
 
wolf-coming/video/audio/v_13_1_n.mp3 ADDED
Binary file (32 kB). View file
 
wolf-coming/video/audio/v_14_1_n.mp3 ADDED
Binary file (43.3 kB). View file
 
wolf-coming/video/film.bundle.js ADDED
The diff for this file is too large to render. See raw diff
 
wolf-coming/video/film/fonts/chars.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ !#%+,-./0123456789:;?@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz·—‘’“”…、。《》「」【】・一三上下不与丑世丛东丝两个中为主丽举久么乎乐也乡书乱了事二于云五些交享亮亲人仅今介从他代以们仰任伙会传伴位低住体何作你例侧便俏保信倒候值停傍像儿充入全公六共关其具内再写冠冬冰冲决况冷冻准几出击函分则创到制前剩副力加动勾匀包化区半单印即压原又叉友发取变叠口古句另只叫可台右叶号叽各合同名后向吓吗吧呆告命和咔咦咬哀哈响哔哥哩哪唧啄喂善喊喙喜喳嘲嘴器噪嚓四回团围固国图圆圈在地场均坐块垂垛域堆塘填境增墨壁声壳处备复夏外多夜大天太失头夹奇奏她好妈始姐姑娘嫌子字存季孤学孩孵它安完官定实宣宫家宽寂寄密寒寸对导封小尖尚就尺尼尾层屋屏展属山岸峰工左差己已布希帘带帧幅幕干平并序应底度廓延建开式引张弦弯弹归当录形彩影往径很徒得循微心快念忽态怕性怪总息悄悬情惊愈意感慢成截所扇手才扑打扫扬把抖折报拉拍拖拢拨择持按挖振换掉排掠接控推描提插揭撇播擎支收改放政故效敢数整文斑断新旁无日时明春是昼显晓晕晚景晰暂暖暗曲更替最有朋望朝期木未本术朱机权村条来极构果枝枪染柔查柳标树校样核根格桃案梅梦梳检楷模横檐欠次欢欺款歌正此步段每比毛气水池治沼沿法波泥泪泽洁流测浏浓浮消淡深混清渐渡温渲游湖湿溅源溪滴漂漫潮灰点烟烧然照熬片版牙物状猎率环现理瓣生用田由电画畅界痕登白的皮皴益盖盛目直相省看真眼着睡瞬瞻短码砂砰破确碍示神离秋种秒积程空窗窝立竖章童竹笆笑笔笛符等筝签简篇篱簧粒粘系素紧红纤约级纳纷纸纹线组终经绒结绘给络绪续维缓编缘缩网置美群羽翅翻翼而肉背能脊脑脖脚脸腿膀臂自致色艺节芦花芽苇苹范茸草荷落蓝藏蛋蜓蜻蝴蝶融行衣补表被裁裂装西要见观规视览觉角解触计让记讲证试话语说请读课调负败质贴资走起路跳身躲轨转轮软轴轻载输辨边过近还这进远连迟述追送适选逐递通遮那部都配采里重野量金钟钻铃铺错键镜长门间阅阴阵阶阻附限除随难雁雪雾需霞露静面音页顶顿预领颈频颗题颜颤风飘飞马驳验高髻鱼鸟鸡鸣鸭鹅鹜麦黄黑齐️︐!()+,:;?|🐓👀🥚🦢
wolf-coming/video/film/fonts/mashanzheng.woff2 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fe7ce130e02290a7dff93743e8a7492fb7c41bdbe9d6b338945144e018735476
3
+ size 420940
wolf-coming/video/film/fonts/wenkai.woff2 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:14374c15ca95593a0fc159be8f69f4435a3e7c4206b1817fd7bfba9495222f02
3
+ size 240976
wolf-coming/video/film/voice.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:50046e8a3fb27e30e64cb0ca975c630a567ff84a3e6a217076501607745250fe
3
+ size 1655162
wolf-coming/video/index.html ADDED
@@ -0,0 +1 @@
 
 
1
+ <!doctype html><html><head><meta charset="utf-8"><title>狼来了 · render</title></head><body><script>if(!/[?&]film=/.test(location.search)){history.replaceState(null,'',location.pathname+(location.search?location.search+'&':'?')+'film=wolf')}</script><script src="film.bundle.js"></script></body></html>
wolf-coming/video/out/subs.srt ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 1
2
+ 00:00:00,500 --> 00:00:01,967
3
+ 伊索寓言
4
+
5
+ 2
6
+ 00:00:01,967 --> 00:00:03,068
7
+ 狼来了
8
+
9
+ 3
10
+ 00:00:07,000 --> 00:00:09,096
11
+ 山脚下有个小村子
12
+
13
+ 4
14
+ 00:00:09,096 --> 00:00:11,715
15
+ 大人们每天在田里干活
16
+
17
+ 5
18
+ 00:00:11,715 --> 00:00:13,549
19
+ 有个放羊的孩子
20
+
21
+ 6
22
+ 00:00:13,549 --> 00:00:16,168
23
+ 天天赶着羊群上山吃草
24
+
25
+ 7
26
+ 00:00:17,567 --> 00:00:18,403
27
+ 山坡上
28
+
29
+ 8
30
+ 00:00:18,403 --> 00:00:20,910
31
+ 羊儿慢悠悠地吃着草
32
+
33
+ 9
34
+ 00:00:20,910 --> 00:00:23,139
35
+ 放羊娃躺在大树下
36
+
37
+ 10
38
+ 00:00:23,139 --> 00:00:25,090
39
+ 嘴里叼着一根草
40
+
41
+ 11
42
+ 00:00:25,090 --> 00:00:26,762
43
+ 觉得无聊极了
44
+
45
+ 12
46
+ 00:00:26,762 --> 00:00:27,319
47
+ 忽然
48
+
49
+ 13
50
+ 00:00:27,319 --> 00:00:28,991
51
+ 他的眼珠一转
52
+
53
+ 14
54
+ 00:00:30,400 --> 00:00:32,233
55
+ 他跳上大石头
56
+
57
+ 15
58
+ 00:00:32,233 --> 00:00:33,760
59
+ 拢着嘴大喊
60
+
61
+ 16
62
+ 00:00:34,140 --> 00:00:35,524
63
+ 狼来了!
64
+
65
+ 17
66
+ 00:00:35,524 --> 00:00:36,908
67
+ 狼来了!
68
+
69
+ 18
70
+ 00:00:36,908 --> 00:00:38,292
71
+ 快来呀!
72
+
73
+ 19
74
+ 00:00:39,700 --> 00:00:41,956
75
+ 山下的农民听见了
76
+
77
+ 20
78
+ 00:00:41,956 --> 00:00:43,084
79
+ 扛起锄头
80
+
81
+ 21
82
+ 00:00:43,084 --> 00:00:44,212
83
+ 拿起扁担
84
+
85
+ 22
86
+ 00:00:44,212 --> 00:00:46,468
87
+ 急急忙忙往山上跑
88
+
89
+ 23
90
+ 00:00:46,848 --> 00:00:48,480
91
+ 狼在哪儿?
92
+
93
+ 24
94
+ 00:00:48,480 --> 00:00:50,112
95
+ 狼在哪儿?
96
+
97
+ 25
98
+ 00:00:51,500 --> 00:00:52,215
99
+ 哈哈哈
100
+
101
+ 26
102
+ 00:00:52,215 --> 00:00:53,408
103
+ 哪有什么狼
104
+
105
+ 27
106
+ 00:00:53,408 --> 00:00:55,316
107
+ 我是逗你们玩的!
108
+
109
+ 28
110
+ 00:00:55,696 --> 00:00:57,863
111
+ 农民们气得直摇头
112
+
113
+ 29
114
+ 00:00:57,863 --> 00:00:59,488
115
+ 只好下山去了
116
+
117
+ 30
118
+ 00:01:00,900 --> 00:01:02,069
119
+ 过了几天
120
+
121
+ 31
122
+ 00:01:02,069 --> 00:01:04,116
123
+ 放羊娃又喊起来
124
+
125
+ 32
126
+ 00:01:04,496 --> 00:01:05,948
127
+ 狼来了!
128
+
129
+ 33
130
+ 00:01:05,948 --> 00:01:07,400
131
+ 狼来了!
132
+
133
+ 34
134
+ 00:01:07,780 --> 00:01:10,396
135
+ 农民们又一次跑上了山
136
+
137
+ 35
138
+ 00:01:11,800 --> 00:01:12,472
139
+ 哈哈
140
+
141
+ 36
142
+ 00:01:12,472 --> 00:01:14,152
143
+ 又上当啦!
144
+
145
+ 37
146
+ 00:01:14,532 --> 00:01:15,636
147
+ 你再说谎
148
+
149
+ 38
150
+ 00:01:15,636 --> 00:01:18,396
151
+ 下次我们就不信你了!
152
+
153
+ 39
154
+ 00:01:19,800 --> 00:01:21,564
155
+ 又过了些日子
156
+
157
+ 40
158
+ 00:01:21,564 --> 00:01:23,328
159
+ 乌云压着山头
160
+
161
+ 41
162
+ 00:01:23,328 --> 00:01:24,210
163
+ 树丛里
164
+
165
+ 42
166
+ 00:01:24,210 --> 00:01:26,562
167
+ 一双绿莹莹的眼睛
168
+
169
+ 43
170
+ 00:01:26,562 --> 00:01:28,620
171
+ 慢慢靠近了羊群
172
+
173
+ 44
174
+ 00:01:28,620 --> 00:01:28,914
175
+ 狼
176
+
177
+ 45
178
+ 00:01:28,914 --> 00:01:30,384
179
+ 真的来了!
180
+
181
+ 46
182
+ 00:01:31,800 --> 00:01:33,715
183
+ 放羊娃吓坏了
184
+
185
+ 47
186
+ 00:01:33,715 --> 00:01:34,992
187
+ 拼命地喊
188
+
189
+ 48
190
+ 00:01:35,372 --> 00:01:36,634
191
+ 狼来了!
192
+
193
+ 49
194
+ 00:01:36,634 --> 00:01:38,526
195
+ 狼真的来了!
196
+
197
+ 50
198
+ 00:01:38,526 --> 00:01:39,788
199
+ 救命啊!
200
+
201
+ 51
202
+ 00:01:41,200 --> 00:01:43,348
203
+ 山下的农民听见了
204
+
205
+ 52
206
+ 00:01:43,348 --> 00:01:45,496
207
+ 都以为他又在说谎
208
+
209
+ 53
210
+ 00:01:45,876 --> 00:01:46,409
211
+ 哼
212
+
213
+ 54
214
+ 00:01:46,409 --> 00:01:48,540
215
+ 又来骗人
216
+
217
+ 55
218
+ 00:01:48,920 --> 00:01:50,960
219
+ 谁也没有上山
220
+
221
+ 56
222
+ 00:01:52,367 --> 00:01:53,725
223
+ 狼冲进羊群
224
+
225
+ 57
226
+ 00:01:53,725 --> 00:01:55,898
227
+ 羊儿吓得四处乱跑
228
+
229
+ 58
230
+ 00:01:55,898 --> 00:01:58,343
231
+ 好几只羊被狼叼走了
232
+
233
+ 59
234
+ 00:02:00,367 --> 00:02:01,699
235
+ 太阳落山了
236
+
237
+ 60
238
+ 00:02:01,699 --> 00:02:04,363
239
+ 放羊娃抱着剩下的小羊
240
+
241
+ 61
242
+ 00:02:04,363 --> 00:02:05,695
243
+ 伤心地哭了
244
+
245
+ 62
246
+ 00:02:08,367 --> 00:02:09,437
247
+ 说谎的人
248
+
249
+ 63
250
+ 00:02:09,437 --> 00:02:11,042
251
+ 就算说了真话
252
+
253
+ 64
254
+ 00:02:11,042 --> 00:02:13,183
255
+ 也没有人再相信他
256
+
257
+ 65
258
+ 00:02:13,183 --> 00:02:15,591
259
+ 做一个诚实的孩子吧
wolf-coming/video/storyboard.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "title": "狼来了",
3
+ "format": { "w": 1920, "h": 1080, "fps": 30 },
4
+ "style": "ink-wash textbook illustration",
5
+ "voices": {
6
+ "n": { "voice": "zh-CN-XiaoxiaoNeural", "rate": "-6%", "pitch": "+0Hz", "name": "旁白" },
7
+ "boy": { "voice": "zh-CN-YunxiaNeural", "rate": "+4%", "pitch": "+6Hz", "name": "放羊娃" },
8
+ "farmer": { "voice": "zh-CN-YunjianNeural", "rate": "-2%", "pitch": "-2Hz", "name": "农民" }
9
+ },
10
+ "sources": [
11
+ "Aesop's Fables, \"The Boy Who Cried Wolf\" (Perry Index 210) — public domain",
12
+ "https://en.wikipedia.org/wiki/The_Boy_Who_Cried_Wolf"
13
+ ],
14
+ "scenes": [
15
+ { "id": "title", "min_dur": 6.5, "lines": [{ "who": "n", "text": "伊索寓言,狼来了。" }] },
16
+ { "id": "village", "min_dur": 8, "lines": [{ "who": "n", "text": "山脚下有个小村子。大人们每天在田里干活,有个放羊的孩子,天天赶着羊群上山吃草。" }] },
17
+ { "id": "bored", "min_dur": 8, "lines": [{ "who": "n", "text": "山坡上,羊儿慢悠悠地吃着草。放羊娃躺在大树下,嘴里叼着一根草,觉得无聊极了。忽然,他的眼珠一转。" }] },
18
+ { "id": "shout1", "min_dur": 6, "lines": [{ "who": "n", "text": "他跳上大石头,拢着嘴大喊:" }, { "who": "boy", "text": "狼来了!狼来了!快来呀!" }] },
19
+ { "id": "run1", "min_dur": 7, "lines": [{ "who": "n", "text": "山下的农民听见了,扛起锄头,拿起扁担,急急忙忙往山上跑。" }, { "who": "farmer", "text": "狼在哪儿?狼在哪儿?" }] },
20
+ { "id": "laugh1", "min_dur": 7, "lines": [{ "who": "boy", "text": "哈哈哈,哪有什么狼,我是逗你们玩的!" }, { "who": "n", "text": "农民们气得直摇头,只好下山去了。" }] },
21
+ { "id": "shout2", "min_dur": 7, "lines": [{ "who": "n", "text": "过了几天,放羊娃又喊起来:" }, { "who": "boy", "text": "狼来了!狼来了!" }, { "who": "n", "text": "农民们又一次跑上了山。" }] },
22
+ { "id": "warn", "min_dur": 7, "lines": [{ "who": "boy", "text": "哈哈,又上当啦!" }, { "who": "farmer", "text": "你再说谎,下次我们就不信你了!" }] },
23
+ { "id": "wolf", "min_dur": 9, "lines": [{ "who": "n", "text": "又过了些日子,乌云压着山头。树丛里,一双绿莹莹的眼睛,慢慢靠近了羊群。狼,真的来了!" }] },
24
+ { "id": "cry", "min_dur": 7, "lines": [{ "who": "n", "text": "放羊娃吓坏了,拼命地喊:" }, { "who": "boy", "text": "狼来了!狼真的来了!救命啊!" }] },
25
+ { "id": "noone", "min_dur": 7, "lines": [{ "who": "n", "text": "山下的农民听见了,都以为他又在说谎。" }, { "who": "farmer", "text": "哼,又来骗人。" }, { "who": "n", "text": "谁也没有上山。" }] },
26
+ { "id": "chaos", "min_dur": 8, "lines": [{ "who": "n", "text": "狼冲进羊群,羊儿吓得四处乱跑,好几只羊被狼叼走了。" }] },
27
+ { "id": "regret", "min_dur": 8, "lines": [{ "who": "n", "text": "太阳落山了,放羊娃抱着剩下的小羊,伤心地哭了。" }] },
28
+ { "id": "moral", "min_dur": 10, "lines": [{ "who": "n", "text": "说谎的人,就算说了真话,也没有人再相信他。做一个诚实的孩子吧。" }] }
29
+ ]
30
+ }
wolf-coming/video/timeline.json ADDED
@@ -0,0 +1,1134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "title": "狼来了",
3
+ "style": "ink-wash textbook illustration",
4
+ "w": 1920,
5
+ "h": 1080,
6
+ "fps": 30,
7
+ "total": 137.86666666666667,
8
+ "frames": 4136,
9
+ "voice": {
10
+ "n": "zh-CN-XiaoxiaoNeural",
11
+ "boy": "zh-CN-YunxiaNeural",
12
+ "farmer": "zh-CN-YunjianNeural"
13
+ },
14
+ "scenes": [
15
+ {
16
+ "id": "title",
17
+ "start": 0.0,
18
+ "dur": 6.5,
19
+ "voice_start": 0.5,
20
+ "voice_dur": 2.568,
21
+ "lines": [
22
+ {
23
+ "who": "n",
24
+ "text": "伊索寓言,狼来了。",
25
+ "t0": 0.5,
26
+ "dur": 2.568,
27
+ "subs": [
28
+ {
29
+ "t0": 0.5,
30
+ "t1": 1.967,
31
+ "text": "伊索寓言",
32
+ "who": "n"
33
+ },
34
+ {
35
+ "t0": 1.967,
36
+ "t1": 3.068,
37
+ "text": "狼来了",
38
+ "who": "n"
39
+ }
40
+ ]
41
+ }
42
+ ],
43
+ "subs": [
44
+ {
45
+ "t0": 0.5,
46
+ "t1": 1.967,
47
+ "text": "伊索寓言",
48
+ "who": "n"
49
+ },
50
+ {
51
+ "t0": 1.967,
52
+ "t1": 3.068,
53
+ "text": "狼来了",
54
+ "who": "n"
55
+ }
56
+ ]
57
+ },
58
+ {
59
+ "id": "village",
60
+ "start": 6.5,
61
+ "dur": 10.566666666666666,
62
+ "voice_start": 7.0,
63
+ "voice_dur": 9.168,
64
+ "lines": [
65
+ {
66
+ "who": "n",
67
+ "text": "山脚下有个小村子。大人们每天在田里干活,有个放羊的孩子,天天赶着羊群上山吃草。",
68
+ "t0": 7.0,
69
+ "dur": 9.168,
70
+ "subs": [
71
+ {
72
+ "t0": 7.0,
73
+ "t1": 9.096,
74
+ "text": "山脚下有个小村子",
75
+ "who": "n"
76
+ },
77
+ {
78
+ "t0": 9.096,
79
+ "t1": 11.715,
80
+ "text": "大人们每天在田里干活",
81
+ "who": "n"
82
+ },
83
+ {
84
+ "t0": 11.715,
85
+ "t1": 13.549,
86
+ "text": "有个放羊的孩子",
87
+ "who": "n"
88
+ },
89
+ {
90
+ "t0": 13.549,
91
+ "t1": 16.168,
92
+ "text": "天天赶着羊群上山吃草",
93
+ "who": "n"
94
+ }
95
+ ]
96
+ }
97
+ ],
98
+ "subs": [
99
+ {
100
+ "t0": 7.0,
101
+ "t1": 9.096,
102
+ "text": "山脚下有个小村子",
103
+ "who": "n"
104
+ },
105
+ {
106
+ "t0": 9.096,
107
+ "t1": 11.715,
108
+ "text": "大人们每天在田里干活",
109
+ "who": "n"
110
+ },
111
+ {
112
+ "t0": 11.715,
113
+ "t1": 13.549,
114
+ "text": "有个放羊的孩子",
115
+ "who": "n"
116
+ },
117
+ {
118
+ "t0": 13.549,
119
+ "t1": 16.168,
120
+ "text": "天天赶着羊群上山吃草",
121
+ "who": "n"
122
+ }
123
+ ]
124
+ },
125
+ {
126
+ "id": "bored",
127
+ "start": 17.0667,
128
+ "dur": 12.833333333333334,
129
+ "voice_start": 17.567,
130
+ "voice_dur": 11.424,
131
+ "lines": [
132
+ {
133
+ "who": "n",
134
+ "text": "山坡上,羊儿慢悠悠地吃着草。放羊娃躺在大树下,嘴里叼着一根草,觉得无聊极了。忽然,他的眼珠一转。",
135
+ "t0": 17.567,
136
+ "dur": 11.424,
137
+ "subs": [
138
+ {
139
+ "t0": 17.567,
140
+ "t1": 18.403,
141
+ "text": "山坡上",
142
+ "who": "n"
143
+ },
144
+ {
145
+ "t0": 18.403,
146
+ "t1": 20.91,
147
+ "text": "羊儿慢悠悠地吃着草",
148
+ "who": "n"
149
+ },
150
+ {
151
+ "t0": 20.91,
152
+ "t1": 23.139,
153
+ "text": "放羊娃躺在大树下",
154
+ "who": "n"
155
+ },
156
+ {
157
+ "t0": 23.139,
158
+ "t1": 25.09,
159
+ "text": "嘴里叼着一根草",
160
+ "who": "n"
161
+ },
162
+ {
163
+ "t0": 25.09,
164
+ "t1": 26.762,
165
+ "text": "觉得无聊极了",
166
+ "who": "n"
167
+ },
168
+ {
169
+ "t0": 26.762,
170
+ "t1": 27.319,
171
+ "text": "忽然",
172
+ "who": "n"
173
+ },
174
+ {
175
+ "t0": 27.319,
176
+ "t1": 28.991,
177
+ "text": "他的眼珠一转",
178
+ "who": "n"
179
+ }
180
+ ]
181
+ }
182
+ ],
183
+ "subs": [
184
+ {
185
+ "t0": 17.567,
186
+ "t1": 18.403,
187
+ "text": "山坡上",
188
+ "who": "n"
189
+ },
190
+ {
191
+ "t0": 18.403,
192
+ "t1": 20.91,
193
+ "text": "羊儿慢悠悠地吃着草",
194
+ "who": "n"
195
+ },
196
+ {
197
+ "t0": 20.91,
198
+ "t1": 23.139,
199
+ "text": "放羊娃躺在大树下",
200
+ "who": "n"
201
+ },
202
+ {
203
+ "t0": 23.139,
204
+ "t1": 25.09,
205
+ "text": "嘴里叼着一根草",
206
+ "who": "n"
207
+ },
208
+ {
209
+ "t0": 25.09,
210
+ "t1": 26.762,
211
+ "text": "觉得无聊极了",
212
+ "who": "n"
213
+ },
214
+ {
215
+ "t0": 26.762,
216
+ "t1": 27.319,
217
+ "text": "忽然",
218
+ "who": "n"
219
+ },
220
+ {
221
+ "t0": 27.319,
222
+ "t1": 28.991,
223
+ "text": "他的眼珠一转",
224
+ "who": "n"
225
+ }
226
+ ]
227
+ },
228
+ {
229
+ "id": "shout1",
230
+ "start": 29.9,
231
+ "dur": 9.3,
232
+ "voice_start": 30.4,
233
+ "voice_dur": 7.892,
234
+ "lines": [
235
+ {
236
+ "who": "n",
237
+ "text": "他跳上大石头,拢着嘴大喊:",
238
+ "t0": 30.4,
239
+ "dur": 3.36,
240
+ "subs": [
241
+ {
242
+ "t0": 30.4,
243
+ "t1": 32.233,
244
+ "text": "他跳上大石头",
245
+ "who": "n"
246
+ },
247
+ {
248
+ "t0": 32.233,
249
+ "t1": 33.76,
250
+ "text": "拢着嘴大喊",
251
+ "who": "n"
252
+ }
253
+ ]
254
+ },
255
+ {
256
+ "who": "boy",
257
+ "text": "狼来了!狼来了!快来呀!",
258
+ "t0": 34.14,
259
+ "dur": 4.152,
260
+ "subs": [
261
+ {
262
+ "t0": 34.14,
263
+ "t1": 35.524,
264
+ "text": "狼来了!",
265
+ "who": "boy"
266
+ },
267
+ {
268
+ "t0": 35.524,
269
+ "t1": 36.908,
270
+ "text": "狼来了!",
271
+ "who": "boy"
272
+ },
273
+ {
274
+ "t0": 36.908,
275
+ "t1": 38.292,
276
+ "text": "快来呀!",
277
+ "who": "boy"
278
+ }
279
+ ]
280
+ }
281
+ ],
282
+ "subs": [
283
+ {
284
+ "t0": 30.4,
285
+ "t1": 32.233,
286
+ "text": "他跳上大石头",
287
+ "who": "n"
288
+ },
289
+ {
290
+ "t0": 32.233,
291
+ "t1": 33.76,
292
+ "text": "拢着嘴大喊",
293
+ "who": "n"
294
+ },
295
+ {
296
+ "t0": 34.14,
297
+ "t1": 35.524,
298
+ "text": "狼来了!",
299
+ "who": "boy"
300
+ },
301
+ {
302
+ "t0": 35.524,
303
+ "t1": 36.908,
304
+ "text": "狼来了!",
305
+ "who": "boy"
306
+ },
307
+ {
308
+ "t0": 36.908,
309
+ "t1": 38.292,
310
+ "text": "快来呀!",
311
+ "who": "boy"
312
+ }
313
+ ]
314
+ },
315
+ {
316
+ "id": "run1",
317
+ "start": 39.2,
318
+ "dur": 11.8,
319
+ "voice_start": 39.7,
320
+ "voice_dur": 10.412,
321
+ "lines": [
322
+ {
323
+ "who": "n",
324
+ "text": "山下的农民听见了,扛起锄头,拿起扁担,急急忙忙往山上跑。",
325
+ "t0": 39.7,
326
+ "dur": 6.768,
327
+ "subs": [
328
+ {
329
+ "t0": 39.7,
330
+ "t1": 41.956,
331
+ "text": "山下的农民听见了",
332
+ "who": "n"
333
+ },
334
+ {
335
+ "t0": 41.956,
336
+ "t1": 43.084,
337
+ "text": "扛起锄头",
338
+ "who": "n"
339
+ },
340
+ {
341
+ "t0": 43.084,
342
+ "t1": 44.212,
343
+ "text": "拿起扁担",
344
+ "who": "n"
345
+ },
346
+ {
347
+ "t0": 44.212,
348
+ "t1": 46.468,
349
+ "text": "急急忙忙往山上跑",
350
+ "who": "n"
351
+ }
352
+ ]
353
+ },
354
+ {
355
+ "who": "farmer",
356
+ "text": "狼在哪儿?狼在哪儿?",
357
+ "t0": 46.848,
358
+ "dur": 3.264,
359
+ "subs": [
360
+ {
361
+ "t0": 46.848,
362
+ "t1": 48.48,
363
+ "text": "狼在哪儿?",
364
+ "who": "farmer"
365
+ },
366
+ {
367
+ "t0": 48.48,
368
+ "t1": 50.112,
369
+ "text": "狼在哪儿?",
370
+ "who": "farmer"
371
+ }
372
+ ]
373
+ }
374
+ ],
375
+ "subs": [
376
+ {
377
+ "t0": 39.7,
378
+ "t1": 41.956,
379
+ "text": "山下的农民听见了",
380
+ "who": "n"
381
+ },
382
+ {
383
+ "t0": 41.956,
384
+ "t1": 43.084,
385
+ "text": "扛起锄头",
386
+ "who": "n"
387
+ },
388
+ {
389
+ "t0": 43.084,
390
+ "t1": 44.212,
391
+ "text": "拿起扁担",
392
+ "who": "n"
393
+ },
394
+ {
395
+ "t0": 44.212,
396
+ "t1": 46.468,
397
+ "text": "急急忙忙往山上跑",
398
+ "who": "n"
399
+ },
400
+ {
401
+ "t0": 46.848,
402
+ "t1": 48.48,
403
+ "text": "狼在哪儿?",
404
+ "who": "farmer"
405
+ },
406
+ {
407
+ "t0": 48.48,
408
+ "t1": 50.112,
409
+ "text": "狼在哪儿?",
410
+ "who": "farmer"
411
+ }
412
+ ]
413
+ },
414
+ {
415
+ "id": "laugh1",
416
+ "start": 51.0,
417
+ "dur": 9.4,
418
+ "voice_start": 51.5,
419
+ "voice_dur": 7.988,
420
+ "lines": [
421
+ {
422
+ "who": "boy",
423
+ "text": "哈哈哈,哪有什么狼,我是逗你们玩的!",
424
+ "t0": 51.5,
425
+ "dur": 3.816,
426
+ "subs": [
427
+ {
428
+ "t0": 51.5,
429
+ "t1": 52.215,
430
+ "text": "哈哈哈",
431
+ "who": "boy"
432
+ },
433
+ {
434
+ "t0": 52.215,
435
+ "t1": 53.408,
436
+ "text": "哪有什么狼",
437
+ "who": "boy"
438
+ },
439
+ {
440
+ "t0": 53.408,
441
+ "t1": 55.316,
442
+ "text": "我是逗你们玩的!",
443
+ "who": "boy"
444
+ }
445
+ ]
446
+ },
447
+ {
448
+ "who": "n",
449
+ "text": "农民们气得直摇头,只好下山去了。",
450
+ "t0": 55.696,
451
+ "dur": 3.792,
452
+ "subs": [
453
+ {
454
+ "t0": 55.696,
455
+ "t1": 57.863,
456
+ "text": "农民们气得直摇头",
457
+ "who": "n"
458
+ },
459
+ {
460
+ "t0": 57.863,
461
+ "t1": 59.488,
462
+ "text": "只好下山去了",
463
+ "who": "n"
464
+ }
465
+ ]
466
+ }
467
+ ],
468
+ "subs": [
469
+ {
470
+ "t0": 51.5,
471
+ "t1": 52.215,
472
+ "text": "哈哈哈",
473
+ "who": "boy"
474
+ },
475
+ {
476
+ "t0": 52.215,
477
+ "t1": 53.408,
478
+ "text": "哪有什么狼",
479
+ "who": "boy"
480
+ },
481
+ {
482
+ "t0": 53.408,
483
+ "t1": 55.316,
484
+ "text": "我是逗你们玩的!",
485
+ "who": "boy"
486
+ },
487
+ {
488
+ "t0": 55.696,
489
+ "t1": 57.863,
490
+ "text": "农民们气得直摇头",
491
+ "who": "n"
492
+ },
493
+ {
494
+ "t0": 57.863,
495
+ "t1": 59.488,
496
+ "text": "只好下山去了",
497
+ "who": "n"
498
+ }
499
+ ]
500
+ },
501
+ {
502
+ "id": "shout2",
503
+ "start": 60.4,
504
+ "dur": 10.9,
505
+ "voice_start": 60.9,
506
+ "voice_dur": 9.496,
507
+ "lines": [
508
+ {
509
+ "who": "n",
510
+ "text": "过了几天,放羊娃又喊起来:",
511
+ "t0": 60.9,
512
+ "dur": 3.216,
513
+ "subs": [
514
+ {
515
+ "t0": 60.9,
516
+ "t1": 62.069,
517
+ "text": "过了几天",
518
+ "who": "n"
519
+ },
520
+ {
521
+ "t0": 62.069,
522
+ "t1": 64.116,
523
+ "text": "放羊娃又喊起来",
524
+ "who": "n"
525
+ }
526
+ ]
527
+ },
528
+ {
529
+ "who": "boy",
530
+ "text": "狼来了!狼来了!",
531
+ "t0": 64.496,
532
+ "dur": 2.904,
533
+ "subs": [
534
+ {
535
+ "t0": 64.496,
536
+ "t1": 65.948,
537
+ "text": "狼来了!",
538
+ "who": "boy"
539
+ },
540
+ {
541
+ "t0": 65.948,
542
+ "t1": 67.4,
543
+ "text": "狼来了!",
544
+ "who": "boy"
545
+ }
546
+ ]
547
+ },
548
+ {
549
+ "who": "n",
550
+ "text": "农民们又一次跑上了山。",
551
+ "t0": 67.78,
552
+ "dur": 2.616,
553
+ "subs": [
554
+ {
555
+ "t0": 67.78,
556
+ "t1": 70.396,
557
+ "text": "农民们又一次跑上了山",
558
+ "who": "n"
559
+ }
560
+ ]
561
+ }
562
+ ],
563
+ "subs": [
564
+ {
565
+ "t0": 60.9,
566
+ "t1": 62.069,
567
+ "text": "过了几天",
568
+ "who": "n"
569
+ },
570
+ {
571
+ "t0": 62.069,
572
+ "t1": 64.116,
573
+ "text": "放羊娃又喊起来",
574
+ "who": "n"
575
+ },
576
+ {
577
+ "t0": 64.496,
578
+ "t1": 65.948,
579
+ "text": "狼来了!",
580
+ "who": "boy"
581
+ },
582
+ {
583
+ "t0": 65.948,
584
+ "t1": 67.4,
585
+ "text": "狼来了!",
586
+ "who": "boy"
587
+ },
588
+ {
589
+ "t0": 67.78,
590
+ "t1": 70.396,
591
+ "text": "农民们又一次跑上了山",
592
+ "who": "n"
593
+ }
594
+ ]
595
+ },
596
+ {
597
+ "id": "warn",
598
+ "start": 71.3,
599
+ "dur": 8.0,
600
+ "voice_start": 71.8,
601
+ "voice_dur": 6.596,
602
+ "lines": [
603
+ {
604
+ "who": "boy",
605
+ "text": "哈哈,又上当啦!",
606
+ "t0": 71.8,
607
+ "dur": 2.352,
608
+ "subs": [
609
+ {
610
+ "t0": 71.8,
611
+ "t1": 72.472,
612
+ "text": "哈哈",
613
+ "who": "boy"
614
+ },
615
+ {
616
+ "t0": 72.472,
617
+ "t1": 74.152,
618
+ "text": "又上当啦!",
619
+ "who": "boy"
620
+ }
621
+ ]
622
+ },
623
+ {
624
+ "who": "farmer",
625
+ "text": "你再说谎,下次我们就不信你了!",
626
+ "t0": 74.532,
627
+ "dur": 3.864,
628
+ "subs": [
629
+ {
630
+ "t0": 74.532,
631
+ "t1": 75.636,
632
+ "text": "你再说谎",
633
+ "who": "farmer"
634
+ },
635
+ {
636
+ "t0": 75.636,
637
+ "t1": 78.396,
638
+ "text": "下次我们就不信你了!",
639
+ "who": "farmer"
640
+ }
641
+ ]
642
+ }
643
+ ],
644
+ "subs": [
645
+ {
646
+ "t0": 71.8,
647
+ "t1": 72.472,
648
+ "text": "哈哈",
649
+ "who": "boy"
650
+ },
651
+ {
652
+ "t0": 72.472,
653
+ "t1": 74.152,
654
+ "text": "又上当啦!",
655
+ "who": "boy"
656
+ },
657
+ {
658
+ "t0": 74.532,
659
+ "t1": 75.636,
660
+ "text": "你再说谎",
661
+ "who": "farmer"
662
+ },
663
+ {
664
+ "t0": 75.636,
665
+ "t1": 78.396,
666
+ "text": "下次我们就不信你了!",
667
+ "who": "farmer"
668
+ }
669
+ ]
670
+ },
671
+ {
672
+ "id": "wolf",
673
+ "start": 79.3,
674
+ "dur": 12.0,
675
+ "voice_start": 79.8,
676
+ "voice_dur": 10.584,
677
+ "lines": [
678
+ {
679
+ "who": "n",
680
+ "text": "又过了些日子,乌云压着山头。树丛里,一双绿莹莹的眼睛,慢慢靠近了羊群。狼,真的来了!",
681
+ "t0": 79.8,
682
+ "dur": 10.584,
683
+ "subs": [
684
+ {
685
+ "t0": 79.8,
686
+ "t1": 81.564,
687
+ "text": "又过了些日子",
688
+ "who": "n"
689
+ },
690
+ {
691
+ "t0": 81.564,
692
+ "t1": 83.328,
693
+ "text": "乌云压着山头",
694
+ "who": "n"
695
+ },
696
+ {
697
+ "t0": 83.328,
698
+ "t1": 84.21,
699
+ "text": "树丛里",
700
+ "who": "n"
701
+ },
702
+ {
703
+ "t0": 84.21,
704
+ "t1": 86.562,
705
+ "text": "一双绿莹莹的眼睛",
706
+ "who": "n"
707
+ },
708
+ {
709
+ "t0": 86.562,
710
+ "t1": 88.62,
711
+ "text": "慢慢靠近了羊群",
712
+ "who": "n"
713
+ },
714
+ {
715
+ "t0": 88.62,
716
+ "t1": 88.914,
717
+ "text": "狼",
718
+ "who": "n"
719
+ },
720
+ {
721
+ "t0": 88.914,
722
+ "t1": 90.384,
723
+ "text": "真的来了!",
724
+ "who": "n"
725
+ }
726
+ ]
727
+ }
728
+ ],
729
+ "subs": [
730
+ {
731
+ "t0": 79.8,
732
+ "t1": 81.564,
733
+ "text": "又过了些日子",
734
+ "who": "n"
735
+ },
736
+ {
737
+ "t0": 81.564,
738
+ "t1": 83.328,
739
+ "text": "乌云压着山头",
740
+ "who": "n"
741
+ },
742
+ {
743
+ "t0": 83.328,
744
+ "t1": 84.21,
745
+ "text": "树丛里",
746
+ "who": "n"
747
+ },
748
+ {
749
+ "t0": 84.21,
750
+ "t1": 86.562,
751
+ "text": "一双绿莹莹的眼睛",
752
+ "who": "n"
753
+ },
754
+ {
755
+ "t0": 86.562,
756
+ "t1": 88.62,
757
+ "text": "慢慢靠近了羊群",
758
+ "who": "n"
759
+ },
760
+ {
761
+ "t0": 88.62,
762
+ "t1": 88.914,
763
+ "text": "狼",
764
+ "who": "n"
765
+ },
766
+ {
767
+ "t0": 88.914,
768
+ "t1": 90.384,
769
+ "text": "真的来了!",
770
+ "who": "n"
771
+ }
772
+ ]
773
+ },
774
+ {
775
+ "id": "cry",
776
+ "start": 91.3,
777
+ "dur": 9.4,
778
+ "voice_start": 91.8,
779
+ "voice_dur": 7.988,
780
+ "lines": [
781
+ {
782
+ "who": "n",
783
+ "text": "放羊娃吓坏了,拼命地喊:",
784
+ "t0": 91.8,
785
+ "dur": 3.192,
786
+ "subs": [
787
+ {
788
+ "t0": 91.8,
789
+ "t1": 93.715,
790
+ "text": "放羊娃吓坏了",
791
+ "who": "n"
792
+ },
793
+ {
794
+ "t0": 93.715,
795
+ "t1": 94.992,
796
+ "text": "拼命地喊",
797
+ "who": "n"
798
+ }
799
+ ]
800
+ },
801
+ {
802
+ "who": "boy",
803
+ "text": "狼来了!狼真的来了!救命啊!",
804
+ "t0": 95.372,
805
+ "dur": 4.416,
806
+ "subs": [
807
+ {
808
+ "t0": 95.372,
809
+ "t1": 96.634,
810
+ "text": "狼来了!",
811
+ "who": "boy"
812
+ },
813
+ {
814
+ "t0": 96.634,
815
+ "t1": 98.526,
816
+ "text": "狼真的来了!",
817
+ "who": "boy"
818
+ },
819
+ {
820
+ "t0": 98.526,
821
+ "t1": 99.788,
822
+ "text": "救命啊!",
823
+ "who": "boy"
824
+ }
825
+ ]
826
+ }
827
+ ],
828
+ "subs": [
829
+ {
830
+ "t0": 91.8,
831
+ "t1": 93.715,
832
+ "text": "放羊娃吓坏了",
833
+ "who": "n"
834
+ },
835
+ {
836
+ "t0": 93.715,
837
+ "t1": 94.992,
838
+ "text": "拼命地喊",
839
+ "who": "n"
840
+ },
841
+ {
842
+ "t0": 95.372,
843
+ "t1": 96.634,
844
+ "text": "狼来了!",
845
+ "who": "boy"
846
+ },
847
+ {
848
+ "t0": 96.634,
849
+ "t1": 98.526,
850
+ "text": "狼真的来了!",
851
+ "who": "boy"
852
+ },
853
+ {
854
+ "t0": 98.526,
855
+ "t1": 99.788,
856
+ "text": "救命啊!",
857
+ "who": "boy"
858
+ }
859
+ ]
860
+ },
861
+ {
862
+ "id": "noone",
863
+ "start": 100.7,
864
+ "dur": 11.166666666666666,
865
+ "voice_start": 101.2,
866
+ "voice_dur": 9.76,
867
+ "lines": [
868
+ {
869
+ "who": "n",
870
+ "text": "山下的农民听见了,都以为他又在说谎。",
871
+ "t0": 101.2,
872
+ "dur": 4.296,
873
+ "subs": [
874
+ {
875
+ "t0": 101.2,
876
+ "t1": 103.348,
877
+ "text": "山下的农民听见了",
878
+ "who": "n"
879
+ },
880
+ {
881
+ "t0": 103.348,
882
+ "t1": 105.496,
883
+ "text": "都以为他又在说谎",
884
+ "who": "n"
885
+ }
886
+ ]
887
+ },
888
+ {
889
+ "who": "farmer",
890
+ "text": "哼,又来骗人。",
891
+ "t0": 105.876,
892
+ "dur": 2.664,
893
+ "subs": [
894
+ {
895
+ "t0": 105.876,
896
+ "t1": 106.409,
897
+ "text": "哼",
898
+ "who": "farmer"
899
+ },
900
+ {
901
+ "t0": 106.409,
902
+ "t1": 108.54,
903
+ "text": "又来骗人",
904
+ "who": "farmer"
905
+ }
906
+ ]
907
+ },
908
+ {
909
+ "who": "n",
910
+ "text": "谁也没有上山。",
911
+ "t0": 108.92,
912
+ "dur": 2.04,
913
+ "subs": [
914
+ {
915
+ "t0": 108.92,
916
+ "t1": 110.96,
917
+ "text": "谁也没有上山",
918
+ "who": "n"
919
+ }
920
+ ]
921
+ }
922
+ ],
923
+ "subs": [
924
+ {
925
+ "t0": 101.2,
926
+ "t1": 103.348,
927
+ "text": "山下的农民听见了",
928
+ "who": "n"
929
+ },
930
+ {
931
+ "t0": 103.348,
932
+ "t1": 105.496,
933
+ "text": "都以为他又在说谎",
934
+ "who": "n"
935
+ },
936
+ {
937
+ "t0": 105.876,
938
+ "t1": 106.409,
939
+ "text": "哼",
940
+ "who": "farmer"
941
+ },
942
+ {
943
+ "t0": 106.409,
944
+ "t1": 108.54,
945
+ "text": "又来骗人",
946
+ "who": "farmer"
947
+ },
948
+ {
949
+ "t0": 108.92,
950
+ "t1": 110.96,
951
+ "text": "谁也没有上山",
952
+ "who": "n"
953
+ }
954
+ ]
955
+ },
956
+ {
957
+ "id": "chaos",
958
+ "start": 111.8667,
959
+ "dur": 8.0,
960
+ "voice_start": 112.367,
961
+ "voice_dur": 5.976,
962
+ "lines": [
963
+ {
964
+ "who": "n",
965
+ "text": "狼冲进羊群,羊儿吓得四处乱跑,好几只羊被狼叼走了。",
966
+ "t0": 112.367,
967
+ "dur": 5.976,
968
+ "subs": [
969
+ {
970
+ "t0": 112.367,
971
+ "t1": 113.725,
972
+ "text": "狼冲进羊群",
973
+ "who": "n"
974
+ },
975
+ {
976
+ "t0": 113.725,
977
+ "t1": 115.898,
978
+ "text": "羊儿吓得四处乱跑",
979
+ "who": "n"
980
+ },
981
+ {
982
+ "t0": 115.898,
983
+ "t1": 118.343,
984
+ "text": "好几只羊被狼叼走了",
985
+ "who": "n"
986
+ }
987
+ ]
988
+ }
989
+ ],
990
+ "subs": [
991
+ {
992
+ "t0": 112.367,
993
+ "t1": 113.725,
994
+ "text": "狼冲进羊群",
995
+ "who": "n"
996
+ },
997
+ {
998
+ "t0": 113.725,
999
+ "t1": 115.898,
1000
+ "text": "羊儿吓得四处乱跑",
1001
+ "who": "n"
1002
+ },
1003
+ {
1004
+ "t0": 115.898,
1005
+ "t1": 118.343,
1006
+ "text": "好几只羊被狼叼走了",
1007
+ "who": "n"
1008
+ }
1009
+ ]
1010
+ },
1011
+ {
1012
+ "id": "regret",
1013
+ "start": 119.8667,
1014
+ "dur": 8.0,
1015
+ "voice_start": 120.367,
1016
+ "voice_dur": 5.328,
1017
+ "lines": [
1018
+ {
1019
+ "who": "n",
1020
+ "text": "太阳落山了,放羊娃抱着剩下的小羊,伤心地哭了。",
1021
+ "t0": 120.367,
1022
+ "dur": 5.328,
1023
+ "subs": [
1024
+ {
1025
+ "t0": 120.367,
1026
+ "t1": 121.699,
1027
+ "text": "太阳落山了",
1028
+ "who": "n"
1029
+ },
1030
+ {
1031
+ "t0": 121.699,
1032
+ "t1": 124.363,
1033
+ "text": "放羊娃抱着剩下的小羊",
1034
+ "who": "n"
1035
+ },
1036
+ {
1037
+ "t0": 124.363,
1038
+ "t1": 125.695,
1039
+ "text": "伤心地哭了",
1040
+ "who": "n"
1041
+ }
1042
+ ]
1043
+ }
1044
+ ],
1045
+ "subs": [
1046
+ {
1047
+ "t0": 120.367,
1048
+ "t1": 121.699,
1049
+ "text": "太阳落山了",
1050
+ "who": "n"
1051
+ },
1052
+ {
1053
+ "t0": 121.699,
1054
+ "t1": 124.363,
1055
+ "text": "放羊娃抱着剩下的小羊",
1056
+ "who": "n"
1057
+ },
1058
+ {
1059
+ "t0": 124.363,
1060
+ "t1": 125.695,
1061
+ "text": "伤心地哭了",
1062
+ "who": "n"
1063
+ }
1064
+ ]
1065
+ },
1066
+ {
1067
+ "id": "moral",
1068
+ "start": 127.8667,
1069
+ "dur": 10.0,
1070
+ "voice_start": 128.367,
1071
+ "voice_dur": 7.224,
1072
+ "lines": [
1073
+ {
1074
+ "who": "n",
1075
+ "text": "说谎的人,就算说了真话,也没有人再相信他。做一个诚实的孩子吧。",
1076
+ "t0": 128.367,
1077
+ "dur": 7.224,
1078
+ "subs": [
1079
+ {
1080
+ "t0": 128.367,
1081
+ "t1": 129.437,
1082
+ "text": "说谎的人",
1083
+ "who": "n"
1084
+ },
1085
+ {
1086
+ "t0": 129.437,
1087
+ "t1": 131.042,
1088
+ "text": "就算说了真话",
1089
+ "who": "n"
1090
+ },
1091
+ {
1092
+ "t0": 131.042,
1093
+ "t1": 133.183,
1094
+ "text": "也没有人再相信他",
1095
+ "who": "n"
1096
+ },
1097
+ {
1098
+ "t0": 133.183,
1099
+ "t1": 135.591,
1100
+ "text": "做一个诚实的孩子吧",
1101
+ "who": "n"
1102
+ }
1103
+ ]
1104
+ }
1105
+ ],
1106
+ "subs": [
1107
+ {
1108
+ "t0": 128.367,
1109
+ "t1": 129.437,
1110
+ "text": "说谎的人",
1111
+ "who": "n"
1112
+ },
1113
+ {
1114
+ "t0": 129.437,
1115
+ "t1": 131.042,
1116
+ "text": "就算说了真话",
1117
+ "who": "n"
1118
+ },
1119
+ {
1120
+ "t0": 131.042,
1121
+ "t1": 133.183,
1122
+ "text": "也没有人再相信他",
1123
+ "who": "n"
1124
+ },
1125
+ {
1126
+ "t0": 133.183,
1127
+ "t1": 135.591,
1128
+ "text": "做一个诚实的孩子吧",
1129
+ "who": "n"
1130
+ }
1131
+ ]
1132
+ }
1133
+ ]
1134
+ }
wolf-coming/video/wolf/voice.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0d161c8ce43a4b1d0cc2927f83ec8a118133e873443bc70dc060f425d584e483
3
+ size 1655162
wolf-coming/web/public/film/fonts/chars.txt CHANGED
@@ -1 +1 @@
1
- !#%+,-./0123456789:;?@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz·—‘’“”…、。《》「」【】・一三上下不与丑世丛东丝两个中为主丽举久么乎乐也乡书乱了事二于云五些交享亮亲人仅今介从他代以们仰任伙会传伴位低住体何作你例侧便俏保信倒候值停傍像儿充入全公六共关其具内再写冠冬冰冲决况冷冻准几出击函分则创到制前剩副力加动勾匀包化区半单印即压原又叉友发取变叠口古句另只叫可台右叶号叽各合同名后向吓吗吧呆告命和咔咦咬哀哈响哔哥哩哪唧啄喂善喊喙喜喳嘲嘴器噪嚓四回团围固国图圆圈在地场均坐块垂垛域堆塘填境增墨壁声壳处备复夏外多夜大天太失头夹奇奏她好妈始姐姑娘嫌子字存季孤学孩孵它安完官定实宣宫家宽寂寄密寒寸对导封小尖尚就尺尼尾层屋屏展属山岸峰工左差己已布希帘带帧幅幕干平并序应底度廓延建开式引张弦弯弹归当录形彩影往径很徒得循微心快念忽态怕性怪总息悄悬情惊愈意感慢成截所扇手才扑打扫扬把抖折报拉拍拖拢拨择持按挖振换掉排掠接控推描提插揭撇播擎支收改放政故效敢数整文斑断新旁无日时明春是昼显晓晕晚景晰暂暖暗曲更替最有朋望朝期木未本术朱机权村条来极构果枝枪染柔查柳标树校样核根格桃案梅梦梳检楷模横檐欠次欢欺款歌正此步段每比毛气水池治沼沿法波泥泪泽洁流测浏浓浮消淡深混清渐渡温渲游湖湿溅源溪滴漂漫潮灰点烟烧然照熬片版牙物状猎率环现理瓣生用田由电画畅界痕登白的皮皴益盖盛目直相省看真眼着睡瞬瞻短码砂砰破确碍示神离秋种秒积程空窗窝立竖章童竹笆笑笔笛符等筝签简篇篱簧粒粘系素紧红纤约级纳纷纸纹线组终经绒结绘给络绪续维缓编缘缩网置美群羽翅翻翼而肉背能脊脑脖脚脸腿膀臂自致色艺节芦花芽苇苹范茸草荷落蓝藏蛋蜓蜻蝴蝶融行衣补表被裁裂装西要见观规视览觉角解触计让记讲证试话语说请读课调负败质贴资走起路跳身躲轨转轮软轴轻载输辨边过近还这进远连迟述追送适选逐递通遮那部都配采里重野量金钟钻铃铺错键镜长门间阅阴阵阶阻附限除随难雁雪雾需霞露静面音页顶顿预领颈频颗题颜颤风飘飞马驳验高髻鱼鸟鸡鸣鸭鹅鹜麦黄黑齐️︐!()+,:;?|🐓👀🥚🦢
 
1
+ !#%+,-./0123456789:;?@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz·—‘’“”…、。《》「」【】・一三上下不与丑专世丛东丝两个中为主丽举久么义之乌乎乐也乡书乱了事二于云互五些交享亮亲人什仅今介从他代以们仰任伊伙会传伤伴伸位低住体何余作你使例供侧便促俏保信倒候值做停健傍像儿充先光入全公六共关其具内再写农冠冬冰冲决况冷冻准几出击函分列则创到制刻前剩剪副力加动勾匀包化区升半单印即却卷压原去又叉及友双发取变叠口古句另只叫可台右叶号叼叽吃各合同名后向吓吗否吧听吹吻呀呆呈告呼命和咔咦咬哀哈响哔哥哩哪哭哼唧啄啊啦喂善喊喘喙喜喳嗓嘲嘴器噪嚎嚓四回团园围固国图圆圈在地场址均坏坐块坡垂型垛域堆塘塞填境墙增墨壁声壳处备复夏夕外多夜大天太失头夹奇奏奔套她好如妈始姐姑姿娃娘嫌子字存季孤学孩孵它安完官定实宣宫家宽寂寄密寒寓寸对导封小尔尖尚就尺尼尾层屋屏展属山岸峰工左差己已巾布希帘带帧帽幅幕干平并序库应底度廓延建开式引张弦弧弯弹强归当录形彩影往径很徒得循微心忙快念忽态怕急性怪总息悄悔悠悬情惊惋惜愈意感慢憋懒成我或截所扁扇手才扑打扛扫扬把抖折报抱担拉拍拖拢拨择拱拼拿持挂按挖振换掉排掠探接控推描提插握揭摇撇播擎支收改放政故效救敢散数整文斑斜断新方旁无日时明易春是昼显晓晕晚景晰暂暖暗曲更替最有朋望朝期木未末本术朱机权村条来极构果枝枪柄染柔查柳标树校样核根格桃案梅梦梯梳检楷模横檐欠次欢欺款歌正此步段每比毛民气水求汗池沙没治沼沿法波泥泪泽洁洋活流测浏浓浮消润淡深混清渐渡温渲游湖湿溅源溪滑滴漂漫潮灯灰点烟烧然照熬爱片版牙物状独狼猎率玩环现珠理瓣生用田由电画畅界略疏痕登白的皮皴益盖盛目直相省眉看真眼着睛睡瞬瞻短石码砂砰破确碍碎示神离秋种秒秧积移稀程稍空窗窝立竖章童竹笆笑笔笛符第等筐筝签简算篇篱簧类粒粗粘系素索紧红纤约级纳纷纸纹线组细终经绑绒结绘给络绪续维绿缓编缘缩网置署羊美群羽翅翻翼而耳聊肉肢肤背胸能脊脑脖脚脸腮腰腿膀膝臂自致色艺节芦花芽苇苗苹范茅茸草荷莹落蓝藏蛋蜓蜻蝴蝶融行衣补表袖被裁裂装裤襟西要见观规视览觉角解触言警计认让记讲设证词试诚��语说请读课谁调谎谷贝负败质贴资走赶起越跑路跳身躯躲躺轨转轮软轴轻载输辨辫边迁过近还这进远连迟述追送适逃选逐递逗通逼遍道遮那部都配采里重野量金钟钻铃铺链锄错键镜长门闪间阅阳阴阵阶阻附限除随难雁雪雷雾需霞露静靠面音页顶顿预领颈频颗题颜额颤风飘飞马驳验骗骨高髻鱼鸟鸡鸣鸦鸭鹅鹜麦黄黑默鼻齐️︐!()+,:;?|~🐑🐓🐺👀📢😂🥚🦢
wolf-coming/web/public/film/fonts/mashanzheng.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:e009f88eccbb75b93ece3214992da9dbab3c96e3e003d1078a88d753a001b630
3
- size 343228
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fe7ce130e02290a7dff93743e8a7492fb7c41bdbe9d6b338945144e018735476
3
+ size 420940
wolf-coming/web/public/film/fonts/wenkai.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:c673c2d90b5d3e48ff76694316aec488e221d610b6269440d795d093207566fe
3
- size 200844
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:14374c15ca95593a0fc159be8f69f4435a3e7c4206b1817fd7bfba9495222f02
3
+ size 240976
wolf-coming/web/public/wolf/voice.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0d161c8ce43a4b1d0cc2927f83ec8a118133e873443bc70dc060f425d584e483
3
+ size 1655162
wolf-coming/web/src/App.tsx CHANGED
@@ -5,7 +5,12 @@ import Covers from '@/components/Covers'
5
  import PublishCopy from '@/components/PublishCopy'
6
  import LivePhotos from '@/components/LivePhotos'
7
  import Making from '@/components/Making'
8
- import { TOTAL } from '@/film/film'
 
 
 
 
 
9
 
10
  const isPhone = () => typeof window !== 'undefined' && window.innerWidth < 768 && window.innerHeight > window.innerWidth
11
 
@@ -29,8 +34,13 @@ export default function App() {
29
  <div className="paper-bg min-h-screen text-[#2a2420]">
30
  <header className="sticky top-0 z-20 border-b border-[#d9cdb2] bg-[#f3ecdc]/90 backdrop-blur">
31
  <div className="mx-auto flex max-w-6xl items-center gap-3 px-4 py-2">
32
- <span className="flex h-9 w-9 items-center justify-center rounded-sm bg-[#b8322a] font-brush text-lg leading-none text-[#f6efdf]">鸭</span>
33
- <span className="font-brush text-2xl">丑小鸭</span>
 
 
 
 
 
34
  <nav className="ml-auto flex gap-1 overflow-x-auto text-sm">
35
  {nav.map(([id, n]) => <a key={id} href={`#${id}`} className="shrink-0 rounded px-2 py-1 text-[#4a3f30] hover:bg-[#e8dec6]">{n}</a>)}
36
  </nav>
@@ -40,14 +50,12 @@ export default function App() {
40
  <main className="mx-auto flex max-w-6xl flex-col gap-14 px-4 pb-24 pt-8">
41
  <div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
42
  <div>
43
- <h1 className="font-brush text-5xl leading-tight md:text-6xl">丑小鸭<span className="ml-3 align-middle text-2xl text-[#8a7a5a] md:text-3xl">变成白天鹅</span></h1>
44
- <p className="mt-2 max-w-2xl leading-relaxed text-[#4a3f30]">
45
- 安徒生童话 · 水墨手绘儿童动画。宣纸、毛笔、淡彩、朱红印章,像小学语文课本里的插图那样,一笔一画在你的浏览器里实时画出来;旁白、原创国风配乐与音效同步播放。
46
- </p>
47
  </div>
48
  <div className="flex gap-4 text-sm text-[#6b5d48]">
49
  <div><div className="font-brush text-2xl text-[#2a2420]">{Math.floor(TOTAL / 60)}′{Math.round(TOTAL % 60)}″</div>片长</div>
50
- <div><div className="font-brush text-2xl text-[#2a2420]">14</div>场景</div>
51
  <div><div className="font-brush text-2xl text-[#2a2420]">2</div>画幅</div>
52
  </div>
53
  </div>
@@ -60,7 +68,7 @@ export default function App() {
60
  <ExportPanel key={orientation} defaultAspect={orientation === 'portrait' ? '9:16' : '16:9'} />
61
  </Section>
62
 
63
- <Section id="covers" no="三" title="封面" sub="4:3 · 3:4 · 16:9 各 4 款,可多选打包">
64
  <Covers />
65
  </Section>
66
 
@@ -68,7 +76,7 @@ export default function App() {
68
  <PublishCopy />
69
  </Section>
70
 
71
- <Section id="live" no="五" title="实况图" sub="6 组 JPG + MOV · 小红书 / 抖音图文">
72
  <LivePhotos />
73
  </Section>
74
 
@@ -77,7 +85,7 @@ export default function App() {
77
  </Section>
78
  </main>
79
  <footer className="border-t border-[#d9cdb2] py-6 text-center text-xs text-[#8a7a5a]">
80
- 故事改编自安徒生《丑小鸭》(1843,公有领域)· 画面、配乐、音效均由代码原创生成 · 字体:霞鹜文楷、马善政毛笔楷书(SIL OFL)
81
  </footer>
82
  </div>
83
  )
 
5
  import PublishCopy from '@/components/PublishCopy'
6
  import LivePhotos from '@/components/LivePhotos'
7
  import Making from '@/components/Making'
8
+ import { TOTAL, FILM, setFilm } from '@/film/film'
9
+ import { FILMS, pickFilm } from '@/films'
10
+
11
+ const PICK = pickFilm()
12
+ setFilm(PICK.film)
13
+ if (typeof window !== 'undefined' && !PICK.locked) window.addEventListener('hashchange', () => location.reload())
14
 
15
  const isPhone = () => typeof window !== 'undefined' && window.innerWidth < 768 && window.innerHeight > window.innerWidth
16
 
 
34
  <div className="paper-bg min-h-screen text-[#2a2420]">
35
  <header className="sticky top-0 z-20 border-b border-[#d9cdb2] bg-[#f3ecdc]/90 backdrop-blur">
36
  <div className="mx-auto flex max-w-6xl items-center gap-3 px-4 py-2">
37
+ <span className="flex h-9 w-9 items-center justify-center rounded-sm bg-[#b8322a] font-brush text-lg leading-none text-[#f6efdf]">{[...FILM.title].pop()}</span>
38
+ <span className="font-brush text-2xl">{FILM.title}</span>
39
+ <span className="hidden items-center gap-1 sm:flex">
40
+ {FILMS.filter((f) => f.id !== FILM.id).map((f) => (
41
+ <a key={f.id} href={PICK.locked ? `../${f.slug}/` : `#${f.id}`} className="rounded border border-[#d0c3a5] px-2 py-0.5 text-xs text-[#6b5d48] hover:border-[#8a7a5a]">系列 · 看《{f.title}》</a>
42
+ ))}
43
+ </span>
44
  <nav className="ml-auto flex gap-1 overflow-x-auto text-sm">
45
  {nav.map(([id, n]) => <a key={id} href={`#${id}`} className="shrink-0 rounded px-2 py-1 text-[#4a3f30] hover:bg-[#e8dec6]">{n}</a>)}
46
  </nav>
 
50
  <main className="mx-auto flex max-w-6xl flex-col gap-14 px-4 pb-24 pt-8">
51
  <div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
52
  <div>
53
+ <h1 className="font-brush text-5xl leading-tight md:text-6xl">{FILM.title}<span className="ml-3 align-middle text-2xl text-[#8a7a5a] md:text-3xl">{FILM.tagline}</span></h1>
54
+ <p className="mt-2 max-w-2xl leading-relaxed text-[#4a3f30]">{FILM.intro}</p>
 
 
55
  </div>
56
  <div className="flex gap-4 text-sm text-[#6b5d48]">
57
  <div><div className="font-brush text-2xl text-[#2a2420]">{Math.floor(TOTAL / 60)}′{Math.round(TOTAL % 60)}″</div>片长</div>
58
+ <div><div className="font-brush text-2xl text-[#2a2420]">{FILM.timeline.scenes.length}</div>场景</div>
59
  <div><div className="font-brush text-2xl text-[#2a2420]">2</div>画幅</div>
60
  </div>
61
  </div>
 
68
  <ExportPanel key={orientation} defaultAspect={orientation === 'portrait' ? '9:16' : '16:9'} />
69
  </Section>
70
 
71
+ <Section id="covers" no="三" title="封面" sub={`4:3 · 3:4 · 16:9 各 ${FILM.covers.length} 款,可多选打包`}>
72
  <Covers />
73
  </Section>
74
 
 
76
  <PublishCopy />
77
  </Section>
78
 
79
+ <Section id="live" no="五" title="实况图" sub={`${FILM.live.length} 组 JPG + MOV · 小红书 / 抖音图文`}>
80
  <LivePhotos />
81
  </Section>
82
 
 
85
  </Section>
86
  </main>
87
  <footer className="border-t border-[#d9cdb2] py-6 text-center text-xs text-[#8a7a5a]">
88
+ {FILM.credit} · 画面、配乐、音效均由代码原创生成 · 字体:霞鹜文楷、马善政毛笔楷书(SIL OFL)
89
  </footer>
90
  </div>
91
  )
wolf-coming/web/src/components/Covers.tsx CHANGED
@@ -1,12 +1,14 @@
1
  import { useEffect, useState } from 'react'
2
  import { Check, Download, Loader2, PackageOpen } from 'lucide-react'
3
  import { zipSync } from 'fflate'
4
- import { COVER_DESIGNS, COVER_RATIOS, drawCover } from '@/film/overlays'
 
 
5
  import { loadFonts } from '@/film/core'
6
 
7
  const BASE = import.meta.env.BASE_URL
8
  type Ratio = (typeof COVER_RATIOS)[number]
9
- type Design = (typeof COVER_DESIGNS)[number]
10
  type Item = { key: string; ratio: string; design: string; name: string; url: string; w: number; h: number }
11
  const THUMB = 420 // 预览图长边;下载时才按原尺寸重新绘制(省内存,手机也流畅)
12
 
@@ -20,8 +22,8 @@ async function renderCover(ratio: Ratio, d: Design, scale = 1): Promise<Blob> {
20
  c.width = c.height = 1
21
  return blob
22
  }
23
- const fullName = (r: Ratio, d: Design) => `丑小鸭封面_${r.id.replace(':', 'x')}_${d.name}_${r.w}x${r.h}.jpg`
24
- const find = (key: string) => { const [r, d] = key.split('|'); return { r: COVER_RATIOS.find((x) => x.id === r)!, d: COVER_DESIGNS.find((x) => x.id === d)! } }
25
 
26
  export default function Covers() {
27
  const [items, setItems] = useState<Item[]>([])
@@ -33,7 +35,7 @@ export default function Covers() {
33
  let dead = false
34
  ;(async () => {
35
  await loadFonts(BASE)
36
- for (const r of COVER_RATIOS) for (const d of COVER_DESIGNS) {
37
  if (dead) return
38
  await new Promise((res) => setTimeout(res, 30)) // 让出主线程
39
  const blob = await renderCover(r, d, THUMB / Math.max(r.w, r.h))
@@ -44,7 +46,7 @@ export default function Covers() {
44
  return () => { dead = true }
45
  }, [])
46
 
47
- const total = COVER_RATIOS.length * COVER_DESIGNS.length
48
  const shown = items.filter((i) => tab === '全部' || i.ratio === tab)
49
  const toggle = (k: string) => setSel((s) => { const n = new Set(s); if (n.has(k)) n.delete(k); else n.add(k); return n })
50
  const [busyKey, setBusyKey] = useState<string | null>(null)
@@ -55,7 +57,7 @@ export default function Covers() {
55
  const files: Record<string, Uint8Array> = {}
56
  for (const it of items.filter((i) => sel.has(i.key))) { const { r, d } = find(it.key); files[it.name] = new Uint8Array(await (await renderCover(r, d)).arrayBuffer()); await new Promise((res) => setTimeout(res, 10)) }
57
  const out = zipSync(files, { level: 0 })
58
- save(new Blob([out as Uint8Array<ArrayBuffer>], { type: 'application/zip' }), `丑小鸭封面_${sel.size}张.zip`)
59
  setZipping(false)
60
  }
61
 
@@ -84,7 +86,7 @@ export default function Covers() {
84
  </button>
85
  <span className={`absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded border-2 ${on ? 'border-[#b8322a] bg-[#b8322a] text-white' : 'border-white/90 bg-black/20'}`}>{on && <Check className="h-4 w-4" />}</span>
86
  <div className="flex items-center justify-between px-2 py-1.5 text-xs text-[#4a3f30]">
87
- <span><b className="mr-1">{it.ratio}</b>{COVER_DESIGNS.find((d) => d.id === it.design)?.name} · {it.w}×{it.h}</span>
88
  <button onClick={() => dl(it)} className="rounded p-1 hover:bg-[#ece2cc]" aria-label="下载原图" title="下载原尺寸 JPG">{busyKey === it.key ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}</button>
89
  </div>
90
  </div>
 
1
  import { useEffect, useState } from 'react'
2
  import { Check, Download, Loader2, PackageOpen } from 'lucide-react'
3
  import { zipSync } from 'fflate'
4
+ import { COVER_RATIOS, drawCover } from '@/film/overlays'
5
+ import { FILM } from '@/film/film'
6
+ import type { CoverDesign } from '@/film/types'
7
  import { loadFonts } from '@/film/core'
8
 
9
  const BASE = import.meta.env.BASE_URL
10
  type Ratio = (typeof COVER_RATIOS)[number]
11
+ type Design = CoverDesign
12
  type Item = { key: string; ratio: string; design: string; name: string; url: string; w: number; h: number }
13
  const THUMB = 420 // 预览图长边;下载时才按原尺寸重新绘制(省内存,手机也流畅)
14
 
 
22
  c.width = c.height = 1
23
  return blob
24
  }
25
+ const fullName = (r: Ratio, d: Design) => `${FILM.title}封面_${r.id.replace(':', 'x')}_${d.name}_${r.w}x${r.h}.jpg`
26
+ const find = (key: string) => { const [r, d] = key.split('|'); return { r: COVER_RATIOS.find((x) => x.id === r)!, d: FILM.covers.find((x) => x.id === d)! } }
27
 
28
  export default function Covers() {
29
  const [items, setItems] = useState<Item[]>([])
 
35
  let dead = false
36
  ;(async () => {
37
  await loadFonts(BASE)
38
+ for (const r of COVER_RATIOS) for (const d of FILM.covers) {
39
  if (dead) return
40
  await new Promise((res) => setTimeout(res, 30)) // 让出主线程
41
  const blob = await renderCover(r, d, THUMB / Math.max(r.w, r.h))
 
46
  return () => { dead = true }
47
  }, [])
48
 
49
+ const total = COVER_RATIOS.length * FILM.covers.length
50
  const shown = items.filter((i) => tab === '全部' || i.ratio === tab)
51
  const toggle = (k: string) => setSel((s) => { const n = new Set(s); if (n.has(k)) n.delete(k); else n.add(k); return n })
52
  const [busyKey, setBusyKey] = useState<string | null>(null)
 
57
  const files: Record<string, Uint8Array> = {}
58
  for (const it of items.filter((i) => sel.has(i.key))) { const { r, d } = find(it.key); files[it.name] = new Uint8Array(await (await renderCover(r, d)).arrayBuffer()); await new Promise((res) => setTimeout(res, 10)) }
59
  const out = zipSync(files, { level: 0 })
60
+ save(new Blob([out as Uint8Array<ArrayBuffer>], { type: 'application/zip' }), `${FILM.title}封面_${sel.size}张.zip`)
61
  setZipping(false)
62
  }
63
 
 
86
  </button>
87
  <span className={`absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded border-2 ${on ? 'border-[#b8322a] bg-[#b8322a] text-white' : 'border-white/90 bg-black/20'}`}>{on && <Check className="h-4 w-4" />}</span>
88
  <div className="flex items-center justify-between px-2 py-1.5 text-xs text-[#4a3f30]">
89
+ <span><b className="mr-1">{it.ratio}</b>{FILM.covers.find((d) => d.id === it.design)?.name} · {it.w}×{it.h}</span>
90
  <button onClick={() => dl(it)} className="rounded p-1 hover:bg-[#ece2cc]" aria-label="下载原图" title="下载原尺寸 JPG">{busyKey === it.key ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}</button>
91
  </div>
92
  </div>
wolf-coming/web/src/components/ExportPanel.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import { useRef, useState } from 'react'
2
  import { Download, Film, Loader2, X, Smartphone, Monitor } from 'lucide-react'
3
  import { Output, Mp4OutputFormat, BufferTarget, CanvasSource, AudioBufferSource, QUALITY_HIGH, getFirstEncodableVideoCodec, getFirstEncodableAudioCodec } from 'mediabunny'
4
- import { renderFrame, TOTAL } from '@/film/film'
5
  import { buildSoundtrack } from '@/film/audio'
6
  import { loadFonts } from '@/film/core'
7
 
@@ -30,7 +30,7 @@ export default function ExportPanel({ defaultAspect }: { defaultAspect: Aspect }
30
  try {
31
  setMsg('准备字体与音轨…')
32
  await loadFonts(BASE)
33
- const full = await buildSoundtrack(`${BASE}film/voice.mp3`)
34
  // ?exportSeconds=N 仅用于自动化测试(只导出前 N 秒)
35
  const lim = Number(new URLSearchParams(location.search).get('exportSeconds')) || TOTAL
36
  const N = Math.round(Math.min(TOTAL, lim) * fps)
@@ -68,7 +68,7 @@ export default function ExportPanel({ defaultAspect }: { defaultAspect: Aspect }
68
  await output.finalize()
69
  const buf = (output.target as BufferTarget).buffer!
70
  const blob = new Blob([buf], { type: 'video/mp4' })
71
- const name = `丑小鸭_${aspect === '16:9' ? '横屏' : '竖屏'}_${W}x${H}_${fps}fps.mp4`
72
  setFile({ url: URL.createObjectURL(blob), name, size: blob.size })
73
  setProg(1)
74
  setMsg(`完成:${vcodec.toUpperCase()} 视频${acodec ? ` + ${acodec.toUpperCase()} 音频` : '(此浏览器不支持音频编码,导出为无声视频)'}`)
 
1
  import { useRef, useState } from 'react'
2
  import { Download, Film, Loader2, X, Smartphone, Monitor } from 'lucide-react'
3
  import { Output, Mp4OutputFormat, BufferTarget, CanvasSource, AudioBufferSource, QUALITY_HIGH, getFirstEncodableVideoCodec, getFirstEncodableAudioCodec } from 'mediabunny'
4
+ import { renderFrame, TOTAL, FILM } from '@/film/film'
5
  import { buildSoundtrack } from '@/film/audio'
6
  import { loadFonts } from '@/film/core'
7
 
 
30
  try {
31
  setMsg('准备字体与音轨…')
32
  await loadFonts(BASE)
33
+ const full = await buildSoundtrack(`${BASE}${FILM.assets}voice.mp3`)
34
  // ?exportSeconds=N 仅用于自动化测试(只导出前 N 秒)
35
  const lim = Number(new URLSearchParams(location.search).get('exportSeconds')) || TOTAL
36
  const N = Math.round(Math.min(TOTAL, lim) * fps)
 
68
  await output.finalize()
69
  const buf = (output.target as BufferTarget).buffer!
70
  const blob = new Blob([buf], { type: 'video/mp4' })
71
+ const name = `${FILM.title}_${aspect === '16:9' ? '横屏' : '竖屏'}_${W}x${H}_${fps}fps.mp4`
72
  setFile({ url: URL.createObjectURL(blob), name, size: blob.size })
73
  setProg(1)
74
  setMsg(`完成:${vcodec.toUpperCase()} 视频${acodec ? ` + ${acodec.toUpperCase()} 音频` : '(此浏览器不支持音频编码,导出为无声视频)'}`)
wolf-coming/web/src/components/LivePhotos.tsx CHANGED
@@ -1,9 +1,9 @@
1
  import { Download, PackageOpen, Apple } from 'lucide-react'
2
- import { LIVE } from '@/film/overlays'
3
  import { PhotoCopy } from './PublishCopy'
4
 
5
  const BASE = import.meta.env.BASE_URL
6
- const L = (f: string) => `${BASE}film/live/${f}`
7
 
8
  export default function LivePhotos() {
9
  return (
@@ -11,13 +11,13 @@ export default function LivePhotos() {
11
  <div className="flex flex-wrap items-center gap-3 rounded-md border border-[#d9cdb2] bg-[#fbf7ee] p-4 text-sm text-[#3a3128]">
12
  <Apple className="h-5 w-5 shrink-0" />
13
  <div className="min-w-[240px] flex-1 leading-relaxed">
14
- 6 组苹果实况照片(每组 <b>JPG + MOV</b>,3:4 · 1080×1440 · 3 秒 · 30fps),按 livecanvas 流程写入 Live Photo 配对信息(共享 content identifier + still-image-time),另附 .pvt 资源包。
15
  在 Mac 上解压后,把同名的 JPG 和 MOV 一起拖进「照片」即合成为实况图,再同步到手机发小红书 / 抖音图文。
16
  </div>
17
- <a href={L('livephotos.zip')} download="丑小鸭_水墨实况图_6组.zip" className="inline-flex items-center gap-2 rounded bg-[#b8322a] px-4 py-2 text-[#f6efdf] shadow hover:bg-[#a02a23]"><PackageOpen className="h-4 w-4" />下载实况图压缩包</a>
18
  </div>
19
  <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
20
- {LIVE.map((l) => (
21
  <figure key={l.id} className="overflow-hidden rounded-md border border-[#d9cdb2] bg-[#fbf7ee]">
22
  <video src={L(`${l.id}.mp4`)} poster={L(`${l.id}.jpg`)} autoPlay muted loop playsInline className="block aspect-[3/4] w-full object-cover" />
23
  <figcaption className="flex items-center justify-between gap-1 px-2 py-1.5 text-xs text-[#4a3f30]">
 
1
  import { Download, PackageOpen, Apple } from 'lucide-react'
2
+ import { FILM } from '@/film/film'
3
  import { PhotoCopy } from './PublishCopy'
4
 
5
  const BASE = import.meta.env.BASE_URL
6
+ const L = (f: string) => `${BASE}${FILM.assets}live/${f}`
7
 
8
  export default function LivePhotos() {
9
  return (
 
11
  <div className="flex flex-wrap items-center gap-3 rounded-md border border-[#d9cdb2] bg-[#fbf7ee] p-4 text-sm text-[#3a3128]">
12
  <Apple className="h-5 w-5 shrink-0" />
13
  <div className="min-w-[240px] flex-1 leading-relaxed">
14
+ {FILM.live.length} 组苹果实况照片(每组 <b>JPG + MOV</b>,3:4 · 1080×1440 · 3 秒 · 30fps),按 livecanvas 流程写入 Live Photo 配对信息(共享 content identifier + still-image-time),另附 .pvt 资源包。
15
  在 Mac 上解压后,把同名的 JPG 和 MOV 一起拖进「照片」即合成为实况图,再同步到手机发小红书 / 抖音图文。
16
  </div>
17
+ <a href={L('livephotos.zip')} download={`${FILM.title}_水墨实况图_${FILM.live.length}组.zip`} className="inline-flex items-center gap-2 rounded bg-[#b8322a] px-4 py-2 text-[#f6efdf] shadow hover:bg-[#a02a23]"><PackageOpen className="h-4 w-4" />下载实况图压缩包</a>
18
  </div>
19
  <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
20
+ {FILM.live.map((l) => (
21
  <figure key={l.id} className="overflow-hidden rounded-md border border-[#d9cdb2] bg-[#fbf7ee]">
22
  <video src={L(`${l.id}.mp4`)} poster={L(`${l.id}.jpg`)} autoPlay muted loop playsInline className="block aspect-[3/4] w-full object-cover" />
23
  <figcaption className="flex items-center justify-between gap-1 px-2 py-1.5 text-xs text-[#4a3f30]">
wolf-coming/web/src/components/Making.tsx CHANGED
@@ -1,22 +1,16 @@
1
- import { TL, chapters } from '@/film/film'
2
- import qa from '@/film/qa.json'
3
 
4
  const BASE = import.meta.env.BASE_URL
5
- type QA = { status: string; generated?: string; video?: string; meta?: { w: number; h: number; fps: number; dur: number }; results: { level: string; check: string; msg: string }[]; verdict?: string }
6
- const Q = qa as QA
7
  const fmt = (t: number) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}`
8
  const col: Record<string, string> = { PASS: 'bg-[#e3ecd9] text-[#2f5d3a]', WARN: 'bg-[#f6e7c4] text-[#8a5a10]', FAIL: 'bg-[#f5d9d5] text-[#b8322a]', INFO: 'bg-[#e6e0d2] text-[#5a4f3c]' }
9
 
10
  export default function Making() {
11
  const chs = chapters()
 
12
  return (
13
  <div className="flex flex-col gap-6">
14
  <div className="grid gap-4 md:grid-cols-3">
15
- {[
16
- ['画面', '全部由代码逐帧绘制:程序生成宣纸纹理,变宽毛笔笔触带起笔、收笔和飞白,淡彩晕染有水彩积边,再盖朱砂印章。同一只「鸟」可以从小黄鸭一路长成天鹅。每帧只由时间决定,可逐帧复现。'],
17
- ['声音', '中文神经网络语音旁白;原创五声音阶配乐(古筝拨弦、竹笛、铺底、铃),随四季换情绪;破壳、鸭叫、枪声、冰裂、振翅、拍手等音效都是程序合成。人声出现时配乐自动压低,整体响度 −14 LUFS。'],
18
- ['流程', '按 vibe-video 流程:分镜 storyboard → 配音生成时间轴 → 接触表 / 静帧检查 → 确定性自检 → 全片渲染 → 混音 → vibe qa 质检 → 封面与发布包;实况图按 livecanvas 流程配对封装。'],
19
- ].map(([h, t]) => (
20
  <div key={h} className="rounded-md border border-[#d9cdb2] bg-[#fbf7ee] p-4">
21
  <div className="mb-1 font-brush text-xl">{h}</div>
22
  <p className="text-sm leading-relaxed text-[#4a3f30]">{t}</p>
@@ -63,8 +57,8 @@ export default function Making() {
63
  )}
64
  {Q.status !== 'pending' && (
65
  <div className="grid gap-3 md:grid-cols-2">
66
- <figure><img src={`${BASE}film/qa/sheet_landscape.jpg`} alt="横屏接触表" className="w-full rounded border border-[#d9cdb2]" /><figcaption className="mt-1 text-xs text-[#8a7a5a]">横屏接触表(每 2 秒一格)</figcaption></figure>
67
- <figure><img src={`${BASE}film/qa/sheet_portrait.jpg`} alt="竖屏接触表" className="w-full rounded border border-[#d9cdb2]" /><figcaption className="mt-1 text-xs text-[#8a7a5a]">竖屏接触表</figcaption></figure>
68
  </div>
69
  )}
70
  </div>
 
1
+ import { TL, chapters, FILM } from '@/film/film'
 
2
 
3
  const BASE = import.meta.env.BASE_URL
 
 
4
  const fmt = (t: number) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}`
5
  const col: Record<string, string> = { PASS: 'bg-[#e3ecd9] text-[#2f5d3a]', WARN: 'bg-[#f6e7c4] text-[#8a5a10]', FAIL: 'bg-[#f5d9d5] text-[#b8322a]', INFO: 'bg-[#e6e0d2] text-[#5a4f3c]' }
6
 
7
  export default function Making() {
8
  const chs = chapters()
9
+ const Q = FILM.qa
10
  return (
11
  <div className="flex flex-col gap-6">
12
  <div className="grid gap-4 md:grid-cols-3">
13
+ {FILM.making.map(([h, t]) => (
 
 
 
 
14
  <div key={h} className="rounded-md border border-[#d9cdb2] bg-[#fbf7ee] p-4">
15
  <div className="mb-1 font-brush text-xl">{h}</div>
16
  <p className="text-sm leading-relaxed text-[#4a3f30]">{t}</p>
 
57
  )}
58
  {Q.status !== 'pending' && (
59
  <div className="grid gap-3 md:grid-cols-2">
60
+ <figure><img src={`${BASE}${FILM.assets}qa/sheet_landscape.jpg`} alt="横屏接触表" className="w-full rounded border border-[#d9cdb2]" /><figcaption className="mt-1 text-xs text-[#8a7a5a]">横屏接触表(每 2 秒一格)</figcaption></figure>
61
+ <figure><img src={`${BASE}${FILM.assets}qa/sheet_portrait.jpg`} alt="竖屏接触表" className="w-full rounded border border-[#d9cdb2]" /><figcaption className="mt-1 text-xs text-[#8a7a5a]">竖屏接触表</figcaption></figure>
62
  </div>
63
  )}
64
  </div>
wolf-coming/web/src/components/Player.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
  import { Play, Pause, Maximize2, Subtitles, Smartphone, Monitor, RotateCcw, Volume2, VolumeX } from 'lucide-react'
3
- import { renderFrame, TOTAL, chapters } from '@/film/film'
4
  import { buildSoundtrack } from '@/film/audio'
5
  import { loadFonts } from '@/film/core'
6
 
@@ -31,7 +31,7 @@ export default function Player({ orientation, onOrientation }: { orientation: Or
31
 
32
  useEffect(() => {
33
  loadFonts(BASE).then(() => setFontsOk(true))
34
- buildSoundtrack(`${BASE}film/voice.mp3`, setStage).then((b) => { buf.current = b; setReady(true) }).catch((e) => setStage('音轨合成失败:' + (e?.message ?? e)))
35
  }, [])
36
 
37
  const now = useCallback(() => (playingRef.current && ac.current ? ac.current.currentTime - startAt.current : offset.current), [])
 
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
  import { Play, Pause, Maximize2, Subtitles, Smartphone, Monitor, RotateCcw, Volume2, VolumeX } from 'lucide-react'
3
+ import { renderFrame, TOTAL, chapters, FILM } from '@/film/film'
4
  import { buildSoundtrack } from '@/film/audio'
5
  import { loadFonts } from '@/film/core'
6
 
 
31
 
32
  useEffect(() => {
33
  loadFonts(BASE).then(() => setFontsOk(true))
34
+ buildSoundtrack(`${BASE}${FILM.assets}voice.mp3`, setStage).then((b) => { buf.current = b; setReady(true) }).catch((e) => setStage('音轨合成失败:' + (e?.message ?? e)))
35
  }, [])
36
 
37
  const now = useCallback(() => (playingRef.current && ac.current ? ac.current.currentTime - startAt.current : offset.current), [])
wolf-coming/web/src/components/PublishCopy.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useState } from 'react'
2
  import { Copy, Check } from 'lucide-react'
3
- import { PLATFORMS, PHOTO_POSTS, len } from '@/content'
 
4
 
5
  export function useCopy() {
6
  const [done, setDone] = useState<string | null>(null)
@@ -73,10 +74,10 @@ export function PostCard({ p, c, pre }: { p: Post; c: ReturnType<typeof useCopy>
73
 
74
  export default function PublishCopy() {
75
  const c = useCopy()
76
- return <div className="grid gap-4 md:grid-cols-2">{PLATFORMS.map((p) => <PostCard key={p.id} p={p} c={c} pre="v-" />)}</div>
77
  }
78
 
79
  export function PhotoCopy() {
80
  const c = useCopy()
81
- return <div className="grid gap-4 md:grid-cols-2">{PHOTO_POSTS.map((p) => <PostCard key={p.id} p={{ ...p, color: p.id === 'xhs' ? '#ff2442' : '#111111' }} c={c} pre="p-" />)}</div>
82
  }
 
1
  import { useState } from 'react'
2
  import { Copy, Check } from 'lucide-react'
3
+ import { FILM } from '@/film/film'
4
+ const len = (s: string) => [...s].length
5
 
6
  export function useCopy() {
7
  const [done, setDone] = useState<string | null>(null)
 
74
 
75
  export default function PublishCopy() {
76
  const c = useCopy()
77
+ return <div className="grid gap-4 md:grid-cols-2">{FILM.platforms.map((p) => <PostCard key={p.id} p={p} c={c} pre="v-" />)}</div>
78
  }
79
 
80
  export function PhotoCopy() {
81
  const c = useCopy()
82
+ return <div className="grid gap-4 md:grid-cols-2">{FILM.photoPosts.map((p) => <PostCard key={p.id} p={{ ...p, color: p.id === 'xhs' ? '#ff2442' : '#111111' }} c={c} pre="p-" />)}</div>
83
  }
wolf-coming/web/src/film/audio.ts CHANGED
@@ -1,24 +1,40 @@
1
  // 声音:原创五声音阶配乐(古筝拨弦 + 竹笛 + 铺底 + 铃)+ 程序音效 + 旁白,离线混成一条立体声。
2
  // 完全确定:同一输入 → 同一音轨。网页播放、MP4 导出、质检用的是同一条音轨。
3
- import { TL, TOTAL } from './film'
4
  import { rng } from './core'
5
- import { CUE } from './cues'
6
 
7
- const SR = 44100
8
  type AC = OfflineAudioContext
9
- const midi = (m: number) => 440 * Math.pow(2, (m - 69) / 12)
10
  // 五声音阶(G 宫 / E 羽 共用)
11
- const N: Record<string, number> = { E2: 40, G2: 43, A2: 45, B2: 47, D3: 50, E3: 52, G3: 55, A3: 57, B3: 59, D4: 62, E4: 64, G4: 67, A4: 69, B4: 71, D5: 74, E5: 76, G5: 79, A5: 81, B5: 83, D6: 86, E6: 88 }
12
- const sceneStart = (id: string) => TL.scenes.find((s) => s.id === id)?.start ?? 0
13
- const sceneDur = (id: string) => TL.scenes.find((s) => s.id === id)?.dur ?? 0
14
 
15
- class Mixer {
16
  ctx: AC
17
  music: GainNode; sfx: GainNode; voice: GainNode; rev: ConvolverNode; revIn: GainNode; master: GainNode
18
  noise: AudioBuffer
19
  ksCache = new Map<number, AudioBuffer>()
20
  r = rng(4242)
21
  q: { t: number; fn: () => void }[] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  constructor(ctx: AC) {
23
  this.ctx = ctx
24
  // 延迟建节点:只在需要前 ~1 秒才真正创建(离线渲染快很多)
@@ -254,206 +270,12 @@ class Mixer {
254
  lap(t0: number, t1: number, vel = 0.05) { for (let t = t0; t < t1; t += 0.6 + this.r() * 0.9) this.noiseHit(t, 0.5, 'bandpass', 700 + this.r() * 300, 300, vel, this.r() * 1.2 - 0.6, 1.5, 0.3) }
255
  }
256
 
257
- // ---------- 编排 ----------
258
- function score(m: Mixer) {
259
- const B = 60 / 72 // 每拍秒数
260
- const S = sceneStart, D = sceneDur
261
- const r = rng(808)
262
- const arp = (t0: number, t1: number, chords: number[][], step: number, vel: number, pattern = [0, 1, 2, 1]) => {
263
- let i = 0
264
- for (let t = t0; t < t1 - 0.05; t += step, i++) {
265
- const ch = chords[Math.floor((t - t0) / (step * 8)) % chords.length]
266
- m.pluck(t, ch[pattern[i % pattern.length] % ch.length], vel * (i % 4 === 0 ? 1 : 0.7), (i % 2 ? 0.25 : -0.25), { dur: 1.8 })
267
- }
268
- }
269
- const G = [N.G3, N.D4, N.B4], Em = [N.E3, N.B3, N.G4], Dm = [N.D3, N.A3, N.D4], Am = [N.A2, N.E3, N.A3], Gh = [N.G4, N.B4, N.D5]
270
- // 片头
271
- let t = S('title')
272
- m.gliss(t + 0.35, [N.G3, N.A3, N.B3, N.D4, N.E4, N.G4, N.A4, N.B4, N.D5], 0.07, 0.28)
273
- m.pad(t + 0.5, S('summer') + 1, [N.G2, N.D3], 0.05)
274
- m.melody(t + 2.2, B, [['D5', 1], ['E5', 0.5], ['G5', 0.5], ['E5', 1], ['D5', 1.5]], 0.17)
275
- // 夏
276
- t = S('summer')
277
- m.pad(t, S('hatch') + 0.5, [N.G2, N.D3, N.B3], 0.045)
278
- arp(t + 0.3, S('hatch'), [G, Em, G, Dm], B / 2, 0.2)
279
- m.melody(t + 1.2, B, [['D5', 1], ['E5', 0.5], ['G5', 0.5], ['E5', 1], ['D5', 1], ['B4', 1], ['D5', 0.5], ['B4', 0.5], ['A4', 2], ['G4', 1], ['A4', 0.5], ['B4', 0.5], ['D5', 1], ['E5', 0.5], ['D5', 0.5], ['B4', 1], ['A4', 0.5], ['G4', 0.5], ['G4', 2]], 0.13)
280
- // 破壳:俏皮的跳音 + 木鱼
281
- t = S('hatch')
282
- for (let i = 0; i < Math.floor(D('hatch') / B); i++) { if (i % 2 === 0) m.block(t + i * B, 0.07, 1100, -0.3); m.pluck(t + i * B + B / 2, [N.G4, N.B4, N.D5, N.E5][i % 4], 0.13, 0.3, { dur: 0.5, bright: 0.9 }) }
283
- m.pad(t, S('bigegg'), [N.G2, N.D3], 0.035)
284
- // 大蛋:悬念颤音 → 揭晓 → 「咦?」
285
- t = S('bigegg')
286
- const BE = CUE.bigegg
287
- m.pad(t, t + BE.open, [N.E2, N.B2], 0.05, 500)
288
- for (let x = t + 0.6; x < t + BE.open - 0.05; x += 0.09) m.pluck(x, N.E3, 0.06 + 0.1 * (x - t) / BE.open, 0, { dur: 0.3, send: 0.1 })
289
- m.pluck(t + BE.surprise, N.D4, 0.3, 0, { bend: -2 }); m.pluck(t + BE.surprise + 0.25, N.A3, 0.3, 0, { bend: -2 })
290
- m.pluck(t + BE.question + 0.05, N.B4, 0.22, 0.2, { bend: 2 })
291
- arp(t + BE.walk, S('bullied'), [Em, G], B / 2, 0.12)
292
- // 被嘲笑:E 羽调式,慢
293
- t = S('bullied')
294
- m.pad(t, S('leave') + 0.5, [N.E2, N.B2, N.G3], 0.05, 700)
295
- for (let i = 0; i < 12; i++) m.pluck(t + 0.3 + i * B, [N.E4, N.D4, N.B3, N.A3, N.G3, N.A3, N.B3, N.E3][i % 8], 0.16, -0.2, { dur: 2, bend: i % 4 === 3 ? -1 : 0 })
296
- // 离家:竹笛哀歌
297
- t = S('leave')
298
- m.pad(t, S('marsh') + 0.3, [N.E2, N.B2], 0.045, 600)
299
- m.melody(t + 0.4, B * 1.1, [['E5', 1.5], ['D5', 0.5], ['B4', 1], ['A4', 1], ['G4', 1], ['A4', 0.5], ['B4', 0.5], ['E4', 2]], 0.13)
300
- // 沼泽:低音颤奏 → 枪声后寂静 → 心跳
301
- t = S('marsh')
302
- for (let x = t + 0.2; x < t + CUE.marsh.shots[0] - 0.02; x += 0.125) m.pluck(x, (Math.floor((x - t) / 0.5) % 2) ? N.B2 : N.E2, 0.17, 0, { dur: 0.25, send: 0.05 })
303
- m.pad(t + CUE.marsh.hide, S('autumn') + 0.5, [N.E2], 0.04, 400)
304
- for (let x = t + CUE.marsh.hide + 0.6; x < t + 13; x += 0.95) m.heart(x)
305
- // 秋:向往(E 羽 → G 宫的过渡)
306
- t = S('autumn')
307
- m.pad(t, S('winter') + 0.5, [N.E2, N.B2, N.G3], 0.04, 800)
308
- arp(t + 0.3, S('winter'), [Em, Am, G, Em], B * 0.75, 0.13)
309
- m.bell(t + CUE.autumn.swans + 0.6, N.B5, 0.07, 0.4); m.bell(t + CUE.autumn.swans + 3, N.E6, 0.06, -0.3)
310
- m.melody(t + CUE.autumn.swans + 1.4, B, [['B4', 1.5], ['D5', 0.5], ['E5', 1], ['G5', 1], ['E5', 2], ['D5', 1], ['B4', 1], ['A4', 2]], 0.11, 0)
311
- // 冬:几乎只剩风与孤铃
312
- t = S('winter')
313
- m.pad(t, S('spring') + 0.5, [N.E2, N.B2], 0.035, 350)
314
- for (let i = 0; i < 6; i++) m.bell(t + 1 + i * 2.1, [N.E5, N.B4, N.D5, N.A4, N.B4, N.E5][i], 0.05, (i % 2 ? 0.4 : -0.4))
315
- // 春:回到 G 宫,逐渐加密
316
- t = S('spring')
317
- m.bell(t + 0.15, N.G5, 0.1, 0); m.bell(t + 0.4, N.D6, 0.07, 0.3)
318
- m.pad(t, S('reflection') + 0.5, [N.G2, N.D3, N.B3], 0.045)
319
- const SP = CUE.spring
320
- arp(t + 0.5, t + SP.flapHard, [G, Em], B / 2, 0.15)
321
- arp(t + SP.flapHard, S('reflection'), [G, Dm, Em, G], B / 4, 0.14, [0, 1, 2, 1, 2, 1])
322
- m.gliss(t + SP.lift - 0.05, [N.G3, N.A3, N.B3, N.D4, N.E4, N.G4, N.A4, N.B4, N.D5, N.E5, N.G5], 0.05, 0.25)
323
- m.melody(t + SP.lift + 0.4, B, [['D5', 1], ['E5', 0.5], ['G5', 0.5], ['A5', 1], ['G5', 1], ['E5', 1], ['G5', 0.5], ['E5', 0.5], ['D5', 2]], 0.13)
324
- // 倒影:揭晓
325
- t = S('reflection')
326
- const RF = CUE.reflection
327
- m.pad(t, t + RF.reveal, [N.E2, N.B2, N.E3], 0.04, 600)
328
- for (let i = 0; i < 10; i++) m.pluck(t + 0.8 + i * B * 1.05, [N.B3, N.D4, N.E4, N.D4, N.B3, N.A3, N.B3, N.D4, N.E4, N.G4][i], 0.12, 0, { dur: 2 })
329
- m.bell(t + RF.mirror, N.E5, 0.06, 0); m.bell(t + RF.mirror + 0.3, N.B5, 0.04, 0.3)
330
- m.gliss(t + RF.reveal - 0.05, [N.G3, N.B3, N.D4, N.G4, N.B4, N.D5, N.G5, N.B5, N.D6], 0.06, 0.3)
331
- ;[N.G5, N.B5, N.D6, N.G5].forEach((x, i) => m.bell(t + RF.reveal + i * 0.18, x, 0.08, i % 2 ? 0.3 : -0.3))
332
- m.pad(t + RF.reveal, S('accepted') + 0.5, [N.G2, N.D3, N.B3, N.D4], 0.05, 1200)
333
- m.melody(t + RF.reveal + 1.2, B, [['G5', 1.5], ['E5', 0.5], ['D5', 1], ['B4', 1], ['D5', 2]], 0.14)
334
- // 接纳:温暖
335
- t = S('accepted')
336
- arp(t + 0.2, S('fly'), [G, Em, Dm, G], B / 2, 0.15)
337
- m.pad(t, S('fly') + 0.3, [N.G2, N.D3], 0.04)
338
- m.melody(t + 0.8, B, [['G4', 1], ['A4', 0.5], ['B4', 0.5], ['D5', 1], ['E5', 0.5], ['D5', 0.5], ['B4', 1], ['A4', 0.5], ['G4', 0.5], ['G4', 2]], 0.12)
339
- // 高飞:高潮
340
- t = S('fly')
341
- m.pad(t, S('ending') + 1, [N.G2, N.D3, N.G3, N.B3], 0.06, 1600)
342
- arp(t, S('ending'), [Gh, G, Em, Gh], B / 4, 0.16, [0, 1, 2, 1, 2, 1, 0, 2])
343
- for (let i = 0; i < Math.floor(D('fly') / B); i++) m.block(t + i * B, 0.08, 800, 0)
344
- m.melody(t + 0.2, B, [['D5', 1], ['E5', 0.5], ['G5', 0.5], ['A5', 1], ['G5', 1], ['E5', 1], ['G5', 0.5], ['A5', 0.5], ['B5', 2]], 0.15, 0)
345
- // 尾声
346
- t = S('ending')
347
- m.pad(t, TOTAL, [N.G2, N.D3, N.B3], 0.045)
348
- const EN = CUE.ending.end
349
- arp(t + 0.2, t + EN - 0.2, [G, Em, G, Dm], B * 0.75, 0.12)
350
- m.gliss(t + EN, [N.G3, N.B3, N.D4, N.G4, N.B4, N.D5, N.G5], 0.08, 0.22)
351
- m.bell(t + EN + 0.1, N.G5, 0.08); m.bell(t + EN + 0.35, N.D6, 0.05, 0.3)
352
- m.pluck(t + EN + 0.6, N.G3, 0.2, 0, { dur: 2 }); m.pluck(t + EN + 0.6, N.D4, 0.15, 0, { dur: 2 }); m.pluck(t + EN + 0.6, N.G4, 0.12, 0, { dur: 2 })
353
- void r
354
- }
355
-
356
- function effects(m: Mixer) {
357
- const S = sceneStart, D = sceneDur
358
- const r = m.r
359
- let t = S('title')
360
- const TT = CUE.title
361
- m.drop(t + TT.drop); m.swish(t + TT.write); m.swish(t + TT.write + 0.45); m.swish(t + TT.write + 0.9); m.thump(t + TT.seal + 0.01, 0.45)
362
- for (let i = 0; i < 6; i++) m.peep(t + 2.6 + i * 0.65, 0.04, 0.3)
363
- // 夏:鸟鸣、微风
364
- t = S('summer')
365
- m.wind(t, D('summer'), 0.025)
366
- for (let x = t + 0.6; x < t + D('summer'); x += 1.1 + r() * 1.6) m.chirp(x, 0.035, r() * 1.4 - 0.7)
367
- // 破壳
368
- t = S('hatch')
369
- for (let k = 0; k < 4; k++) {
370
- const tc = t + 0.4 + k * 0.75
371
- m.crack(tc + 0.05, 0.22, -0.3 + k * 0.2); m.crack(tc + 0.25, 0.18, -0.3 + k * 0.2)
372
- m.noiseHit(tc + 0.55, 0.08, 'bandpass', 1800, 900, 0.18, -0.3 + k * 0.2, 2, 0.1)
373
- m.peep(tc + 0.75, 0.1, -0.3 + k * 0.2)
374
- for (let p = 1; p < 5; p++) { const pt = tc + 1 + 2.3 * (p - 1); if (pt - t < D('hatch')) m.peep(pt + 0.05, 0.07, -0.3 + k * 0.2) }
375
- m.noiseHit(tc + 3.0, 0.05, 'lowpass', 600, 200, 0.12, -0.3 + k * 0.2, 0.7, 0.05)
376
- }
377
- // 大蛋
378
- t = S('bigegg')
379
- const BE = CUE.bigegg
380
- for (let x = t + 0.5; x < t + BE.crackFrom; x += Math.max(0.12, 0.5 - (x - t) * 0.12)) m.block(x, 0.05 + (x - t) * 0.02, 420, 0)
381
- m.crack(t + BE.crackFrom + 0.05, 0.2); m.crack(t + BE.crackFrom + 0.4, 0.22)
382
- m.crack(t + BE.open, 0.35, 0, true)
383
- m.noiseHit(t + BE.surprise, 0.25, 'bandpass', 2400, 1200, 0.06, -0.4, 1.2, 0.2)
384
- m.peep(t + BE.surprise + 0.1, 0.08, -0.4); m.peep(t + BE.surprise + 0.2, 0.08, -0.5)
385
- for (let x = t + BE.walk + 0.1; x < t + BE.walk + 2.6; x += 0.6) m.noiseHit(x, 0.05, 'lowpass', 600, 200, 0.1, 0, 0.7, 0.05)
386
- // 被嘲笑
387
- t = S('bullied')
388
- const BU = CUE.bullied
389
- for (let x = t + BU.laugh1[0] + 0.1; x < t + BU.laugh1[1] - 0.2; x += 0.35 + r() * 0.3) m.quack(x, 0.09, -0.5 + r() * 0.3, 1.25 + r() * 0.2)
390
- for (let x = t + BU.laugh2[0] + 0.1; x < t + BU.laugh2[1] - 0.2; x += 0.4 + r() * 0.35) m.quack(x, 0.07, -0.5 + r() * 0.3, 1.25 + r() * 0.2)
391
- // 公鸡登场:扑翅 + 啄
392
- m.flap(t + BU.roosterIn, 6, 9, 0.12, 0.5)
393
- m.tone(t + BU.roosterIn + 0.2, 600, 900, 0.25, 0.08, 'sawtooth', 0.5, 0.3); m.tone(t + BU.roosterIn + 0.45, 900, 500, 0.5, 0.07, 'sawtooth', 0.5, 0.4)
394
- for (const p of BU.pecks) { m.block(t + p, 0.25, 1500, 0.2); m.flap(t + p - 0.15, 2, 10, 0.08, 0.3) }
395
- m.swish(t + BU.shoo, 0.07)
396
- // 离家
397
- t = S('leave')
398
- m.wind(t, D('leave') + 1, 0.07, -0.2)
399
- for (let x = t + CUE.leave.walk + 0.2; x < t + D('leave'); x += 0.62) m.noiseHit(x, 0.04, 'lowpass', 500, 200, 0.05, 0, 0.7, 0.05)
400
- // 沼泽
401
- t = S('marsh')
402
- m.lap(t, t + D('marsh'), 0.035)
403
- const MA = CUE.marsh
404
- for (let x = t + MA.laugh[0] + 0.1; x < t + MA.laugh[1] - 0.2; x += 0.3 + r() * 0.3) m.quack(x, 0.1, 0.4 + r() * 0.3, 0.95 + r() * 0.15)
405
- m.shot(t + MA.shots[0], 0.6); m.shot(t + MA.shots[1], 0.45)
406
- m.flap(t + MA.scatter, 14, 12, 0.13, 0.4)
407
- for (let i = 0; i < 6; i++) m.quack(t + MA.scatter + 0.05 + i * 0.12, 0.08, 0.5, 1.1)
408
- m.splash(t + MA.hide + 0.2, 0.12)
409
- // 秋
410
- t = S('autumn')
411
- m.wind(t, D('autumn'), 0.03, 0.2)
412
- const AU = CUE.autumn.swans
413
- m.swanCall(t + AU + 0.8, 0.06, 0.5); m.swanCall(t + AU + 3.0, 0.07, 0.1); m.swanCall(t + AU + 5.6, 0.05, -0.5)
414
- m.flap(t + AU + 1.5, 12, 1.4, 0.04, 0.2)
415
- // 冬
416
- t = S('winter')
417
- m.wind(t, D('winter') + 0.8, 0.12, 0)
418
- m.wind(t + 2, D('winter') - 2, 0.06, 0.6)
419
- m.crack(t + CUE.winter.ice, 0.2, 0, true); m.noiseHit(t + CUE.winter.ice + 0.05, 0.9, 'bandpass', 300, 120, 0.12, 0, 4, 0.5)
420
- // 春
421
- t = S('spring')
422
- for (let x = t + 0.4; x < t + D('spring'); x += 0.7 + r() * 1.1) m.chirp(x, 0.04, r() * 1.4 - 0.7)
423
- const SP = CUE.spring
424
- m.flap(t + SP.spread, 2, 2, 0.1, 0)
425
- m.flap(t + SP.spread + 0.9, Math.floor((SP.flapHard - SP.spread - 0.9) * 0.83), 0.83, 0.08, 0)
426
- m.flap(t + SP.flapHard, Math.floor((SP.lift - SP.flapHard) * 1.6), 1.6, 0.13, 0); m.flap(t + SP.lift, 6, 1.46, 0.1, 0.2)
427
- m.noiseHit(t + SP.lift - 0.1, 1.2, 'bandpass', 400, 2400, 0.12, 0, 1, 0.4)
428
- m.pluck(t + SP.question, N.B4, 0.2, 0, { bend: 2 })
429
- // 倒影
430
- t = S('reflection')
431
- const RF = CUE.reflection
432
- m.flap(t, 5, 7, 0.1, -0.2)
433
- m.splash(t + RF.land, 0.3)
434
- m.lap(t + 1.2, t + D('reflection'), 0.03)
435
- m.bell(t + RF.reveal, 91, 0.06, 0, m.sfx)
436
- // 接纳
437
- t = S('accepted')
438
- m.lap(t, t + D('accepted'), 0.03)
439
- const CH = CUE.accepted.cheer
440
- for (let x = t + CH + 0.1; x < t + CH + 4.5; x += 0.11 + r() * 0.08) m.clap(x, 0.06 * (1 - (x - t - CH) / 5), -0.55 + r() * 0.2)
441
- for (let i = 0; i < 4; i++) m.chirp(t + CH + 0.4 + i * 1.3, 0.03, 0.4)
442
- // 高飞
443
- t = S('fly')
444
- m.wind(t, D('fly') + 1, 0.07, 0)
445
- m.flap(t + 0.3, Math.floor(D('fly') * 3.2 / 2 / Math.PI * 2), 3.2 / Math.PI, 0.09, 0)
446
- m.noiseHit(t + 0.9, 1.6, 'bandpass', 300, 2000, 0.1, 0, 1, 0.5)
447
- // 尾声
448
- t = S('ending')
449
- m.lap(t, t + D('ending'), 0.025)
450
- m.thump(t + CUE.ending.end + 0.12, 0.4)
451
- }
452
-
453
- let cached: Promise<AudioBuffer> | null = null
454
  export function buildSoundtrack(voiceUrl: string, onStage?: (s: string) => void): Promise<AudioBuffer> {
455
- if (cached) return cached
456
- cached = (async () => {
 
 
457
  const len = Math.ceil(SR * (TOTAL + 0.3))
458
  const ctx = new OfflineAudioContext(2, len, SR)
459
  onStage?.('加载旁白…')
@@ -464,17 +286,19 @@ export function buildSoundtrack(voiceUrl: string, onStage?: (s: string) => void)
464
  } catch (e) { console.warn('voice load failed', e) }
465
  onStage?.('合成配乐与音效…')
466
  const m = new Mixer(ctx)
 
467
  // 人声压低 BGM(sidechain 式):按时间轴里的配音区间自动化
468
  const mg = m.music.gain
469
  mg.setValueAtTime(0.9, 0)
470
  for (const s of TL.scenes) {
471
- if (!s.voice_start || !s.voice_dur) continue
472
- const a = s.voice_start, b = s.voice_start + s.voice_dur
473
- mg.setTargetAtTime(0.42, Math.max(0, a - 0.25), 0.08)
474
- mg.setTargetAtTime(0.9, b + 0.1, 0.25)
 
475
  }
476
- score(m)
477
- effects(m)
478
  m.flushPlucks()
479
  m.q.sort((a, b) => a.t - b.t)
480
  let qi = 0
@@ -497,7 +321,8 @@ export function buildSoundtrack(voiceUrl: string, onStage?: (s: string) => void)
497
  onStage?.('完成')
498
  return out
499
  })()
500
- cached.catch(() => { cached = null })
 
501
  return cached
502
  }
503
 
 
1
  // 声音:原创五声音阶配乐(古筝拨弦 + 竹笛 + 铺底 + 铃)+ 程序音效 + 旁白,离线混成一条立体声。
2
  // 完全确定:同一输入 → 同一音轨。网页播放、MP4 导出、质检用的是同一条音轨。
3
+ import { TL, TOTAL, FILM } from './film'
4
  import { rng } from './core'
 
5
 
6
+ export const SR = 44100
7
  type AC = OfflineAudioContext
8
+ export const midi = (m: number) => 440 * Math.pow(2, (m - 69) / 12)
9
  // 五声音阶(G 宫 / E 羽 共用)
10
+ export const N: Record<string, number> = { E2: 40, G2: 43, A2: 45, B2: 47, D3: 50, E3: 52, G3: 55, A3: 57, B3: 59, D4: 62, E4: 64, G4: 67, A4: 69, B4: 71, D5: 74, E5: 76, G5: 79, A5: 81, B5: 83, D6: 86, E6: 88 }
11
+ export const sceneStart = (id: string) => TL.scenes.find((s) => s.id === id)?.start ?? 0
12
+ export const sceneDur = (id: string) => TL.scenes.find((s) => s.id === id)?.dur ?? 0
13
 
14
+ export class Mixer {
15
  ctx: AC
16
  music: GainNode; sfx: GainNode; voice: GainNode; rev: ConvolverNode; revIn: GainNode; master: GainNode
17
  noise: AudioBuffer
18
  ksCache = new Map<number, AudioBuffer>()
19
  r = rng(4242)
20
  q: { t: number; fn: () => void }[] = []
21
+ /** 人声轨(供「山谷回声」等效果使用;在 score/effects 之前由 buildSoundtrack 设置) */
22
+ voiceBuf: AudioBuffer | null = null
23
+ /** 在 t 时刻真正创建节点(离线渲染按需建节点) */
24
+ at(t: number, fn: () => void) { this.q.push({ t, fn }) }
25
+ /** 把人声 [t0, t0+dur) 这一段当回声再放几遍:越来越远、越来越暗 */
26
+ echo(t0: number, dur: number, delay = 0.42, gain = 0.3, taps = 3) {
27
+ this.at(t0 + delay, () => {
28
+ if (!this.voiceBuf) return
29
+ for (let k = 1; k <= taps; k++) {
30
+ const s = this.ctx.createBufferSource(); s.buffer = this.voiceBuf
31
+ const f = this.ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 2800 / k
32
+ const g = this.ctx.createGain(); g.gain.value = gain * Math.pow(0.5, k - 1)
33
+ s.connect(f); f.connect(g); this.out(g, this.sfx, k % 2 ? -0.45 : 0.45, 0.6)
34
+ s.start(t0 + delay * k + 1 / 30, t0, dur)
35
+ }
36
+ })
37
+ }
38
  constructor(ctx: AC) {
39
  this.ctx = ctx
40
  // 延迟建节点:只在需要前 ~1 秒才真正创建(离线渲染快很多)
 
270
  lap(t0: number, t1: number, vel = 0.05) { for (let t = t0; t < t1; t += 0.6 + this.r() * 0.9) this.noiseHit(t, 0.5, 'bandpass', 700 + this.r() * 300, 300, vel, this.r() * 1.2 - 0.6, 1.5, 0.3) }
271
  }
272
 
273
+ const cache = new Map<string, Promise<AudioBuffer>>()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  export function buildSoundtrack(voiceUrl: string, onStage?: (s: string) => void): Promise<AudioBuffer> {
275
+ const film = FILM
276
+ const hit = cache.get(film.id)
277
+ if (hit) return hit
278
+ const cached = (async () => {
279
  const len = Math.ceil(SR * (TOTAL + 0.3))
280
  const ctx = new OfflineAudioContext(2, len, SR)
281
  onStage?.('加载旁白…')
 
286
  } catch (e) { console.warn('voice load failed', e) }
287
  onStage?.('合成配乐与音效…')
288
  const m = new Mixer(ctx)
289
+ m.voiceBuf = voiceBuf
290
  // 人声压低 BGM(sidechain 式):按时间轴里的配音区间自动化
291
  const mg = m.music.gain
292
  mg.setValueAtTime(0.9, 0)
293
  for (const s of TL.scenes) {
294
+ const spans: [number, number][] = s.lines?.length ? s.lines.map((l) => [l.t0, l.t0 + l.dur]) : s.voice_start && s.voice_dur ? [[s.voice_start, s.voice_start + s.voice_dur]] : []
295
+ for (const [a, b] of spans) {
296
+ mg.setTargetAtTime(0.42, Math.max(0, a - 0.25), 0.08)
297
+ mg.setTargetAtTime(0.9, b + 0.1, 0.25)
298
+ }
299
  }
300
+ film.score(m)
301
+ film.effects(m)
302
  m.flushPlucks()
303
  m.q.sort((a, b) => a.t - b.t)
304
  let qi = 0
 
321
  onStage?.('完成')
322
  return out
323
  })()
324
+ cache.set(film.id, cached)
325
+ cached.catch(() => cache.delete(film.id))
326
  return cached
327
  }
328
 
wolf-coming/web/src/film/film.ts CHANGED
@@ -1,12 +1,17 @@
1
  // 合成器:时间轴 → 场景 → 晕染转场 → 字幕。每一帧只由 T 决定。
2
- import timeline from './timeline.json'
3
  import { type Ctx, paper, makeCanvas, ctx2d, blobLoop, spline, tracePath, rgba, INK, PAPER, FONT_KAI, easeInOut, clamp, inv, smooth } from './core'
4
- import { SCENES, type SceneCtx } from './scenes'
5
 
6
- export type Sub = { t0: number; t1: number; text: string }
7
- export type TLScene = { id: string; start: number; dur: number; voice_start?: number; voice_dur?: number; subs?: Sub[]; sfx?: { at: number; type: string }[] }
8
- export const TL = timeline as unknown as { total: number; fps: number; scenes: TLScene[] }
9
- export const TOTAL = TL.total
 
 
 
 
 
 
10
 
11
  export type RenderOpts = { subs?: boolean; fadeEnd?: boolean }
12
 
@@ -97,7 +102,7 @@ export function renderFrame(g: Ctx, W: number, H: number, T: number, opts: Rende
97
  g.restore()
98
  }
99
 
100
- export function currentSub(T: number): { text: string; a: number } | null {
101
  const i = sceneIndexAt(T)
102
  const subs = TL.scenes[i].subs ?? []
103
  for (let k = 0; k < subs.length; k++) {
@@ -105,7 +110,7 @@ export function currentSub(T: number): { text: string; a: number } | null {
105
  const end = k + 1 < subs.length ? subs[k + 1].t0 : s.t1 + 0.35
106
  if (T >= s.t0 - 0.05 && T < end) {
107
  const a = Math.min(smooth((T - s.t0 + 0.05) / 0.18), smooth((end - T) / 0.15))
108
- return { text: s.text, a }
109
  }
110
  }
111
  return null
@@ -114,6 +119,7 @@ export function currentSub(T: number): { text: string; a: number } | null {
114
  export function drawSubs(g: Ctx, W: number, H: number, T: number) {
115
  const s = currentSub(T)
116
  if (!s || s.a <= 0) return
 
117
  const portrait = H > W * 1.05
118
  const u = Math.min(W, H) / 1080
119
  const fs = (portrait ? 58 : 50) * u
@@ -133,7 +139,7 @@ export function drawSubs(g: Ctx, W: number, H: number, T: number) {
133
  g.lineJoin = 'round'
134
  g.strokeStyle = rgba(PAPER, 0.96); g.lineWidth = fs * 0.2
135
  g.strokeText(s.text, W / 2, y)
136
- g.fillStyle = rgba(INK, 0.97)
137
  g.fillText(s.text, W / 2, y)
138
  g.restore()
139
  }
 
1
  // 合成器:时间轴 → 场景 → 晕染转场 → 字幕。每一帧只由 T 决定。
 
2
  import { type Ctx, paper, makeCanvas, ctx2d, blobLoop, spline, tracePath, rgba, INK, PAPER, FONT_KAI, easeInOut, clamp, inv, smooth } from './core'
3
+ import type { FilmDef, SceneCtx, SceneDef, Timeline } from './types'
4
 
5
+ // 当前片子(ES 模块导出是活绑定:setFilm 之后各处读到的 TL / TOTAL 自动更新)
6
+ export let FILM: FilmDef = null as unknown as FilmDef
7
+ export let TL: Timeline = { total: 1, fps: 30, scenes: [] }
8
+ export let TOTAL = 1
9
+ let SCENES: Record<string, SceneDef> = {}
10
+ export function setFilm(f: FilmDef) {
11
+ if (FILM === f) return
12
+ FILM = f; TL = f.timeline; TOTAL = f.timeline.total; SCENES = f.scenes
13
+ bgCache.clear()
14
+ }
15
 
16
  export type RenderOpts = { subs?: boolean; fadeEnd?: boolean }
17
 
 
102
  g.restore()
103
  }
104
 
105
+ export function currentSub(T: number): { text: string; a: number; who?: string } | null {
106
  const i = sceneIndexAt(T)
107
  const subs = TL.scenes[i].subs ?? []
108
  for (let k = 0; k < subs.length; k++) {
 
110
  const end = k + 1 < subs.length ? subs[k + 1].t0 : s.t1 + 0.35
111
  if (T >= s.t0 - 0.05 && T < end) {
112
  const a = Math.min(smooth((T - s.t0 + 0.05) / 0.18), smooth((end - T) / 0.15))
113
+ return { text: s.text, a, who: s.who }
114
  }
115
  }
116
  return null
 
119
  export function drawSubs(g: Ctx, W: number, H: number, T: number) {
120
  const s = currentSub(T)
121
  if (!s || s.a <= 0) return
122
+ if (SCENES[TL.scenes[sceneIndexAt(T)].id]?.noSubs) return
123
  const portrait = H > W * 1.05
124
  const u = Math.min(W, H) / 1080
125
  const fs = (portrait ? 58 : 50) * u
 
139
  g.lineJoin = 'round'
140
  g.strokeStyle = rgba(PAPER, 0.96); g.lineWidth = fs * 0.2
141
  g.strokeText(s.text, W / 2, y)
142
+ g.fillStyle = rgba((s.who && FILM.subColors?.[s.who]) || INK, 0.97)
143
  g.fillText(s.text, W / 2, y)
144
  g.restore()
145
  }