Ethscriptions commited on
Commit
5e7f974
·
verified ·
1 Parent(s): bd4fc1b

Step 4-7: 音乐音效混音 + 播放器 + MP4 导出 + 发布文案 + 封面

Browse files
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ shouhouzi/covers/cover-landscape.png filter=lfs diff=lfs merge=lfs -text
2
+ shouhouzi/covers/cover-portrait.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -25,10 +25,10 @@ pinned: false
25
  | 1 | 写计划 / 大纲 / 步骤(本文件) | ✅ 完成 |
26
  | 2 | 旁白文案定稿 + TTS 生成配音 + 时长表(15 场景 / 77 句 / 约 6 分钟,旁白+商人+助手三种声线) | ✅ 完成 |
27
  | 3 | 动画引擎 + 全部 15 个场景(横屏 16:9 / 竖屏 9:16 双布局,已用无头浏览器逐句截图验证) | ✅ 完成 |
28
- | 4 | 程序化音乐 + 音效 + 旁白混音(WebAudio 离线渲染) | ⏳ |
29
- | 5 | 播放器 UI + MP4 本地导出(24/30/60fps,横/竖屏) | ⏳ |
30
- | 6 | 抖音 / B站 发布文案(可一键复制) | ⏳ |
31
- | 7 | 横屏 / 竖屏封面图生成 | ⏳ |
32
  | 8 | 无头浏览器自动化测试 + 修复 + 最终说明 | ⏳ |
33
 
34
  ---
 
25
  | 1 | 写计划 / 大纲 / 步骤(本文件) | ✅ 完成 |
26
  | 2 | 旁白文案定稿 + TTS 生成配音 + 时长表(15 场景 / 77 句 / 约 6 分钟,旁白+商人+助手三种声线) | ✅ 完成 |
27
  | 3 | 动画引擎 + 全部 15 个场景(横屏 16:9 / 竖屏 9:16 双布局,已用无头浏览器逐句截图验证) | ✅ 完成 |
28
+ | 4 | 程序化音乐(9 种情绪段落)+ 30 余种合成音效 + 旁白闪避混音(OfflineAudioContext 分窗渲染,约 10 秒生成整条音轨) | ✅ 完成 |
29
+ | 5 | 播放器 UI + MP4 本地导出(24/30/60fps,横/竖屏;H.264+AAC,无 H.264 时自动回退 VP9+Opus) | ✅ 完成 |
30
+ | 6 | 抖音 / B站 发布文案(可一键复制,B站简介自动生成章节时间戳) | ✅ 完成 |
31
+ | 7 | 横屏 1920×1080 / 竖屏 1080×1920 封面图(静态 PNG + 网页内重绘下载) | ✅ 完成 |
32
  | 8 | 无头浏览器自动化测试 + 修复 + 最终说明 | ⏳ |
33
 
34
  ---
shouhouzi/covers/cover-landscape.png ADDED

Git LFS Details

  • SHA256: 5ddaeef7c56cd08dcda01b4f904c3b6f5ceb6252124dc502a946e5a22563ce69
  • Pointer size: 132 Bytes
  • Size of remote file: 1.66 MB
shouhouzi/covers/cover-portrait.png ADDED

Git LFS Details

  • SHA256: a0f6001ece216474fb05b26b6e23e13718692e244b4a4729e121e1dbc2944df1
  • Pointer size: 132 Bytes
  • Size of remote file: 1.68 MB
