Spaces:
Running
Running
Step 4-7: 音乐音效混音 + 播放器 + MP4 导出 + 发布文案 + 封面
Browse files- .gitattributes +2 -0
- README.md +4 -4
- shouhouzi/covers/cover-landscape.png +3 -0
- shouhouzi/covers/cover-portrait.png +3 -0
- shouhouzi/css/style.css +85 -0
- shouhouzi/fonts/kuaile.woff2 +0 -0
- shouhouzi/index.html +127 -1
- shouhouzi/js/app.js +228 -0
- shouhouzi/js/audio.js +352 -0
- shouhouzi/js/engine.js +10 -7
- shouhouzi/js/exporter.js +123 -0
- shouhouzi/js/publish.js +37 -0
- shouhouzi/tools/audio-test.html +7 -0
.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 | 程序化音乐 + 音效 + 旁白混音(
|
| 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
|
shouhouzi/covers/cover-portrait.png
ADDED
|
Git LFS Details
|
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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, '<')}</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 |
-
|
|
|
|
| 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(
|
| 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(
|
| 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(
|
| 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>
|