shouhouzi/css/style.css ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @font-face { font-family: "KuaiLe"; src: url(../fonts/kuaile.woff2) format("woff2"); font-display: block; }
2
+ :root { --ink: #2b2321; --paper: #fbf3e1; --gold: #ffcd3c; --red: #ff5b4f; --green: #38b86a; --blue: #4a8fe2; --card: #fffaf0; }
3
+ * { box-sizing: border-box; }
4
+ html { scroll-behavior: smooth; }
5
+ body { margin: 0; background: #f3e7cc url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Cpath d='M0 39.5h40M39.5 0v40' stroke='%23e6d6b3' stroke-width='1'/%3E%3C/svg%3E"); color: var(--ink); font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif; }
6
+ .top { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px max(16px, env(safe-area-inset-left)); background: rgba(43,35,33,.94); color: #fff; flex-wrap: wrap; }
7
+ .brand { display: flex; align-items: center; gap: 12px; }
8
+ .logo { font-size: 38px; }
9
+ .brand h1 { margin: 0; font-family: KuaiLe, sans-serif; font-weight: normal; color: var(--gold); font-size: 30px; letter-spacing: 2px; }
10
+ .brand p { margin: 2px 0 0; font-size: 13px; opacity: .8; }
11
+ nav a { color: #fff; text-decoration: none; margin-left: 14px; font-size: 14px; opacity: .85; }
12
+ nav a:hover { opacity: 1; color: var(--gold); }
13
+ main { max-width: 1180px; margin: 0 auto; padding: 18px 14px 40px; }
14
+ .card { background: var(--card); border: 3px solid var(--ink); border-radius: 18px; box-shadow: 6px 8px 0 rgba(43,35,33,.85); padding: 18px; margin-bottom: 26px; }
15
+ h2 { font-family: KuaiLe, sans-serif; font-weight: normal; font-size: 26px; margin: 0 0 10px; display: flex; align-items: center; gap: 12px; }
16
+ .muted { color: #7a6a5a; font-size: 14px; }
17
+ .stage-wrap { display: flex; justify-content: center; background: #2b2321; border-radius: 12px; padding: 10px; }
18
+ .stage { position: relative; width: 100%; aspect-ratio: 16/9; max-height: 76vh; }
19
+ .stage.portrait { aspect-ratio: 9/16; width: auto; height: 76vh; max-width: 100%; }
20
+ .stage canvas { width: 100%; height: 100%; display: block; border-radius: 8px; background: #fbf3e1; cursor: pointer; }
21
+ .overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; background: rgba(43,35,33,.82); color: #fff; border-radius: 8px; font-size: 16px; }
22
+ .overlay .bar { width: 60%; height: 10px; background: rgba(255,255,255,.2); border-radius: 6px; overflow: hidden; }
23
+ .overlay .bar i { display: block; height: 100%; width: 0; background: var(--gold); transition: width .2s; }
24
+ .spinner { width: 46px; height: 46px; border: 6px solid rgba(255,255,255,.25); border-top-color: var(--gold); border-radius: 50%; animation: spin 1s linear infinite; }
25
+ @keyframes spin { to { transform: rotate(360deg); } }
26
+ .bigplay { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 110px; height: 110px; border-radius: 50%; border: 5px solid var(--ink); background: var(--gold); font-size: 46px; cursor: pointer; box-shadow: 4px 6px 0 var(--ink); padding-left: 10px; }
27
+ .hidden { display: none !important; }
28
+ .controls { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
29
+ .icon { width: 44px; height: 44px; border-radius: 12px; border: 3px solid var(--ink); background: #fff; font-size: 18px; cursor: pointer; flex: none; }
30
+ .icon:hover { background: var(--gold); }
31
+ .time { font-variant-numeric: tabular-nums; font-size: 14px; white-space: nowrap; }
32
+ .seek { position: relative; flex: 1; height: 16px; background: #e8dcc2; border: 3px solid var(--ink); border-radius: 10px; cursor: pointer; touch-action: none; }
33
+ .seek-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--red); border-radius: 6px; }
34
+ .knob { position: absolute; top: 50%; width: 22px; height: 22px; background: #fff; border: 3px solid var(--ink); border-radius: 50%; transform: translate(-50%,-50%); left: 0; }
35
+ .marks i { position: absolute; top: -3px; bottom: -3px; width: 2px; background: rgba(43,35,33,.45); }
36
+ .row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 12px; }
37
+ .seg { display: inline-flex; border: 3px solid var(--ink); border-radius: 12px; overflow: hidden; flex-wrap: wrap; }
38
+ .seg button { border: 0; background: #fff; padding: 9px 14px; font-size: 14px; cursor: pointer; border-right: 2px solid var(--ink); }
39
+ .seg button:last-child { border-right: 0; }
40
+ .seg button.on { background: var(--gold); font-weight: bold; }
41
+ .chk { font-size: 14px; display: inline-flex; gap: 6px; align-items: center; cursor: pointer; margin-top: 10px; }
42
+ .chapters { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
43
+ .chapters button { border: 2px solid var(--ink); border-radius: 20px; background: #fff; padding: 5px 12px; font-size: 13px; cursor: pointer; }
44
+ .chapters button.on { background: var(--ink); color: var(--gold); }
45
+ .chapters button small { opacity: .6; margin-right: 4px; }
46
+ .grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; margin: 14px 0 4px; }
47
+ .lbl { font-size: 13px; color: #7a6a5a; margin-bottom: 6px; }
48
+ .primary { background: var(--red); color: #fff; border: 3px solid var(--ink); border-radius: 14px; padding: 12px 26px; font-size: 17px; font-weight: bold; cursor: pointer; box-shadow: 3px 4px 0 var(--ink); }
49
+ .primary:disabled { opacity: .5; cursor: not-allowed; }
50
+ .ghost, .btn { background: #fff; border: 2px solid var(--ink); border-radius: 10px; padding: 8px 14px; font-size: 14px; cursor: pointer; color: var(--ink); text-decoration: none; display: inline-block; }
51
+ .btn:hover, .ghost:hover { background: var(--gold); }
52
+ .btn.small { font-size: 13px; padding: 4px 10px; font-family: sans-serif; }
53
+ .progress { position: relative; height: 30px; background: #eee2c8; border: 3px solid var(--ink); border-radius: 12px; overflow: hidden; margin-top: 14px; }
54
+ .progress i { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: repeating-linear-gradient(45deg, var(--green), var(--green) 14px, #4fcb7e 14px, #4fcb7e 28px); }
55
+ .progress span { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: bold; }
56
+ #expResult { margin-top: 12px; }
57
+ #expResult video { width: 100%; max-height: 60vh; border-radius: 10px; border: 3px solid var(--ink); background: #000; margin-top: 10px; }
58
+ .note { margin-top: 12px; font-size: 13px; color: #7a6a5a; line-height: 1.7; }
59
+ .note.warn { color: #b3261e; }
60
+ .covers { display: flex; gap: 18px; flex-wrap: wrap; align-items: flex-start; }
61
+ .covers figure { margin: 0; flex: 2 1 420px; }
62
+ .covers figure.tall { flex: 1 1 200px; max-width: 300px; }
63
+ .covers img { width: 100%; border: 3px solid var(--ink); border-radius: 12px; display: block; background: #eee; }
64
+ .covers figcaption { font-size: 14px; margin-top: 8px; line-height: 2.2; }
65
+ .pub-field { border: 2px dashed #c9b9a5; border-radius: 12px; padding: 10px 12px; margin-top: 12px; background: #fff; }
66
+ .pub-field header { display: flex; justify-content: space-between; align-items: center; font-weight: bold; font-size: 14px; margin-bottom: 6px; }
67
+ .pub-field pre { white-space: pre-wrap; word-break: break-word; margin: 0; font-family: inherit; font-size: 14px; line-height: 1.7; cursor: copy; }
68
+ .pub-field .cnt { font-weight: normal; color: #998; font-size: 12px; margin-left: 8px; }
69
+ .script h3 { font-family: KuaiLe, sans-serif; font-weight: normal; margin: 16px 0 6px; font-size: 20px; }
70
+ .script p { margin: 4px 0; line-height: 1.8; font-size: 15px; }
71
+ .script .who { display: inline-block; font-size: 12px; padding: 0 8px; border-radius: 8px; margin-right: 6px; color: #fff; }
72
+ .script .who.m { background: #d9a400; } .script .who.a { background: #2c9c5c; }
73
+ .script .ts { color: #aa9; font-size: 12px; margin-right: 6px; font-variant-numeric: tabular-nums; cursor: pointer; }
74
+ footer { text-align: center; font-size: 12px; color: #7a6a5a; padding: 0 14px 30px; }
75
+ .toast { position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%) translateY(120px); background: var(--ink); color: #fff; padding: 10px 20px; border-radius: 20px; transition: transform .25s; z-index: 50; font-size: 14px; }
76
+ .toast.show { transform: translateX(-50%) translateY(0); }
77
+ .stage:fullscreen { max-height: none; background: #000; display: flex; align-items: center; justify-content: center; }
78
+ .stage:fullscreen canvas { width: auto; height: auto; max-width: 100vw; max-height: 100vh; aspect-ratio: inherit; }
79
+ @media (max-width: 640px) {
80
+ nav { display: none; }
81
+ .brand h1 { font-size: 24px; }
82
+ .stage.portrait { height: 70vh; }
83
+ .time { font-size: 12px; }
84
+ .icon { width: 38px; height: 38px; }
85
+ }
shouhouzi/fonts/kuaile.woff2 CHANGED
Binary files a/shouhouzi/fonts/kuaile.woff2 and b/shouhouzi/fonts/kuaile.woff2 differ
 
shouhouzi/index.html CHANGED
@@ -1 +1,127 @@
1
- <!doctype html><meta charset=utf-8><title>收猴子的故事</title><p>制作中……</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
+ <title>收猴子的故事 · 经济学寓言动画</title>
7
+ <meta name="description" content="用手绘动画讲解经济学寓言“收猴子的故事”,支持网页播放、浏览器本地导出 MP4(横屏B站 / 竖屏抖音,24/30/60fps)。">
8
+ <meta property="og:image" content="covers/cover-landscape.png">
9
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🐒</text></svg>">
10
+ <link rel="stylesheet" href="css/style.css">
11
+ </head>
12
+ <body>
13
+ <header class="top">
14
+ <div class="brand"><span class="logo">🐒</span><div><h1>收猴子的故事</h1><p>一个寓言,看懂所有泡沫 · 手绘动画 · 网页即播 · 本地导出 MP4</p></div></div>
15
+ <nav><a href="#player">播放</a><a href="#export">导出视频</a><a href="#covers">封面</a><a href="#publish">发布文案</a><a href="#script">完整文案</a></nav>
16
+ </header>
17
+
18
+ <main>
19
+ <!-- 播放器 -->
20
+ <section id="player" class="card">
21
+ <div class="stage-wrap" id="stageWrap">
22
+ <div class="stage" id="stage">
23
+ <canvas id="view"></canvas>
24
+ <div class="overlay" id="loading"><div class="spinner"></div><div id="loadingText">正在加载字体与配音……</div><div class="bar"><i id="loadingBar"></i></div></div>
25
+ <button class="bigplay hidden" id="bigPlay" aria-label="播放">▶</button>
26
+ </div>
27
+ </div>
28
+ <div class="controls">
29
+ <button id="btnPlay" class="icon" title="播放/暂停(空格)">▶</button>
30
+ <span class="time"><b id="tCur">00:00</b> / <span id="tTot">00:00</span></span>
31
+ <div class="seek" id="seek"><div class="seek-fill" id="seekFill"></div><div class="marks" id="marks"></div><div class="knob" id="knob"></div></div>
32
+ <button id="btnMute" class="icon" title="静音">🔊</button>
33
+ <button id="btnFull" class="icon" title="全屏">⛶</button>
34
+ </div>
35
+ <div class="row opts">
36
+ <div class="seg" id="modeSeg">
37
+ <button data-mode="landscape" class="on">横屏 16:9 · B站</button>
38
+ <button data-mode="portrait">竖屏 9:16 · 抖音</button>
39
+ </div>
40
+ <label class="chk"><input type="checkbox" id="chkGuides"> 显示手机安全区(刘海 / 底部文案 / 右侧按钮)</label>
41
+ </div>
42
+ <div class="chapters" id="chapters"></div>
43
+ </section>
44
+
45
+ <!-- 导出 -->
46
+ <section id="export" class="card">
47
+ <h2>🎬 导出 MP4(浏览器本地渲染)</h2>
48
+ <p class="muted">逐帧离线渲染 + 硬件编码,画面与音轨和网页预览完全一致;文件不会上传到任何服务器。推荐使用 <b>桌面版 Chrome / Edge 最新版</b>。</p>
49
+ <div class="grid2">
50
+ <div>
51
+ <div class="lbl">画幅</div>
52
+ <div class="seg" id="expMode">
53
+ <button data-v="landscape" class="on">横屏 1920×1080(哔哩哔哩)</button>
54
+ <button data-v="portrait">竖屏 1080×1920(抖音)</button>
55
+ </div>
56
+ </div>
57
+ <div>
58
+ <div class="lbl">帧率</div>
59
+ <div class="seg" id="expFps">
60
+ <button data-v="24">24 fps</button>
61
+ <button data-v="30" class="on">30 fps</button>
62
+ <button data-v="60">60 fps</button>
63
+ </div>
64
+ </div>
65
+ <div>
66
+ <div class="lbl">画质</div>
67
+ <div class="seg" id="expQ">
68
+ <button data-v="std" class="on">标准</button>
69
+ <button data-v="high">高码率</button>
70
+ </div>
71
+ </div>
72
+ <div>
73
+ <div class="lbl">范围</div>
74
+ <div class="seg" id="expRange">
75
+ <button data-v="full" class="on">完整视频</button>
76
+ <button data-v="test">前 15 秒(快速测试)</button>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ <label class="chk" id="diskRow"><input type="checkbox" id="chkDisk"> 边渲染边写入磁盘(省内存,适合 60fps / 低内存设备)</label>
81
+ <div class="row">
82
+ <button class="primary" id="btnExport">开始导出 MP4</button>
83
+ <button class="ghost hidden" id="btnCancel">取消</button>
84
+ <span class="muted" id="expInfo"></span>
85
+ </div>
86
+ <div class="progress hidden" id="expProg"><i id="expBar"></i><span id="expTxt"></span></div>
87
+ <div id="expResult"></div>
88
+ <div class="note" id="supportNote"></div>
89
+ </section>
90
+
91
+ <!-- 封面 -->
92
+ <section id="covers" class="card">
93
+ <h2>🖼️ 封面图</h2>
94
+ <p class="muted">已生成静态 PNG,也可以在浏览器里用动画引擎实时重绘后下载(与视频画风一致)。</p>
95
+ <div class="covers">
96
+ <figure><img src="covers/cover-landscape.png" alt="横屏封面" loading="lazy"><figcaption>横屏 1920×1080 · 哔哩哔哩<br><a class="btn" href="covers/cover-landscape.png" download="收猴子的故事-横屏封面.png">下载 PNG</a> <button class="btn" data-cover="landscape">重新绘制并下载</button></figcaption></figure>
97
+ <figure class="tall"><img src="covers/cover-portrait.png" alt="竖屏封面" loading="lazy"><figcaption>竖屏 1080×1920 · 抖音<br><a class="btn" href="covers/cover-portrait.png" download="收猴子的故事-竖屏封面.png">下载 PNG</a> <button class="btn" data-cover="portrait">重新绘制并下载</button></figcaption></figure>
98
+ </div>
99
+ </section>
100
+
101
+ <!-- 发布文案 -->
102
+ <section id="publish" class="card">
103
+ <h2>📣 发布文案(点击即可复制)</h2>
104
+ <div class="seg" id="pubTabs"><button data-v="douyin" class="on">抖音</button><button data-v="bilibili">哔哩哔哩</button></div>
105
+ <div id="pubBody"></div>
106
+ </section>
107
+
108
+ <!-- 完整文案 -->
109
+ <section id="script" class="card">
110
+ <h2>📝 完整旁白文案 <button class="btn small" id="copyScript">复制全文</button></h2>
111
+ <div id="scriptBody" class="script"></div>
112
+ </section>
113
+ </main>
114
+ <footer>旁白:微软 Edge 神经网络语音合成 · 字体:站酷快乐体(SIL OFL)· 画面、音乐与音效均由代码实时生成</footer>
115
+ <div class="toast" id="toast"></div>
116
+
117
+ <script src="vendor/mp4-muxer.js"></script>
118
+ <script src="js/script-data.js"></script>
119
+ <script src="js/draw.js"></script>
120
+ <script src="js/scenes.js"></script>
121
+ <script src="js/engine.js"></script>
122
+ <script src="js/audio.js"></script>
123
+ <script src="js/exporter.js"></script>
124
+ <script src="js/publish.js"></script>
125
+ <script src="js/app.js"></script>
126
+ </body>
127
+ </html>
shouhouzi/js/app.js ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================
2
+ * app.js —— 播放器 / 导出 / 封面 / 发布文案 UI
3
+ * ========================================================= */
4
+ (function () {
5
+ const $ = (s) => document.querySelector(s);
6
+ const $$ = (s) => [...document.querySelectorAll(s)];
7
+ const view = $('#view'), stage = $('#stage');
8
+ const vctx = view.getContext('2d', { alpha: false });
9
+ const mmss = PUBLISH.mmss;
10
+ const state = { mode: 'landscape', t: 0, playing: false, guides: false, soundtrack: null, actx: null, src: null, gain: null, startAt: 0, clock0: 0, muted: false, ready: false, dirty: true, poster: true };
11
+
12
+ function toast(msg) { const el = $('#toast'); el.textContent = msg; el.classList.add('show'); clearTimeout(toast.h); toast.h = setTimeout(() => el.classList.remove('show'), 1800); }
13
+ function segment(sel, cb) {
14
+ $$(sel + ' button').forEach((b) => b.addEventListener('click', () => { $$(sel + ' button').forEach((x) => x.classList.remove('on')); b.classList.add('on'); cb(b.dataset.v || b.dataset.mode); }));
15
+ }
16
+ const segVal = (sel) => { const b = $(sel + ' button.on'); return b && (b.dataset.v || b.dataset.mode); };
17
+
18
+ /* ---------- 画布尺寸(预览自适应分辨率) ---------- */
19
+ function resize() {
20
+ const L = LAYOUTS[state.mode];
21
+ stage.classList.toggle('portrait', state.mode === 'portrait');
22
+ const cssW = stage.clientWidth || 800;
23
+ const dpr = Math.min(2, window.devicePixelRatio || 1);
24
+ const scale = Math.min(1, (cssW * dpr) / L.W);
25
+ state.scale = Math.max(0.3, scale);
26
+ view.width = Math.round(L.W * state.scale); view.height = Math.round(L.H * state.scale);
27
+ state.dirty = true;
28
+ }
29
+
30
+ /* ---------- 时钟 ---------- */
31
+ function now() {
32
+ if (!state.playing) return state.t;
33
+ if (state.actx && state.src) return state.startAt + (state.actx.currentTime - state.clock0);
34
+ return state.startAt + (performance.now() - state.clock0) / 1000;
35
+ }
36
+ function ensureCtx() {
37
+ if (!state.actx) { const AC = window.AudioContext || window.webkitAudioContext; try { state.actx = new AC({ sampleRate: 48000 }); } catch (e) { state.actx = new AC(); } state.gain = state.actx.createGain(); state.gain.connect(state.actx.destination); state.gain.gain.value = state.muted ? 0 : 1; }
38
+ if (state.actx.state === 'suspended') state.actx.resume();
39
+ }
40
+ function play() {
41
+ if (state.t >= TL.total - 0.05) state.t = 0;
42
+ ensureCtx();
43
+ state.playing = true; state.startAt = state.t;
44
+ if (state.soundtrack) {
45
+ state.src = state.actx.createBufferSource(); state.src.buffer = state.soundtrack; state.src.connect(state.gain);
46
+ state.clock0 = state.actx.currentTime + 0.03; state.src.start(state.clock0, state.t);
47
+ } else { state.src = null; state.clock0 = performance.now(); }
48
+ $('#btnPlay').textContent = '❚❚'; $('#bigPlay').classList.add('hidden');
49
+ }
50
+ function pause() {
51
+ state.t = Math.min(TL.total, now()); state.playing = false;
52
+ if (state.src) { try { state.src.stop(); } catch (e) { /* */ } state.src = null; }
53
+ $('#btnPlay').textContent = '▶'; state.dirty = true;
54
+ }
55
+ function seek(t) { const was = state.playing; if (was) pause(); state.t = Math.max(0, Math.min(TL.total - 0.01, t)); state.dirty = true; if (was) play(); }
56
+ function toggle() { state.playing ? pause() : play(); }
57
+
58
+ /* ---------- 主循环 ---------- */
59
+ let lastScene = -1;
60
+ function loop() {
61
+ if (state.playing) { state.t = now(); if (state.t >= TL.total) { state.t = TL.total - 0.001; pause(); } state.dirty = true; }
62
+ if (state.dirty && state.ready) {
63
+ if (state.poster && !state.playing && state.t === 0) ENGINE.renderCover(vctx, LAYOUTS[state.mode], state.scale);
64
+ else { state.poster = false; ENGINE.render(vctx, state.t, LAYOUTS[state.mode], { guides: state.guides, scale: state.scale }); }
65
+ state.dirty = false;
66
+ const p = state.t / TL.total;
67
+ $('#seekFill').style.width = (p * 100) + '%'; $('#knob').style.left = (p * 100) + '%';
68
+ $('#tCur').textContent = mmss(state.t);
69
+ const si = TL.at(state.t).sc.index;
70
+ if (si !== lastScene) { lastScene = si; $$('#chapters button').forEach((b, i) => b.classList.toggle('on', i === si)); }
71
+ }
72
+ requestAnimationFrame(loop);
73
+ }
74
+
75
+ /* ---------- 进度条拖动 ---------- */
76
+ function initSeek() {
77
+ const el = $('#seek');
78
+ const at = (e) => { const r = el.getBoundingClientRect(); return (Math.max(0, Math.min(1, (e.clientX - r.left) / r.width))) * TL.total; };
79
+ let drag = false, wasPlaying = false;
80
+ el.addEventListener('pointerdown', (e) => { drag = true; wasPlaying = state.playing; if (wasPlaying) pause(); el.setPointerCapture(e.pointerId); state.t = at(e); state.dirty = true; });
81
+ el.addEventListener('pointermove', (e) => { if (drag) { state.t = at(e); state.dirty = true; } });
82
+ el.addEventListener('pointerup', () => { drag = false; if (wasPlaying) play(); });
83
+ $('#marks').innerHTML = TL.scenes.map((s) => `<i style="left:${s.start / TL.total * 100}%"></i>`).join('');
84
+ $('#tTot').textContent = mmss(TL.total);
85
+ $('#chapters').innerHTML = TL.scenes.map((s, i) => `<button data-i="${i}"><small>${mmss(s.start)}</small>${i === 0 ? '开场' : s.title}</button>`).join('');
86
+ $$('#chapters button').forEach((b) => b.addEventListener('click', () => seek(TL.scenes[+b.dataset.i].start + 0.01)));
87
+ }
88
+
89
+ /* ---------- 控件 ---------- */
90
+ function initControls() {
91
+ $('#btnPlay').addEventListener('click', toggle);
92
+ $('#bigPlay').addEventListener('click', toggle);
93
+ view.addEventListener('click', toggle);
94
+ $('#btnMute').addEventListener('click', () => { state.muted = !state.muted; if (state.gain) state.gain.gain.value = state.muted ? 0 : 1; $('#btnMute').textContent = state.muted ? '🔇' : '🔊'; });
95
+ $('#btnFull').addEventListener('click', () => { if (document.fullscreenElement) document.exitFullscreen(); else (stage.requestFullscreen || stage.webkitRequestFullscreen || (() => {})).call(stage); });
96
+ document.addEventListener('keydown', (e) => {
97
+ if (/INPUT|TEXTAREA/.test(e.target.tagName)) return;
98
+ if (e.code === 'Space') { e.preventDefault(); toggle(); }
99
+ if (e.code === 'ArrowRight') seek(state.t + 5);
100
+ if (e.code === 'ArrowLeft') seek(state.t - 5);
101
+ });
102
+ segment('#modeSeg', (m) => { state.mode = m; resize(); });
103
+ $('#chkGuides').addEventListener('change', (e) => { state.guides = e.target.checked; state.dirty = true; });
104
+ window.addEventListener('resize', resize);
105
+ document.addEventListener('fullscreenchange', resize);
106
+ }
107
+
108
+ /* ---------- 导出 ---------- */
109
+ let abortFlag = null;
110
+ function initExport() {
111
+ segment('#expMode', () => {}); segment('#expFps', () => {}); segment('#expQ', () => {}); segment('#expRange', () => {});
112
+ const sup = EXPORTER.supportInfo();
113
+ if (!sup.fsapi) $('#diskRow').classList.add('hidden');
114
+ $('#supportNote').innerHTML = sup.webcodecs
115
+ ? '✅ 当前浏览器支持 WebCodecs 本地编码。导出时间约为视频时长的 0.5~2 倍(取决于电脑性能与帧率),导出期间请保持此标签页在前台。<br>📌 抖音竖屏:画面重要信息已避开顶部刘海/状态栏、底部文案与右侧按钮区;B站横屏:1920×1080,可直接投稿。'
116
+ : '⚠️ 当前浏览器不支持 WebCodecs(VideoEncoder),无法本地导出 MP4。请使用桌面版 Chrome 94+ / Edge 94+(Safari 16.4+ 部分支持)。网页播放不受影响。';
117
+ if (!sup.webcodecs) { $('#supportNote').classList.add('warn'); $('#btnExport').disabled = true; }
118
+ $('#btnExport').addEventListener('click', async () => {
119
+ const mode = segVal('#expMode'), fps = +segVal('#expFps'), q = segVal('#expQ'), range = segVal('#expRange');
120
+ const L = LAYOUTS[mode];
121
+ const base = { 24: 7, 30: 8, 60: 12 }[fps] * 1e6;
122
+ const bitrate = q === 'high' ? base * 1.8 : base;
123
+ if (!state.soundtrack) { toast('音轨还在生成,请稍候……'); return; }
124
+ if (state.playing) pause();
125
+ abortFlag = { aborted: false };
126
+ $('#btnExport').disabled = true; $('#btnCancel').classList.remove('hidden'); $('#expProg').classList.remove('hidden'); $('#expResult').innerHTML = '';
127
+ const filename = `收猴子的故事-${mode === 'landscape' ? 'B站横屏1920x1080' : '抖音竖屏1080x1920'}-${fps}fps${range === 'test' ? '-测试片段' : ''}.mp4`;
128
+ const t0 = performance.now();
129
+ try {
130
+ const res = await EXPORTER.exportMP4({
131
+ mode, fps, bitrate, audio: state.soundtrack, signal: abortFlag, filename,
132
+ range: range === 'test' ? [0, 15] : [0, TL.total],
133
+ saveToDisk: $('#chkDisk').checked,
134
+ onProgress: (p, info) => {
135
+ $('#expBar').style.width = (p * 100).toFixed(1) + '%';
136
+ $('#expTxt').textContent = `${(p * 100).toFixed(1)}% 第 ${info.frame}/${info.total} 帧 已用 ${mmss(info.elapsed)} 剩余约 ${mmss(info.eta)}`;
137
+ $('#expInfo').textContent = `${L.W}×${L.H} · ${fps}fps · 视频 ${info.codec} · 音频 ${info.audio}`;
138
+ },
139
+ });
140
+ const secs = ((performance.now() - t0) / 1000).toFixed(0);
141
+ if (res.savedToDisk) { $('#expResult').innerHTML = `<p>✅ 已直接保存到磁盘:<b>${filename}</b>(用时 ${secs}s)</p>`; }
142
+ else {
143
+ const url = URL.createObjectURL(res.blob);
144
+ const mb = (res.blob.size / 1048576).toFixed(1);
145
+ const warn = /^vp09/.test(res.codec) ? '<p class="note warn">⚠️ 当前浏览器没有 H.264 编码器,已改用 VP9 编码的 MP4。B站可正常投稿;抖音建议改用桌面版 Chrome / Edge 重新导出 H.264 版本。</p>' : '';
146
+ $('#expResult').innerHTML = `<p>✅ 导出完成:<b>${filename}</b>(${mb} MB,用时 ${secs}s,视频 ${res.codec},音频 ${res.audio || '无'}) <a class="btn" href="${url}" download="${filename}">⬇ 下载 MP4</a></p>${warn}<video src="${url}" controls playsinline></video>`;
147
+ const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove();
148
+ }
149
+ toast('导出完成!');
150
+ } catch (e) {
151
+ console.error(e);
152
+ $('#expResult').innerHTML = `<p class="note warn">❌ ${e.message || e}</p>`;
153
+ } finally {
154
+ $('#btnExport').disabled = false; $('#btnCancel').classList.add('hidden');
155
+ }
156
+ });
157
+ $('#btnCancel').addEventListener('click', () => { if (abortFlag) abortFlag.aborted = true; });
158
+ }
159
+
160
+ /* ---------- 封面 ---------- */
161
+ function initCovers() {
162
+ $$('[data-cover]').forEach((b) => b.addEventListener('click', () => {
163
+ const L = LAYOUTS[b.dataset.cover];
164
+ const c = document.createElement('canvas'); c.width = L.W; c.height = L.H;
165
+ ENGINE.renderCover(c.getContext('2d'), L);
166
+ c.toBlob((blob) => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `收猴子的故事-${b.dataset.cover === 'landscape' ? '横屏' : '竖屏'}封面.png`; a.click(); }, 'image/png');
167
+ }));
168
+ }
169
+
170
+ /* ---------- 发布文案 ---------- */
171
+ async function copy(text) {
172
+ try { await navigator.clipboard.writeText(text); }
173
+ catch (e) { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
174
+ toast('已复制到剪贴板 ✓');
175
+ }
176
+ function initPublish() {
177
+ const P = PUBLISH.build();
178
+ const show = (k) => {
179
+ const d = P[k];
180
+ $('#pubBody').innerHTML = `<p class="muted">${d.name}</p>` + d.fields.map((f, i) => `<div class="pub-field"><header><span>${f.k}<span class="cnt">${[...f.v].length} 字</span></span><button class="btn small" data-i="${i}">复制</button></header><pre data-i="${i}"></pre></div>`).join('') + `<div class="row"><button class="primary" id="copyAll">复制全部</button></div>`;
181
+ $$('#pubBody pre').forEach((p) => { p.textContent = d.fields[+p.dataset.i].v; p.addEventListener('click', () => copy(p.textContent)); });
182
+ $$('#pubBody header button').forEach((b) => b.addEventListener('click', () => copy(d.fields[+b.dataset.i].v)));
183
+ $('#copyAll').addEventListener('click', () => copy(d.fields.map((f) => `【${f.k}】\n${f.v}`).join('\n\n')));
184
+ };
185
+ segment('#pubTabs', show); show('douyin');
186
+ }
187
+
188
+ /* ---------- 完整文案 ---------- */
189
+ function initScript() {
190
+ let html = '', plain = '';
191
+ TL.scenes.forEach((s, i) => {
192
+ html += `<h3>${i === 0 ? '开场' : '第' + i + '幕'}|${s.title}</h3>`;
193
+ plain += `\n【${i === 0 ? '开场' : '第' + i + '幕'}|${s.title}】\n`;
194
+ s.lines.forEach((l, j) => {
195
+ const who = l.v === 'm' ? '<span class="who m">商人</span>' : l.v === 'a' ? '<span class="who a">助手</span>' : '';
196
+ html += `<p><span class="ts" data-t="${s.start + s.L[j]}">${mmss(s.start + s.L[j])}</span>${who}${l.s.replace(/</g, '&lt;')}</p>`;
197
+ plain += (l.v === 'm' ? '商人:' : l.v === 'a' ? '助手:' : '') + l.s + '\n';
198
+ });
199
+ });
200
+ $('#scriptBody').innerHTML = html;
201
+ $$('#scriptBody .ts').forEach((el) => el.addEventListener('click', () => { seek(+el.dataset.t); $('#player').scrollIntoView(); }));
202
+ $('#copyScript').addEventListener('click', () => copy(plain.trim()));
203
+ }
204
+
205
+ /* ---------- 启动 ---------- */
206
+ async function boot() {
207
+ resize(); initSeek(); initControls(); initExport(); initCovers(); initPublish(); initScript();
208
+ requestAnimationFrame(loop);
209
+ const setL = (p, txt) => { $('#loadingBar').style.width = (p * 100).toFixed(0) + '%'; $('#loadingText').textContent = txt; };
210
+ setL(0.02, '正在加载字体……');
211
+ try { await Promise.race([document.fonts.load('40px KuaiLe'), new Promise((r) => setTimeout(r, 6000))]); } catch (e) { /* */ }
212
+ state.ready = true; state.dirty = true;
213
+ $('#loading').classList.add('hidden'); $('#bigPlay').classList.remove('hidden');
214
+ // 后台生成音轨(期间可静音预览)
215
+ const tag = $('#expInfo');
216
+ try {
217
+ tag.textContent = '音轨生成中……';
218
+ const buf = await AUDIO.renderSoundtrack((p, s) => { tag.textContent = `音轨生成中:${s} ${(p * 100).toFixed(0)}%`; });
219
+ state.soundtrack = buf;
220
+ tag.textContent = '音轨已就绪 ✓';
221
+ if (state.playing) { pause(); play(); }
222
+ } catch (e) {
223
+ console.error(e); tag.textContent = '音轨生成失败:' + e.message;
224
+ }
225
+ }
226
+ boot();
227
+ window.APP = state;
228
+ })();
shouhouzi/js/audio.js ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================
2
+ * audio.js —— 程序化配乐 + 合成音效 + 旁白混音
3
+ * 用 OfflineAudioContext 一次性渲染整条音轨(48kHz 立体声),
4
+ * 预览播放与 MP4 导出共用同一个 AudioBuffer,保证音画同步。
5
+ * ========================================================= */
6
+ (function () {
7
+ const SR = 48000;
8
+ const midi = (m) => 440 * Math.pow(2, (m - 69) / 12);
9
+ const H = (n) => D.hash(n);
10
+
11
+ /* ---------- 乐器 ---------- */
12
+ function makeKit(ctx, dest) {
13
+ const noiseBuf = ctx.createBuffer(1, SR * 2, SR);
14
+ const nd = noiseBuf.getChannelData(0);
15
+ for (let i = 0; i < nd.length; i++) nd[i] = H(i * 0.123) * 2 - 1;
16
+ const K = { ctx, dest };
17
+ function env(g, t, a, peak, d, sustain = 0.0001) {
18
+ g.gain.setValueAtTime(0.0001, t);
19
+ g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t + a);
20
+ g.gain.exponentialRampToValueAtTime(Math.max(0.0001, sustain), t + a + d);
21
+ }
22
+ function out(node, pan = 0, bus = dest) {
23
+ if (pan && ctx.createStereoPanner) { const p = ctx.createStereoPanner(); p.pan.value = pan; node.connect(p); p.connect(bus); }
24
+ else node.connect(bus);
25
+ }
26
+ function noise(t, dur, type, freq, q, peak, a = 0.003, pan = 0, bus = dest, freqEnd = null) {
27
+ const s = ctx.createBufferSource(); s.buffer = noiseBuf; s.loop = true;
28
+ const f = ctx.createBiquadFilter(); f.type = type; f.frequency.setValueAtTime(freq, t); f.Q.value = q;
29
+ if (freqEnd) f.frequency.exponentialRampToValueAtTime(freqEnd, t + dur);
30
+ const g = ctx.createGain(); env(g, t, a, peak, dur);
31
+ s.connect(f); f.connect(g); out(g, pan, bus);
32
+ s.start(t, H(t) * 1.5); s.stop(t + a + dur + 0.05);
33
+ }
34
+ function tone(t, freq, dur, peak, type = 'sine', a = 0.004, pan = 0, bus = dest, freqEnd = null, lp = null) {
35
+ const o = ctx.createOscillator(); o.type = type; o.frequency.setValueAtTime(freq, t);
36
+ if (freqEnd) o.frequency.exponentialRampToValueAtTime(freqEnd, t + dur);
37
+ const g = ctx.createGain(); env(g, t, a, peak, dur);
38
+ let n = o;
39
+ if (lp) { const f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = lp; o.connect(f); n = f; }
40
+ n.connect(g); out(g, pan, bus);
41
+ o.start(t); o.stop(t + a + dur + 0.05);
42
+ return o;
43
+ }
44
+ K.noise = noise; K.tone = tone;
45
+ // —— 音乐乐器 ——
46
+ K.kick = (t, v = 0.9, bus) => { tone(t, 150, 0.16, v, 'sine', 0.002, 0, bus, 42); };
47
+ K.snare = (t, v = 0.4, bus) => { noise(t, 0.16, 'bandpass', 1900, 0.8, v, 0.002, 0, bus); tone(t, 210, 0.07, v * 0.5, 'triangle', 0.002, 0, bus); };
48
+ K.hat = (t, v = 0.12, bus, pan = 0.2) => noise(t, 0.04, 'highpass', 7500, 0.7, v, 0.001, pan, bus);
49
+ K.shaker = (t, v = 0.07, bus) => noise(t, 0.06, 'bandpass', 5200, 1.2, v, 0.012, -0.25, bus);
50
+ K.bass = (t, m, dur, v = 0.35, bus) => { tone(t, midi(m), dur, v, 'triangle', 0.01, 0, bus, null, 900); tone(t, midi(m - 12), dur, v * 0.6, 'sine', 0.01, 0, bus); };
51
+ K.pluck = (t, m, dur, v = 0.12, bus, pan = 0) => { tone(t, midi(m), dur, v, 'triangle', 0.003, pan, bus, null, 3200); tone(t, midi(m) * 2.005, dur * 0.4, v * 0.25, 'sine', 0.003, pan, bus); };
52
+ K.marimba = (t, m, v = 0.16, bus, pan = 0) => { tone(t, midi(m), 0.45, v, 'sine', 0.002, pan, bus); tone(t, midi(m) * 4, 0.08, v * 0.35, 'sine', 0.001, pan, bus); };
53
+ K.pizz = (t, m, v = 0.16, bus, pan = 0) => { tone(t, midi(m), 0.22, v, 'sawtooth', 0.002, pan, bus, null, 1400); };
54
+ K.epiano = (t, m, dur, v = 0.07, bus, pan = 0) => { tone(t, midi(m), dur, v, 'sine', 0.008, pan, bus); tone(t, midi(m) * 2, dur * 0.3, v * 0.3, 'sine', 0.004, pan, bus); tone(t, midi(m) * 1.003, dur, v * 0.5, 'triangle', 0.01, -pan, bus, null, 1600); };
55
+ K.pad = (t, ms, dur, v = 0.035, bus) => ms.forEach((m, i) => { [-6, 6].forEach((dt) => { const o = ctx.createOscillator(); o.type = 'sawtooth'; o.frequency.value = midi(m); o.detune.value = dt; const f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 900; const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.linearRampToValueAtTime(v, t + Math.min(0.8, dur * .3)); g.gain.setValueAtTime(v, t + dur * 0.75); g.gain.linearRampToValueAtTime(0.0001, t + dur); o.connect(f); f.connect(g); out(g, (i - 1) * 0.3, bus); o.start(t); o.stop(t + dur + 0.05); }); });
56
+ K.bell = (t, m, v = 0.12, bus, dur = 1.2) => { tone(t, midi(m), dur, v, 'sine', 0.002, 0, bus); tone(t, midi(m) * 2.76, dur * 0.4, v * 0.3, 'sine', 0.002, 0, bus); tone(t, midi(m) * 5.4, dur * 0.2, v * 0.15, 'sine', 0.002, 0, bus); };
57
+ return K;
58
+ }
59
+
60
+ /* ---------- 配乐 ---------- */
61
+ const MOODS = {
62
+ // root: 主音 MIDI;prog: 每小节和弦(相对主音的三和弦根音 + 类型)
63
+ bright: { bpm: 112, root: 60, prog: [[0, 'M'], [7, 'M'], [9, 'm'], [5, 'M']], drums: 'pop', lead: 'marimba', scale: [0, 2, 4, 7, 9], density: 0.55 },
64
+ tense: { bpm: 118, root: 57, prog: [[0, 'm'], [8, 'M'], [3, 'M'], [10, 'M']], drums: 'pop', lead: 'pluck', scale: [0, 3, 5, 7, 10], density: 0.5 },
65
+ drum: { bpm: 120, root: 57, prog: [[0, 'm'], [0, 'm'], [0, 'm'], [0, 'm']], drums: 'none', lead: 'none', scale: [0], density: 0 },
66
+ hype: { bpm: 128, root: 60, prog: [[0, 'M'], [7, 'M'], [9, 'm'], [5, 'M']], drums: 'four', lead: 'pluck', scale: [0, 2, 4, 7, 9], density: 0.7 },
67
+ sneaky: { bpm: 96, root: 62, prog: [[0, 'm'], [5, 'm'], [0, 'm'], [7, 'M']], drums: 'sneak', lead: 'pizz', scale: [0, 3, 5, 6, 7, 10], density: 0.45 },
68
+ frenzy: { bpm: 136, root: 62, prog: [[0, 'M'], [5, 'M'], [7, 'M'], [5, 'M']], drums: 'four', lead: 'marimba', scale: [0, 2, 4, 7, 9], density: 0.8 },
69
+ sad: { bpm: 68, root: 57, prog: [[0, 'm'], [8, 'M'], [3, 'M'], [7, 'M']], drums: 'none', lead: 'epiano', scale: [0, 3, 5, 7, 10], density: 0.3 },
70
+ think: { bpm: 84, root: 65, prog: [[0, 'M7'], [-1, 'm7'], [-3, 'm7'], [-5, 'M7']], drums: 'lofi', lead: 'epiano', scale: [0, 2, 4, 7, 9], density: 0.35 },
71
+ warm: { bpm: 104, root: 65, prog: [[0, 'M'], [2, 'm'], [5, 'M'], [7, 'M']], drums: 'pop', lead: 'marimba', scale: [0, 2, 4, 7, 9], density: 0.5 },
72
+ };
73
+ const CHORD = { M: [0, 4, 7], m: [0, 3, 7], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10] };
74
+
75
+ function musicPlan() {
76
+ const c = TL.cue;
77
+ return [
78
+ [0, c('hook', 6, -0.2), 'bright'],
79
+ [c('hook', 6, -0.2), c('village', -1, 0), 'warm'],
80
+ [c('village', -1, 0), c('twenty', 0, 0), 'bright'],
81
+ [c('twenty', 0, 0), c('fifty', -1, 0), 'tense'],
82
+ [c('fifty', -1, 0), c('fifty', 2, 0), 'drum'],
83
+ [c('fifty', 2, 0), c('deal', -1, 0), 'hype'],
84
+ [c('deal', -1, 0), c('frenzy', -1, 0), 'sneaky'],
85
+ [c('frenzy', -1, 0), c('gone', -1, 0), 'frenzy'],
86
+ [c('gone', -1, 0), c('ledger', -1, 0), 'sad'],
87
+ [c('ledger', -1, 0), c('check', -1, 0), 'think'],
88
+ [c('check', -1, 0), TL.total, 'warm'],
89
+ ];
90
+ }
91
+
92
+ function scheduleMusic(K, bus) {
93
+ musicPlan().forEach(([t0, t1, name], si) => {
94
+ const M = MOODS[name];
95
+ const beat = 60 / M.bpm, bar = beat * 4;
96
+ const nBars = Math.ceil((t1 - t0) / bar);
97
+ for (let b = 0; b < nBars; b++) {
98
+ const tb = t0 + b * bar;
99
+ if (tb >= t1) break;
100
+ const [deg, q] = M.prog[b % M.prog.length];
101
+ const root = M.root + deg;
102
+ const chord = CHORD[q].map((x) => root + x);
103
+ const barEnd = Math.min(t1, tb + bar);
104
+ const fadeOut = (t) => t < t1 - 0.05;
105
+ // 低音
106
+ if (name !== 'drum') {
107
+ if (name === 'sneaky') [0, 1, 2, 3].forEach((i) => { const t = tb + i * beat; if (fadeOut(t)) K.pizz(t, root - 12 + [0, 3, 7, 6][i], 0.2, bus); });
108
+ else if (name === 'sad') K.bass(tb, root - 12, bar * 0.95, 0.16, bus);
109
+ else [0, 2].forEach((i) => { const t = tb + i * beat * 2; if (fadeOut(t)) K.bass(t, root - 12 + (i === 1 && name !== 'think' ? 7 : 0), beat * 1.8, name === 'think' ? 0.2 : 0.24, bus); });
110
+ }
111
+ // 和声
112
+ if (name === 'sad' || name === 'think') K.pad(tb, chord.map((m) => m - (name === 'think' ? 12 : 0)), barEnd - tb, name === 'sad' ? 0.03 : 0.022, bus);
113
+ if (name === 'think') [0, 1.5, 2.5].forEach((x) => { const t = tb + x * beat; if (fadeOut(t)) chord.forEach((m, i) => K.epiano(t + i * 0.012, m, beat * 1.2, 0.035, bus, (i - 1.5) * 0.2)); });
114
+ if (['bright', 'warm', 'hype', 'frenzy', 'tense'].includes(name)) {
115
+ for (let i = 0; i < 8; i++) { if (i % 2 === 0 || name === 'frenzy' || name === 'hype') { const t = tb + i * beat / 2; if (!fadeOut(t)) continue; const m = chord[(i >> 1) % chord.length]; K.pluck(t, m, beat * 0.5, 0.045, bus, i % 2 ? 0.3 : -0.3); } }
116
+ }
117
+ // 鼓
118
+ const dr = M.drums;
119
+ for (let i = 0; i < 4; i++) {
120
+ const t = tb + i * beat; if (!fadeOut(t)) continue;
121
+ if (dr === 'pop') { if (i === 0 || i === 2) K.kick(t, 0.5, bus); if (i === 1 || i === 3) K.snare(t, 0.16, bus); K.hat(t + beat / 2, 0.06, bus); K.shaker(t, 0.04, bus); }
122
+ if (dr === 'four') { K.kick(t, 0.6, bus); if (i % 2) K.snare(t, 0.18, bus); K.hat(t + beat / 2, 0.09, bus); K.hat(t + beat / 4 * 3, 0.04, bus, -0.2); }
123
+ if (dr === 'lofi') { if (i === 0 || (i === 2 && b % 2)) K.kick(t, 0.38, bus); if (i === 1 || i === 3) K.snare(t, 0.08, bus); K.hat(t + beat * 0.58, 0.04, bus); K.hat(t, 0.03, bus, -0.2); }
124
+ if (dr === 'sneak') { if (i === 0) K.kick(t, 0.3, bus); K.hat(t + beat * 0.5, 0.05, bus); if (i === 3) K.noise(t, 0.05, 'bandpass', 3000, 2, 0.06, 0.002, 0.3, bus); }
125
+ }
126
+ // 旋律(确定性"随机")
127
+ if (M.lead !== 'none') {
128
+ const steps = name === 'sad' ? 4 : 8;
129
+ for (let i = 0; i < steps; i++) {
130
+ const seed = si * 1000 + b * 16 + i;
131
+ if (H(seed) > M.density) continue;
132
+ const t = tb + i * bar / steps; if (!fadeOut(t)) continue;
133
+ const deg2 = M.scale[Math.floor(H(seed * 3.1) * M.scale.length)];
134
+ const oct = H(seed * 7.7) > 0.7 ? 24 : 12;
135
+ const m = M.root + deg2 + oct;
136
+ const pan = (H(seed * 5.5) - 0.5) * 0.6;
137
+ if (M.lead === 'marimba') K.marimba(t, m, 0.07, bus, pan);
138
+ else if (M.lead === 'pluck') K.pluck(t, m, 0.25, 0.05, bus, pan);
139
+ else if (M.lead === 'pizz') K.pizz(t, m, 0.07, bus, pan);
140
+ else if (M.lead === 'epiano') K.epiano(t, m, 0.9, 0.04, bus, pan);
141
+ }
142
+ }
143
+ }
144
+ });
145
+ }
146
+
147
+ /* ---------- 音效 ---------- */
148
+ function makeSfx(K, bus) {
149
+ const { tone, noise } = K;
150
+ const S = {
151
+ pop: (t, v = 0.35) => tone(t, 380, 0.09, v, 'sine', 0.002, 0, bus, 980),
152
+ click: (t) => { noise(t, 0.02, 'highpass', 3000, 1, 0.4, 0.001, 0, bus); tone(t, 1800, 0.03, 0.15, 'square', 0.001, 0, bus, null, 4000); },
153
+ coin: (t, v = 0.22) => { tone(t, 1975, 0.07, v, 'square', 0.001, 0.1, bus, null, 5000); tone(t + 0.07, 2637, 0.32, v, 'square', 0.001, 0.1, bus, null, 5000); },
154
+ coins: (t, n = 6) => { for (let i = 0; i < n; i++) S.coin(t + i * 0.09 + H(t + i) * 0.04, 0.12); },
155
+ cash: (t) => { noise(t, 0.12, 'bandpass', 2500, 1, 0.3, 0.002, 0, bus); [88, 92, 95].forEach((m, i) => K.bell(t + 0.08 + i * 0.03, m, 0.1, bus, 0.8)); S.coins(t + 0.15, 4); },
156
+ whoosh: (t, d = 0.45, v = 0.28) => noise(t, d, 'bandpass', 300, 1.2, v, d * 0.5, 0, bus, 3200),
157
+ swish: (t) => noise(t, 0.25, 'bandpass', 2400, 2, 0.18, 0.08, 0, bus, 600),
158
+ boing: (t) => { const o = tone(t, 220, 0.5, 0.3, 'sine', 0.004, 0, bus, 520); const l = K.ctx.createOscillator(); l.frequency.value = 18; const g = K.ctx.createGain(); g.gain.value = 40; l.connect(g); g.connect(o.frequency); l.start(t); l.stop(t + 0.6); },
159
+ monkey: (t, n = 3) => {
160
+ for (let i = 0; i < n; i++) {
161
+ const tt = t + i * 0.16;
162
+ const o = K.ctx.createOscillator(); o.type = 'sawtooth';
163
+ o.frequency.setValueAtTime(520, tt); o.frequency.linearRampToValueAtTime(900, tt + 0.05); o.frequency.linearRampToValueAtTime(600, tt + 0.12);
164
+ const f = K.ctx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 1300; f.Q.value = 3;
165
+ const g = K.ctx.createGain(); g.gain.setValueAtTime(0.0001, tt); g.gain.exponentialRampToValueAtTime(0.25, tt + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, tt + 0.13);
166
+ o.connect(f); f.connect(g); g.connect(bus); o.start(tt); o.stop(tt + 0.15);
167
+ }
168
+ },
169
+ cheer: (t, d = 2.2, v = 0.16) => {
170
+ noise(t, d, 'bandpass', 1100, 0.6, v, 0.25, 0, bus);
171
+ for (let i = 0; i < 7; i++) { const tt = t + H(t * 3 + i) * 0.5; const f0 = 260 + H(i * 9 + t) * 260; const o = tone(tt, f0, d * 0.7, 0.025, 'sawtooth', 0.15, (H(i) - 0.5), bus, f0 * 1.3, 1800); }
172
+ },
173
+ drumroll: (t, d) => { let x = 0; while (x < d) { K.snare(t + x, 0.07 + 0.12 * (x / d), bus); x += Math.max(0.035, 0.11 - 0.07 * (x / d)); } },
174
+ crash: (t, v = 0.3) => noise(t, 1.6, 'highpass', 4500, 0.5, v, 0.002, 0, bus),
175
+ sting: (t) => { S.crash(t, 0.22); [60, 64, 67, 72].forEach((m, i) => { K.pluck(t + i * 0.06, m + 12, 0.9, 0.09, bus); }); K.kick(t, 0.8, bus); K.bell(t + 0.25, 84, 0.08, bus, 1.4); },
176
+ stamp: (t) => { tone(t, 120, 0.18, 0.6, 'sine', 0.002, 0, bus, 50); noise(t, 0.08, 'lowpass', 900, 0.7, 0.4, 0.002, 0, bus); },
177
+ flip: (t) => { for (let i = 0; i < 4; i++) noise(t + i * 0.05, 0.03, 'bandpass', 2600 + i * 300, 2, 0.2, 0.001, 0, bus); },
178
+ ding: (t, m = 91) => K.bell(t, m, 0.14, bus, 1.0),
179
+ sparkle: (t) => [84, 88, 91, 96, 100].forEach((m, i) => K.bell(t + i * 0.06, m, 0.05, bus, 0.5)),
180
+ trombone: (t, short = false) => {
181
+ const notes = short ? [55, 51] : [58, 57, 56, 55];
182
+ notes.forEach((m, i) => {
183
+ const dur = i === notes.length - 1 ? 1.4 : 0.42; const tt = t + i * 0.48;
184
+ const o = K.ctx.createOscillator(); o.type = 'sawtooth'; o.frequency.value = midi(m - 12);
185
+ const f = K.ctx.createBiquadFilter(); f.type = 'lowpass'; f.Q.value = 6; f.frequency.setValueAtTime(400, tt); f.frequency.linearRampToValueAtTime(1500, tt + 0.15); f.frequency.linearRampToValueAtTime(500, tt + dur);
186
+ const g = K.ctx.createGain(); g.gain.setValueAtTime(0.0001, tt); g.gain.exponentialRampToValueAtTime(0.18, tt + 0.05); g.gain.setValueAtTime(0.18, tt + dur - 0.1); g.gain.exponentialRampToValueAtTime(0.0001, tt + dur);
187
+ if (i === notes.length - 1) { const l = K.ctx.createOscillator(); l.frequency.value = 6; const lg = K.ctx.createGain(); lg.gain.value = 5; l.connect(lg); lg.connect(o.frequency); l.start(tt); l.stop(tt + dur); }
188
+ o.connect(f); f.connect(g); g.connect(bus); o.start(tt); o.stop(tt + dur + 0.05);
189
+ });
190
+ },
191
+ cricket: (t, d) => { for (let x = 0; x < d; x += 0.9) for (let k = 0; k < 3; k++) tone(t + x + k * 0.06, 4300, 0.035, 0.04, 'sine', 0.003, 0.4, bus); },
192
+ rain: (t, d) => noise(t, d, 'lowpass', 1500, 0.3, 0.07, 0.8, 0, bus),
193
+ wind: (t, d) => noise(t, d, 'bandpass', 500, 0.8, 0.08, d * 0.4, 0, bus, 900),
194
+ type: (t, n = 8) => { for (let i = 0; i < n; i++) noise(t + i * 0.085, 0.02, 'bandpass', 3200, 3, 0.18, 0.001, 0, bus); },
195
+ steps: (t, d, rate = 0.32, v = 0.18) => { for (let x = 0; x < d; x += rate) tone(t + x, 90, 0.06, v, 'sine', 0.002, x % (rate * 2) < rate ? -0.2 : 0.2, bus, 60); },
196
+ car: (t) => { [0, 0.22].forEach((x) => { tone(t + x, 440, 0.16, 0.12, 'square', 0.004, 0, bus, null, 2000); tone(t + x, 554, 0.16, 0.1, 'square', 0.004, 0, bus, null, 2000); }); const o = tone(t + 0.6, 60, 2.2, 0.18, 'sawtooth', 0.2, 0, bus, 140, 500); },
197
+ smash: (t) => { noise(t, 0.35, 'highpass', 1800, 0.5, 0.4, 0.001, 0, bus); for (let i = 0; i < 5; i++) tone(t + i * 0.04, 1800 + H(i + t) * 2400, 0.12, 0.06, 'sine', 0.001, 0, bus); },
198
+ moo: (t) => { tone(t, 150, 1.0, 0.25, 'sawtooth', 0.15, 0, bus, 110, 700); },
199
+ poof: (t) => noise(t, 0.5, 'lowpass', 2500, 0.5, 0.3, 0.01, 0, bus, 300),
200
+ buzz: (t) => tone(t, 110, 0.35, 0.18, 'square', 0.004, 0, bus, null, 900),
201
+ shh: (t) => noise(t, 0.7, 'highpass', 3500, 0.6, 0.1, 0.1, 0, bus),
202
+ boom: (t) => { tone(t, 70, 1.6, 0.7, 'sine', 0.003, 0, bus, 32); noise(t, 0.6, 'lowpass', 400, 0.5, 0.25, 0.003, 0, bus); },
203
+ slide: (t) => tone(t, 1400, 0.9, 0.14, 'sine', 0.01, 0, bus, 180),
204
+ scribble: (t, d = 1) => { for (let x = 0; x < d; x += 0.11) noise(t + x, 0.07, 'bandpass', 2200 + H(x * 9 + t) * 1800, 2, 0.06, 0.01, 0, bus); },
205
+ };
206
+ return S;
207
+ }
208
+
209
+ function cueSfx(S) {
210
+ const c = TL.cue;
211
+ const Q = [];
212
+ const q = (t, fn) => Q.push([t, fn]);
213
+ // 转场
214
+ TL.scenes.forEach((s, i) => { if (i > 0) q(s.start, (t) => S.whoosh(t, 0.4, 0.14)); });
215
+ // hook
216
+ q(c('hook', 0, 0.05), S.pop); q(c('hook', 1, 0.1), (t) => S.whoosh(t, 0.5));
217
+ q(c('hook', 2, 0), S.boing); q(c('hook', 2, 0.4), (t) => S.coins(t, 8));
218
+ q(c('hook', 3, 1.4), S.click); q(c('hook', 3, 1.45), (t) => S.ding(t)); q(c('hook', 3, 1.5), (t) => S.cheer(t, 1.2, 0.08));
219
+ q(c('hook', 4, 0.6), (t) => S.coins(t, 6)); q(c('hook', 5, 0), S.boom); q(c('hook', 5, 2.2), S.stamp);
220
+ q(c('hook', 6, 0), S.sting); [0, 1, 2, 3, 4, 5].forEach((i) => q(c('hook', 6, 0.15 + i * 0.12), (t) => S.pop(t, 0.18)));
221
+ // village
222
+ q(c('village', 1, 0.8), (t) => S.monkey(t, 3)); q(c('village', 1, 2.2), (t) => S.monkey(t, 2));
223
+ [0, 1, 2].forEach((i) => { q(c('village', 2, 0.4 + i * 1.3), (t) => S.pop(t, 0.2)); q(c('village', 2, 0.9 + i * 1.3), S.buzz); });
224
+ q(c('village', 3, 0), (t) => S.monkey(t, 4)); q(c('village', 3, 0.3), (t) => S.steps(t, 4, 0.18, 0.12));
225
+ // arrive
226
+ q(c('arrive', 0, 0), (t) => S.steps(t, 3.8, 0.34)); q(c('arrive', 1, 0.05), S.boing);
227
+ q(c('arrive', 2, 0), (t) => S.whoosh(t, 0.3, 0.2)); [0, 1, 2, 3].forEach((i) => q(c('arrive', 2, 0.4 + i * 0.15), (t) => S.pop(t, 0.15)));
228
+ // ten
229
+ q(c('ten', 1, 0.2), (t) => S.monkey(t, 2)); q(c('ten', 1, 1.1), S.coin); q(c('ten', 1, 1.3), S.ding);
230
+ q(c('ten', 2, 0), (t) => S.cheer(t, 2.5)); q(c('ten', 2, 0.2), (t) => S.steps(t, 4, 0.14, 0.14));
231
+ for (let x = 0; x < 9; x += 0.62) q(c('ten', 3, x), (t) => S.coin(t, 0.08));
232
+ q(c('ten', 4, 0.5), S.cash);
233
+ // twenty
234
+ q(c('twenty', 0, 0.6), (t) => S.monkey(t, 1)); q(c('twenty', 1, 0.3), (t) => S.slide(t));
235
+ q(c('twenty', 3, 0), S.flip); q(c('twenty', 3, 0.5), S.stamp); q(c('twenty', 4, 0), (t) => S.cheer(t, 3)); q(c('twenty', 4, 1.6), S.pop);
236
+ // scarce
237
+ q(c('scarce', 0, 0.3), (t) => S.wind(t, 2)); q(c('scarce', 1, 0.6), S.flip); q(c('scarce', 1, 1.2), S.stamp);
238
+ [0, 1, 2].forEach((i) => q(c('scarce', 2, 0.6 + i * 0.4), (t) => S.pop(t, 0.18)));
239
+ q(c('scarce', 3, 0), S.sparkle); q(c('scarce', 3, 0.3), (t) => S.cheer(t, 1.6, 0.1));
240
+ // fifty
241
+ q(c('fifty', 0, 0), (t) => S.drumroll(t, TL.cue('fifty', 2, 0) - t));
242
+ q(c('fifty', 2, 0), S.sting); q(c('fifty', 2, 0.5), (t) => S.cheer(t, 2.5, 0.2));
243
+ q(c('fifty', 3, 0), S.boing); q(c('fifty', 4, 1.6), S.pop); q(c('fifty', 5, 0.8), S.car);
244
+ // deal
245
+ q(c('deal', 1, 0), S.shh); q(c('deal', 2, 1.4), S.pop); q(c('deal', 3, 0), S.pop); q(c('deal', 4, 0.2), S.cash); q(c('deal', 4, 0.5), S.sparkle);
246
+ // frenzy
247
+ q(c('frenzy', 0, 0), (t) => S.coins(t, 14)); q(c('frenzy', 0, 1.5), (t) => S.cheer(t, 2));
248
+ q(c('frenzy', 1, 0.4), S.swish); q(c('frenzy', 1, 1.2), S.smash); q(c('frenzy', 1, 1.3), (t) => S.coins(t, 8));
249
+ q(c('frenzy', 2, 0.1), S.pop); q(c('frenzy', 2, 1.7), S.moo);
250
+ q(c('frenzy', 3, 0), (t) => S.steps(t, 3.5, 0.2, 0.12));
251
+ for (let x = 0; x < 3.6; x += 0.3) q(c('frenzy', 4, x), (t) => S.coin(t, 0.07));
252
+ q(c('frenzy', 4, 3.4), S.stamp);
253
+ // gone
254
+ q(c('gone', 0, 0), S.poof); q(c('gone', 0, 0.3), (t) => S.wind(t, 5));
255
+ q(c('gone', 1, 0), (t) => S.whoosh(t, 1.2, 0.12));
256
+ [0, 1, 2].forEach((i) => q(c('gone', 2, i * 1.15), S.flip));
257
+ q(c('gone', 2, 0), (t) => S.rain(t, TL.cue('gone', 'end', 0) - t));
258
+ q(c('gone', 3, 0.1), (t) => S.trombone(t)); q(c('gone', 3, 1.5), (t) => S.cricket(t, 4.5));
259
+ // ledger
260
+ [1.4, 3.2, 5.0].forEach((x) => q(c('ledger', 1, x), (t) => S.type(t, 9)));
261
+ q(c('ledger', 2, 0.4), (t) => S.ding(t)); q(c('ledger', 3, 0.6), (t) => S.type(t, 10)); q(c('ledger', 3, 4.6), (t) => S.ding(t, 93));
262
+ q(c('ledger', 4, 0.3), S.cash); q(c('ledger', 5, 0.4), (t) => S.trombone(t, true)); q(c('ledger', 5, 4), S.stamp);
263
+ q(c('ledger', 6, 0), (t) => S.whoosh(t, 0.5)); q(c('ledger', 6, 0.4), (t) => S.coins(t, 10));
264
+ // why
265
+ [0, 1, 2].forEach((i) => q(c('why', 0, 0.3 + i * 0.3), (t) => S.pop(t, 0.18)));
266
+ [1, 2, 3].forEach((i) => q(c('why', i, 0), (t) => S.whoosh(t, 0.4, 0.2)));
267
+ q(c('why', 1, 4.2), S.slide); q(c('why', 2, 0.2), (t) => S.coins(t, 5));
268
+ [0, 1, 2, 3].forEach((i) => q(c('why', 4, 0.4 + i * 0.7), (t) => S.pop(t, 0.18)));
269
+ q(c('why', 5, 0), S.boom); q(c('why', 5, 0.6), S.stamp);
270
+ // real
271
+ q(c('real', 0, 0.1), S.pop); q(c('real', 1, 1.4), S.stamp);
272
+ [2, 3, 4].forEach((i) => q(c('real', i, 0), (t) => S.whoosh(t, 0.4, 0.2)));
273
+ q(c('real', 3, 2.6), S.slide); [0, 1, 2].forEach((i) => q(c('real', 4, 0.3 + i * 0.6), (t) => S.pop(t, 0.18)));
274
+ [0, 1, 2, 3].forEach((i) => q(c('real', 5, 0.6 + i * 0.9), (t) => S.pop(t, 0.2)));
275
+ // check
276
+ q(c('check', 0, 0.6), S.pop); [2, 3, 4].forEach((i) => { q(c('check', i, 0), (t) => S.scribble(t, 1)); q(c('check', i, 2.2), (t) => S.ding(t, 88 + i)); });
277
+ q(c('check', 5, 0.3), S.boing); q(c('check', 5, 1.6), S.stamp);
278
+ // outro
279
+ q(c('outro', 0, 1.3), S.buzz); q(c('outro', 0, 2.9), S.buzz); q(c('outro', 1, 0.3), S.whoosh);
280
+ q(c('outro', 2, 2.6), S.click); q(c('outro', 2, 2.65), (t) => S.ding(t)); q(c('outro', 2, 2.7), (t) => S.cheer(t, 1.5, 0.1));
281
+ [0, 1, 2].forEach((i) => q(c('outro', 2, 0.8 + i * 0.3), (t) => S.pop(t, 0.15)));
282
+ q(c('outro', 3, 0), S.sting); q(c('outro', 3, 0.3), (t) => S.monkey(t, 3));
283
+ return Q;
284
+ }
285
+
286
+ /* ---------- 加载旁白 ---------- */
287
+ const voCache = {};
288
+ async function loadVO(decodeCtx, onProgress) {
289
+ let done = 0;
290
+ const lines = TL.lines;
291
+ const pool = 6; let idx = 0;
292
+ async function worker() {
293
+ while (idx < lines.length) {
294
+ const l = lines[idx++];
295
+ if (!voCache[l.file]) {
296
+ const r = await fetch(l.file); if (!r.ok) throw new Error('加载配音失败:' + l.file);
297
+ const ab = await r.arrayBuffer();
298
+ voCache[l.file] = await decodeCtx.decodeAudioData(ab);
299
+ }
300
+ done++; onProgress && onProgress(done / lines.length);
301
+ }
302
+ }
303
+ await Promise.all(Array.from({ length: pool }, worker));
304
+ }
305
+
306
+ /* ---------- 渲染整条音轨 ---------- */
307
+ async function renderSoundtrack(onProgress = () => {}, opts = {}) {
308
+ const len = Math.ceil((TL.total + 0.3) * SR);
309
+ const ctx = new OfflineAudioContext(2, len, SR);
310
+ await loadVO(ctx, (p) => onProgress(p * 0.4, '加载配音'));
311
+ const master = ctx.createGain(); master.gain.value = 0.95;
312
+ const comp = ctx.createDynamicsCompressor(); comp.threshold.value = -14; comp.ratio.value = 3; comp.attack.value = 0.005; comp.release.value = 0.2; comp.knee.value = 8;
313
+ master.connect(comp); comp.connect(ctx.destination);
314
+ const musicBus = ctx.createGain(); musicBus.gain.value = opts.music != null ? opts.music : 0.5;
315
+ const duck = ctx.createGain(); duck.gain.value = 1;
316
+ musicBus.connect(duck); duck.connect(master);
317
+ const sfxBus = ctx.createGain(); sfxBus.gain.value = opts.sfx != null ? opts.sfx : 0.85; sfxBus.connect(master);
318
+ const voBus = ctx.createGain(); voBus.gain.value = opts.vo != null ? opts.vo : 1.25; voBus.connect(master);
319
+ // 事件队列:所有音符/音效先登记,渲染时按时间窗口用 suspend() 分批创建节点,
320
+ // 避免一次性创建上万个节点导致离线渲染极慢。
321
+ const EV = [];
322
+ const defer = (obj) => { const o = {}; Object.keys(obj).forEach((k) => { o[k] = typeof obj[k] === 'function' ? (t, ...a) => EV.push([t, () => obj[k](t, ...a)]) : obj[k]; }); return o; };
323
+ // 旁白 + 闪避
324
+ if (!opts.noVO) TL.lines.forEach((l, i) => {
325
+ EV.push([l.start, () => { const s = ctx.createBufferSource(); s.buffer = voCache[l.file]; s.connect(voBus); s.start(l.start); }]);
326
+ const next = TL.lines[i + 1];
327
+ duck.gain.setTargetAtTime(0.38, Math.max(0, l.start - 0.12), 0.05);
328
+ if (!next || next.start - l.end > 0.7) duck.gain.setTargetAtTime(1, l.end + 0.15, 0.25);
329
+ });
330
+ const K = makeKit(ctx, musicBus);
331
+ if (!opts.noMusic) scheduleMusic(defer(K), musicBus);
332
+ const SK = makeKit(ctx, sfxBus);
333
+ const S = makeSfx(SK, sfxBus);
334
+ if (!opts.noSfx) cueSfx(S).forEach(([t, fn]) => { if (t >= 0 && t < TL.total) EV.push([t, () => fn(t)]); });
335
+ EV.sort((a, b) => a[0] - b[0]);
336
+ // 结尾淡出
337
+ master.gain.setValueAtTime(0.95, TL.total - 1.5); master.gain.linearRampToValueAtTime(0.0001, TL.total + 0.2);
338
+ const WIN = 2;
339
+ let ei = 0;
340
+ const flush = (until) => { while (ei < EV.length && EV[ei][0] < until) { try { EV[ei][1](); } catch (e) { console.warn(e); } ei++; } };
341
+ flush(WIN + 0.05);
342
+ for (let x = WIN; x < TL.total + 0.3; x += WIN) {
343
+ const xx = x;
344
+ ctx.suspend(xx).then(() => { flush(xx + WIN + 0.05); onProgress(0.4 + 0.6 * Math.min(1, xx / TL.total), '合成音乐与音效'); ctx.resume(); });
345
+ }
346
+ const buf = await ctx.startRendering();
347
+ onProgress(1, '完成');
348
+ return buf;
349
+ }
350
+
351
+ window.AUDIO = { renderSoundtrack, SR };
352
+ })();
shouhouzi/js/engine.js CHANGED
@@ -246,7 +246,8 @@
246
  D.boil = Math.floor(t * 8);
247
  const { sc, lt } = TL.at(t);
248
  const scene = window.SCENES[sc.id];
249
- ctx.setTransform(1, 0, 0, 1, 0, 0);
 
250
  ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';
251
  ctx.fillStyle = '#fbf3e1'; ctx.fillRect(0, 0, Lo.W, Lo.H);
252
  const S = {
@@ -254,10 +255,10 @@
254
  ls: (i) => lt - sc.L[i],
255
  };
256
  ctx.save();
257
- ctx.setTransform(Lo.k, 0, 0, Lo.k, Lo.sx, Lo.sy);
258
  try { scene && scene(ctx, lt, S); } catch (e) { console.error(sc.id, e); }
259
  ctx.restore();
260
- ctx.setTransform(1, 0, 0, 1, 0, 0);
261
  // 颗粒与暗角
262
  ctx.save(); ctx.globalAlpha = 0.07; ctx.globalCompositeOperation = 'multiply';
263
  ctx.fillStyle = ctx.createPattern(getGrain(), 'repeat'); ctx.fillRect(0, 0, Lo.W, Lo.H); ctx.restore();
@@ -272,14 +273,14 @@
272
  }
273
 
274
  /* ---------- 封面 ---------- */
275
- function renderCover(ctx, Lo) {
276
  D.boil = 3;
277
- ctx.setTransform(1, 0, 0, 1, 0, 0);
278
  ctx.fillStyle = '#fbf3e1'; ctx.fillRect(0, 0, Lo.W, Lo.H);
279
- ctx.save(); ctx.setTransform(Lo.k, 0, 0, Lo.k, Lo.sx, Lo.sy);
280
  window.SCENES.__cover(ctx, Lo.name === 'portrait');
281
  ctx.restore();
282
- ctx.setTransform(1, 0, 0, 1, 0, 0);
283
  ctx.save(); ctx.globalAlpha = 0.07; ctx.globalCompositeOperation = 'multiply';
284
  ctx.fillStyle = ctx.createPattern(getGrain(), 'repeat'); ctx.fillRect(0, 0, Lo.W, Lo.H); ctx.restore();
285
  const P = Lo.name === 'portrait';
@@ -289,6 +290,8 @@
289
  D.text(ctx, '一个寓言,看懂所有泡沫', 540, 468, 54, '#fff');
290
  D.bigText(ctx, '你会花35块', 470, 1320, 92, '#fff');
291
  D.bigText(ctx, '买一只猴子吗?', 470, 1440, 92, '#ffcd3c');
 
 
292
  } else {
293
  D.bigText(ctx, '收猴子的故事', 960, 150, 150, '#ffcd3c', { rot: -0.03 });
294
  D.rrect(ctx, 600, 238, 720, 84, 40, 2, 1); D.fs(ctx, '#ff5b4f', 6);
 
246
  D.boil = Math.floor(t * 8);
247
  const { sc, lt } = TL.at(t);
248
  const scene = window.SCENES[sc.id];
249
+ const z = opts.scale || 1;
250
+ ctx.setTransform(z, 0, 0, z, 0, 0);
251
  ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';
252
  ctx.fillStyle = '#fbf3e1'; ctx.fillRect(0, 0, Lo.W, Lo.H);
253
  const S = {
 
255
  ls: (i) => lt - sc.L[i],
256
  };
257
  ctx.save();
258
+ ctx.setTransform(Lo.k * z, 0, 0, Lo.k * z, Lo.sx * z, Lo.sy * z);
259
  try { scene && scene(ctx, lt, S); } catch (e) { console.error(sc.id, e); }
260
  ctx.restore();
261
+ ctx.setTransform(z, 0, 0, z, 0, 0);
262
  // 颗粒与暗角
263
  ctx.save(); ctx.globalAlpha = 0.07; ctx.globalCompositeOperation = 'multiply';
264
  ctx.fillStyle = ctx.createPattern(getGrain(), 'repeat'); ctx.fillRect(0, 0, Lo.W, Lo.H); ctx.restore();
 
273
  }
274
 
275
  /* ---------- 封面 ---------- */
276
+ function renderCover(ctx, Lo, z = 1) {
277
  D.boil = 3;
278
+ ctx.setTransform(z, 0, 0, z, 0, 0);
279
  ctx.fillStyle = '#fbf3e1'; ctx.fillRect(0, 0, Lo.W, Lo.H);
280
+ ctx.save(); ctx.setTransform(Lo.k * z, 0, 0, Lo.k * z, Lo.sx * z, Lo.sy * z);
281
  window.SCENES.__cover(ctx, Lo.name === 'portrait');
282
  ctx.restore();
283
+ ctx.setTransform(z, 0, 0, z, 0, 0);
284
  ctx.save(); ctx.globalAlpha = 0.07; ctx.globalCompositeOperation = 'multiply';
285
  ctx.fillStyle = ctx.createPattern(getGrain(), 'repeat'); ctx.fillRect(0, 0, Lo.W, Lo.H); ctx.restore();
286
  const P = Lo.name === 'portrait';
 
290
  D.text(ctx, '一个寓言,看懂所有泡沫', 540, 468, 54, '#fff');
291
  D.bigText(ctx, '你会花35块', 470, 1320, 92, '#fff');
292
  D.bigText(ctx, '买一只猴子吗?', 470, 1440, 92, '#ffcd3c');
293
+ D.rrect(ctx, 190, 1560, 700, 96, 46, 4, 1); D.fs(ctx, '#2b2321', 0);
294
+ D.text(ctx, '博傻理论 · 泡沫真相 · 动画讲解', 540, 1609, 46, '#ffcd3c');
295
  } else {
296
  D.bigText(ctx, '收猴子的故事', 960, 150, 150, '#ffcd3c', { rot: -0.03 });
297
  D.rrect(ctx, 600, 238, 720, 84, 40, 2, 1); D.fs(ctx, '#ff5b4f', 6);
shouhouzi/js/exporter.js ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================
2
+ * exporter.js —— 浏览器本地渲染导出 MP4
3
+ * WebCodecs(VideoEncoder H.264 + AudioEncoder AAC/Opus) + mp4-muxer
4
+ * 逐帧离线渲染(不依赖实时播放),帧率 24/30/60 可选
5
+ * ========================================================= */
6
+ (function () {
7
+ const AVC_CANDIDATES = ['avc1.640034', 'avc1.640033', 'avc1.640032', 'avc1.64002A', 'avc1.640028', 'avc1.4D4033', 'avc1.4D4028', 'avc1.42E034', 'avc1.42E01F'];
8
+
9
+ async function pickVideoConfig(W, H, fps, bitrate) {
10
+ if (typeof VideoEncoder === 'undefined') throw new Error('当前浏览器不支持 WebCodecs(VideoEncoder)。请使用最新版 Chrome / Edge 桌面版。');
11
+ for (const hw of ['prefer-hardware', 'no-preference', 'prefer-software']) {
12
+ for (const codec of AVC_CANDIDATES) {
13
+ const cfg = { codec, width: W, height: H, bitrate, framerate: fps, hardwareAcceleration: hw, avc: { format: 'avc' }, latencyMode: 'quality', bitrateMode: 'variable' };
14
+ try { const r = await VideoEncoder.isConfigSupported(cfg); if (r.supported) return { cfg: r.config, mux: 'avc' }; } catch (e) { /* 继续尝试 */ }
15
+ }
16
+ }
17
+ // 兜底:没有 H.264 编码器(如部分 Linux 版 Chromium)时改用 VP9(MP4 封装)
18
+ for (const codec of ['vp09.00.41.08', 'vp09.00.40.08', 'vp09.00.31.08']) {
19
+ const cfg = { codec, width: W, height: H, bitrate, framerate: fps, latencyMode: 'quality' };
20
+ try { const r = await VideoEncoder.isConfigSupported(cfg); if (r.supported) return { cfg: r.config, mux: 'vp9' }; } catch (e) { /* */ }
21
+ }
22
+ throw new Error('当前浏览器的 H.264 编码器不支持 ' + W + '×' + H + '@' + fps + 'fps。');
23
+ }
24
+ async function pickAudioConfig() {
25
+ if (typeof AudioEncoder === 'undefined') return null;
26
+ const base = { sampleRate: 48000, numberOfChannels: 2, bitrate: 192000 };
27
+ for (const [codec, mux] of [['mp4a.40.2', 'aac'], ['opus', 'opus']]) {
28
+ try { const r = await AudioEncoder.isConfigSupported(Object.assign({ codec }, base)); if (r.supported) return { cfg: r.config, mux }; } catch (e) { /* */ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function supportInfo() {
34
+ return {
35
+ webcodecs: typeof VideoEncoder !== 'undefined',
36
+ fsapi: typeof window.showSaveFilePicker === 'function',
37
+ };
38
+ }
39
+
40
+ /**
41
+ * opts: { mode:'landscape'|'portrait', fps, bitrate, range:[t0,t1], audio:AudioBuffer|null,
42
+ * onProgress(p, info), signal:{aborted}, saveToDisk:boolean, filename }
43
+ */
44
+ async function exportMP4(opts) {
45
+ const L = LAYOUTS[opts.mode];
46
+ const fps = opts.fps;
47
+ const [t0, t1] = opts.range || [0, TL.total];
48
+ const dur = t1 - t0;
49
+ const nFrames = Math.round(dur * fps);
50
+ const vcfg = await pickVideoConfig(L.W, L.H, fps, opts.bitrate);
51
+ const acfg = opts.audio ? await pickAudioConfig() : null;
52
+
53
+ let fileHandle = null, writable = null, target;
54
+ if (opts.saveToDisk && typeof window.showSaveFilePicker === 'function') {
55
+ fileHandle = await window.showSaveFilePicker({ suggestedName: opts.filename, types: [{ description: 'MP4 视频', accept: { 'video/mp4': ['.mp4'] } }] });
56
+ writable = await fileHandle.createWritable();
57
+ target = new Mp4Muxer.FileSystemWritableFileStreamTarget(writable);
58
+ } else {
59
+ target = new Mp4Muxer.ArrayBufferTarget();
60
+ }
61
+ const muxer = new Mp4Muxer.Muxer({
62
+ target,
63
+ video: { codec: vcfg.mux, width: L.W, height: L.H, frameRate: fps },
64
+ audio: acfg ? { codec: acfg.mux, sampleRate: 48000, numberOfChannels: 2 } : undefined,
65
+ fastStart: writable ? false : 'in-memory',
66
+ firstTimestampBehavior: 'offset',
67
+ });
68
+
69
+ let encErr = null;
70
+ const venc = new VideoEncoder({ output: (chunk, meta) => muxer.addVideoChunk(chunk, meta), error: (e) => { encErr = e; } });
71
+ venc.configure(vcfg.cfg);
72
+
73
+ // —— 音频:一次性编码 ——
74
+ if (acfg) {
75
+ const aenc = new AudioEncoder({ output: (chunk, meta) => muxer.addAudioChunk(chunk, meta), error: (e) => { encErr = e; } });
76
+ aenc.configure(acfg.cfg);
77
+ const buf = opts.audio, SR = buf.sampleRate;
78
+ const s0 = Math.round(t0 * SR), s1 = Math.min(buf.length, Math.round(t1 * SR));
79
+ const c0 = buf.getChannelData(0), c1 = buf.numberOfChannels > 1 ? buf.getChannelData(1) : c0;
80
+ const CH = 4800;
81
+ for (let s = s0; s < s1; s += CH) {
82
+ const n = Math.min(CH, s1 - s);
83
+ const data = new Float32Array(n * 2);
84
+ data.set(c0.subarray(s, s + n), 0); data.set(c1.subarray(s, s + n), n);
85
+ const ad = new AudioData({ format: 'f32-planar', sampleRate: SR, numberOfFrames: n, numberOfChannels: 2, timestamp: Math.round((s - s0) / SR * 1e6), data });
86
+ aenc.encode(ad); ad.close();
87
+ if (aenc.encodeQueueSize > 50) await new Promise((r) => setTimeout(r, 0));
88
+ }
89
+ await aenc.flush(); aenc.close();
90
+ }
91
+
92
+ // —— 视频:逐帧渲染 ——
93
+ const canvas = document.createElement('canvas'); canvas.width = L.W; canvas.height = L.H;
94
+ const ctx = canvas.getContext('2d', { alpha: false });
95
+ const started = performance.now();
96
+ const gop = fps * 2;
97
+ for (let i = 0; i < nFrames; i++) {
98
+ if (opts.signal && opts.signal.aborted) { venc.close(); if (writable) await writable.abort().catch(() => {}); throw new Error('已取消导出'); }
99
+ if (encErr) throw encErr;
100
+ const t = t0 + i / fps;
101
+ ENGINE.render(ctx, t, L, {});
102
+ const vf = new VideoFrame(canvas, { timestamp: Math.round(i * 1e6 / fps), duration: Math.round(1e6 / fps) });
103
+ venc.encode(vf, { keyFrame: i % gop === 0 });
104
+ vf.close();
105
+ if (venc.encodeQueueSize > 6) await new Promise((r) => { const h = () => { venc.removeEventListener('dequeue', h); r(); }; venc.addEventListener('dequeue', h); setTimeout(h, 50); });
106
+ if (i % 10 === 0) {
107
+ const el = (performance.now() - started) / 1000;
108
+ const p = (i + 1) / nFrames;
109
+ opts.onProgress && opts.onProgress(p, { frame: i + 1, total: nFrames, elapsed: el, eta: el / p - el, codec: vcfg.cfg.codec, audio: acfg ? acfg.mux : '无' });
110
+ await new Promise((r) => setTimeout(r, 0));
111
+ }
112
+ }
113
+ await venc.flush(); venc.close();
114
+ if (encErr) throw encErr;
115
+ muxer.finalize();
116
+ opts.onProgress && opts.onProgress(1, { frame: nFrames, total: nFrames, elapsed: (performance.now() - started) / 1000, eta: 0, codec: vcfg.cfg.codec, audio: acfg ? acfg.mux : '无' });
117
+ if (writable) { await writable.close(); return { savedToDisk: true, codec: vcfg.cfg.codec, audio: acfg && acfg.mux }; }
118
+ const blob = new Blob([target.buffer], { type: 'video/mp4' });
119
+ return { blob, codec: vcfg.cfg.codec, audio: acfg && acfg.mux };
120
+ }
121
+
122
+ window.EXPORTER = { exportMP4, supportInfo };
123
+ })();
shouhouzi/js/publish.js ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================
2
+ * publish.js —— 抖音 / 哔哩哔哩 发布文案(标题、简介、标签)
3
+ * B站简介中的章节时间戳根据时间轴自动生成
4
+ * ========================================================= */
5
+ (function () {
6
+ const mmss = (t) => { t = Math.floor(t); return String(Math.floor(t / 60)).padStart(2, '0') + ':' + String(t % 60).padStart(2, '0'); };
7
+ function chapters() {
8
+ return TL.scenes.map((s, i) => `${mmss(s.start)} ${i === 0 ? '开场:10块钱收猴子,你干不干?' : s.title}`).join('\n');
9
+ }
10
+ function build() {
11
+ const dur = mmss(TL.total);
12
+ return {
13
+ douyin: {
14
+ name: '抖音(竖屏 1080×1920)',
15
+ fields: [
16
+ { k: '标题', v: '10块钱一只收猴子,为什么全村人最后都亏了?' },
17
+ { k: '标题(备选)', v: '有人高价收猴子,全村抢着卖,结局却让人后背发凉…' },
18
+ { k: '作品描述', v: '一个经济学寓言,讲透所有泡沫的底层剧本:\n制造需求 → 抬高价格 → 让你接盘 → 庄家离场。\n下次有人跟你说“稳赚不赔”“等老板回来就翻倍”,先看看笼子里装的,是不是猴子🐒\n你身边见过哪些“收猴子”的套路?评论区聊聊👇\n#经济学 #收猴子的故事 #博傻理论 #金融知识 #投资理财 #认知提升 #涨知识 #动画科普' },
19
+ { k: '话题标签', v: '#经济学 #收猴子的故事 #博傻理论 #金融知识 #投资理财 #认知提升 #涨知识 #动画科普 #知识分享 #泡沫' },
20
+ { k: '封面文字建议', v: '你会花35块,买一只猴子吗?' },
21
+ ],
22
+ },
23
+ bilibili: {
24
+ name: '哔哩哔哩(横屏 1920×1080)',
25
+ fields: [
26
+ { k: '标题', v: '【经济学动画】10块钱收猴子,全村却倒亏2万?一个寓言看懂所有泡沫|收猴子的故事' },
27
+ { k: '标题(备选)', v: '商人高价收猴子,村民为什么会抢着接盘?博傻理论动画讲解' },
28
+ { k: '简介', v: `经济学里有个著名的寓言——“收猴子的故事”。\n商人10块、20块、25块一只收猴子,最后宣布50块一只;助手却把笼子里的猴子35块一只卖回给村民……\n这支 ${dur} 的动画,带你算清这笔账,看懂“唯一买家”“赚钱效应”和“博傻理论”,以及现实里那些换了道具的“猴子”。\n\n【章节】\n${chapters()}\n\n【识别猴子三问】\n① 抛开价格,它本身有什么用?\n② 现在是谁在买?为什么买?\n③ 如果再也没人接盘,我还愿意拿着吗?\n\n本视频为经济学寓言科普,不构成任何投资建议。\n觉得有收获的话,一键三连支持一下~` },
29
+ { k: '标签(最多10个)', v: '经济学,收猴子的故事,博傻理论,泡沫经济,金融知识,投资理财,动画科普,知识分享,郁金香泡沫,涨知识' },
30
+ { k: '分区建议', v: '知识 → 财经商业' },
31
+ { k: '动态文案', v: '新视频:10块钱收猴子的故事🐒 一个寓言看懂所有泡沫的剧本,看完你还敢接盘吗?' },
32
+ ],
33
+ },
34
+ };
35
+ }
36
+ window.PUBLISH = { build, chapters, mmss };
37
+ })();
shouhouzi/tools/audio-test.html ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ <!doctype html><meta charset="utf-8"><body>
2
+ <script src="../js/script-data.js"></script><script src="../js/draw.js"></script><script src="../js/scenes.js"></script><script src="../js/engine.js"></script><script src="../js/audio.js"></script>
3
+ <script>
4
+ // tools 目录下需修正配音相对路径
5
+ TL.lines.forEach(l=>{ if(!l.file.startsWith('../')) l.file='../'+l.file; });
6
+ window.go=async(o)=>{ try{ const b=await AUDIO.renderSoundtrack(()=>{},o||{}); window.SND=b; return b.duration;}catch(e){return 'ERR '+e.message} };
7
+ </script>