Spaces:
Running
Running
gemini-stars: 配乐音效与预渲染音轨、播放器、MP4 导出(24/30/60fps)、多平台封面与文案、本地字体,自检完成(step 4–7)
Browse files- .gitattributes +3 -0
- gemini-stars/README.md +33 -18
- gemini-stars/app.js +307 -0
- gemini-stars/audio.js +181 -0
- gemini-stars/audio/soundtrack.mp3 +3 -0
- gemini-stars/copy.js +62 -0
- gemini-stars/fonts/OFL.txt +93 -0
- gemini-stars/fonts/gemini-sans.woff2 +3 -0
- gemini-stars/fonts/gemini-serif.woff2 +3 -0
- gemini-stars/index.html +72 -0
- gemini-stars/scenes.js +11 -11
- gemini-stars/style.css +74 -0
- gemini-stars/tools/build_audio.mjs +24 -0
- gemini-stars/tools/subset_fonts.py +18 -0
.gitattributes
CHANGED
|
@@ -72,3 +72,6 @@ gemini/audio/n01.mp3 filter=lfs diff=lfs merge=lfs -text
|
|
| 72 |
gemini/audio/n10.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 73 |
gemini/audio/n07.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 74 |
gemini/audio/n03.mp3 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
gemini/audio/n10.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 73 |
gemini/audio/n07.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 74 |
gemini/audio/n03.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 75 |
+
gemini-stars/fonts/gemini-serif.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 76 |
+
gemini-stars/fonts/gemini-sans.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 77 |
+
gemini-stars/audio/soundtrack.mp3 filter=lfs diff=lfs merge=lfs -text
|
gemini-stars/README.md
CHANGED
|
@@ -39,34 +39,49 @@
|
|
| 39 |
|
| 40 |
```
|
| 41 |
gemini-stars/
|
| 42 |
-
├── index.html
|
| 43 |
-
├── style.css
|
| 44 |
-
├── app.js
|
| 45 |
-
├── scenes.js
|
| 46 |
-
├── audio.js
|
| 47 |
-
├── timeline.js
|
| 48 |
-
├── copy.js
|
| 49 |
-
├──
|
| 50 |
-
├── audio/
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
```
|
| 53 |
|
| 54 |
关键设计:
|
| 55 |
-
- **确定性渲染**:每一帧只由时间 `t` 决定(随机数
|
| 56 |
-
- **单一混音缓冲**:配乐 + 音效 + 旁白
|
| 57 |
- **画内字幕**:字幕绘制在 Canvas 内,导出的 MP4 自带字幕。
|
|
|
|
| 58 |
|
| 59 |
## 4. 步骤与进度看板
|
| 60 |
|
| 61 |
- [x] Step 1:撰写本计划(本文件)并推送
|
| 62 |
- [x] Step 2:撰写旁白文案,生成 TTS 音频与 `timeline.js`,推送
|
| 63 |
- [x] Step 3:场景动画引擎与全部场景(横/竖屏自适应),推送
|
| 64 |
-
- [
|
| 65 |
-
- [
|
| 66 |
-
- [
|
| 67 |
-
- [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
## 5. 使用
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
```
|
| 41 |
gemini-stars/
|
| 42 |
+
├── index.html # 页面:播放器 + 章节条 + 导出面板 + 多平台发布素材
|
| 43 |
+
├── style.css # 深空主题 UI,桌面/手机自适应
|
| 44 |
+
├── app.js # 播放器、横竖屏、MP4 导出(WebCodecs / MediaRecorder 回退)、封面与文案面板
|
| 45 |
+
├── scenes.js # 9 个章节的程序化绘制(纯函数 renderFrame(ctx, t, W, H))+ 封面绘制 drawCover
|
| 46 |
+
├── audio.js # 配乐 / 音效 / 旁白混音(OfflineAudioContext,确定性)
|
| 47 |
+
├── timeline.js # 由 tools/make_voice.py 生成:每句旁白起止时间与字幕
|
| 48 |
+
├── copy.js # 小红书 / 抖音 / 哔哩哔哩 / 视频号 标题、描述、话题与封面配置
|
| 49 |
+
├── audio/v01–v29.mp3 # 29 句中文 AI 旁白
|
| 50 |
+
├── audio/soundtrack.mp3 # 由 audio.js 预渲染的完整混音(秒开;加载失败时浏览器内实时合成)
|
| 51 |
+
├── fonts/*.woff2 # Noto Sans/Serif SC 子集(OFL),国内网络也能正确显示
|
| 52 |
+
├── vendor/mp4-muxer.mjs # MIT 许可 MP4 封装库(本地化)
|
| 53 |
+
└── tools/
|
| 54 |
+
├── make_voice.py # 旁白 TTS + 时间轴
|
| 55 |
+
├── build_audio.mjs # 预渲染 soundtrack.mp3(node-web-audio-api + ffmpeg)
|
| 56 |
+
└── subset_fonts.py # 字体子集化(修改文字后需重新运行)
|
| 57 |
```
|
| 58 |
|
| 59 |
关键设计:
|
| 60 |
+
- **确定性渲染**:每一帧只由时间 `t` 决定(随机数固定种子),实时播放与离线导出画面完全一致。
|
| 61 |
+
- **单一混音缓冲**:配乐 + 音效 + 旁白混成一条音轨,播放和导出共用,音画严格同步;旁白出现时音乐自动闪避。
|
| 62 |
- **画内字幕**:字幕绘制在 Canvas 内,导出的 MP4 自带字幕。
|
| 63 |
+
- **导出编码**:视频优先 H.264(avc1),不支持时依次 HEVC / VP9 / AV1(均封装 MP4);音频优先 AAC,否则 Opus;每 2 秒一个关键帧。
|
| 64 |
|
| 65 |
## 4. 步骤与进度看板
|
| 66 |
|
| 67 |
- [x] Step 1:撰写本计划(本文件)并推送
|
| 68 |
- [x] Step 2:撰写旁白文案,生成 TTS 音频与 `timeline.js`,推送
|
| 69 |
- [x] Step 3:场景动画引擎与全部场景(横/竖屏自适应),推送
|
| 70 |
+
- [x] Step 4:配乐与音效合成、混音(-13.6 LUFS)、预渲染音轨、播放器,推送
|
| 71 |
+
- [x] Step 5:MP4 本地导出(24/30/60fps,横/竖屏,1080p/720p),推送
|
| 72 |
+
- [x] Step 6:多平台封面(8 张)+ 文案复制面板(字数/话题数自动校验),推送
|
| 73 |
+
- [x] Step 7:自检、修正与最终推送
|
| 74 |
+
|
| 75 |
+
### 自检记录
|
| 76 |
+
- 横屏 + 竖屏全片逐帧扫描(24fps × 214s × 2 = 10,288 帧):0 报错。
|
| 77 |
+
- 无头 Chromium 实测完整导出:24fps / 1280×720 / 3:34,约 2.5 分钟完成,得到含视频与音频的 MP4(该浏览器无 H.264 编码器,自动回退为 VP9 + Opus)。
|
| 78 |
+
- 手机视口(390×844)自动切换竖屏,播放、字幕、控制条正常。
|
| 79 |
+
- 音轨:整体响度 -13.6 LUFS,峰值 -1 dB。
|
| 80 |
|
| 81 |
## 5. 使用
|
| 82 |
|
| 83 |
+
打开 Space 中的 `gemini-stars/index.html`(需通过 http(s) 访问)。
|
| 84 |
+
- 播放:点击画面或 ▶;空格暂停,← → 快退/快进 5 秒;下方章节条可跳转。
|
| 85 |
+
- 画幅:右上角切换横屏 16:9 / 竖屏 9:16;手机竖持时默认竖屏。
|
| 86 |
+
- 导出:选择画幅、帧率(24/30/60)、清晰度后点击“开始导出”,完成后自动下载。推荐桌面版 Chrome / Edge(H.264 + AAC,兼容性最好)。
|
| 87 |
+
- 发布素材:切换平台标签,复制标题 / 描述 / 话题,下载对应比例封面 PNG。
|
gemini-stars/app.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 双子座 · 播放器 / 导出 / 封面 / 文案
|
| 2 |
+
import { renderFrame, drawCover, TIMELINE, sceneAt } from './scenes.js';
|
| 3 |
+
import { buildSoundtrack, SR } from './audio.js';
|
| 4 |
+
import { PLATFORMS, charLen, tagText } from './copy.js';
|
| 5 |
+
import { Muxer, ArrayBufferTarget } from './vendor/mp4-muxer.mjs';
|
| 6 |
+
|
| 7 |
+
const $ = s => document.querySelector(s);
|
| 8 |
+
const DUR = TIMELINE.duration;
|
| 9 |
+
const fmt = t => { t = Math.max(0, t); const m = Math.floor(t / 60), s = Math.floor(t % 60); return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; };
|
| 10 |
+
const CH_NAMES = { intro: '序章', find: '怎样找到它', myth: '神话', castor: '北河二', pollux: '北河三', meteor: '流星雨', discover: '发现', zodiac: '黄道与文化', outro: '尾声' };
|
| 11 |
+
|
| 12 |
+
/* ================= 状态 ================= */
|
| 13 |
+
const state = { orient: 'landscape', playing: false, offset: 0, startAt: 0, buffer: null, ac: null, src: null, gain: null, subs: true, muted: false, exporting: false, userOrient: false };
|
| 14 |
+
const canvas = $('#view'), ctx = canvas.getContext('2d');
|
| 15 |
+
|
| 16 |
+
function logical() { return state.orient === 'landscape' ? [1920, 1080] : [1080, 1920]; }
|
| 17 |
+
function sizeCanvas() {
|
| 18 |
+
const [W, H] = logical();
|
| 19 |
+
const r = canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
| 20 |
+
const target = Math.min(W, Math.max(480, Math.round(r.width * dpr)));
|
| 21 |
+
const s = target / W;
|
| 22 |
+
canvas.width = Math.round(W * s); canvas.height = Math.round(H * s);
|
| 23 |
+
state.scale = s;
|
| 24 |
+
}
|
| 25 |
+
function setOrient(o, byUser) {
|
| 26 |
+
state.orient = o; if (byUser) state.userOrient = true;
|
| 27 |
+
document.body.classList.toggle('portrait', o === 'portrait');
|
| 28 |
+
document.querySelectorAll('.orient button').forEach(b => b.classList.toggle('on', b.dataset.o === o));
|
| 29 |
+
requestAnimationFrame(() => { sizeCanvas(); draw(); });
|
| 30 |
+
}
|
| 31 |
+
document.querySelectorAll('.orient button').forEach(b => b.onclick = () => setOrient(b.dataset.o, true));
|
| 32 |
+
// 手机竖持默认竖屏
|
| 33 |
+
const mq = window.matchMedia('(orientation: portrait) and (max-width: 900px)');
|
| 34 |
+
const autoOrient = () => { if (!state.userOrient) setOrient(mq.matches ? 'portrait' : 'landscape'); };
|
| 35 |
+
mq.addEventListener ? mq.addEventListener('change', autoOrient) : mq.addListener(autoOrient);
|
| 36 |
+
window.addEventListener('resize', () => { sizeCanvas(); draw(); });
|
| 37 |
+
|
| 38 |
+
/* ================= 时钟 ================= */
|
| 39 |
+
function now() {
|
| 40 |
+
if (!state.playing) return state.offset;
|
| 41 |
+
const lat = (state.ac.outputLatency || 0) + (state.ac.baseLatency || 0);
|
| 42 |
+
return Math.min(DUR, state.offset + (state.ac.currentTime - state.startAt) - lat);
|
| 43 |
+
}
|
| 44 |
+
function play() {
|
| 45 |
+
if (!state.buffer) return;
|
| 46 |
+
if (!state.ac) { state.ac = new AudioContext({ sampleRate: SR, latencyHint: 'playback' }); state.gain = state.ac.createGain(); state.gain.connect(state.ac.destination); }
|
| 47 |
+
state.ac.resume();
|
| 48 |
+
if (state.offset >= DUR - .05) state.offset = 0;
|
| 49 |
+
const src = state.ac.createBufferSource(); src.buffer = state.buffer; src.connect(state.gain);
|
| 50 |
+
state.gain.gain.value = state.muted ? 0 : 1;
|
| 51 |
+
state.startAt = state.ac.currentTime + .05; src.start(state.startAt, state.offset);
|
| 52 |
+
src.onended = () => { if (state.src === src && state.playing && now() >= DUR - .2) { state.playing = false; state.offset = DUR; ui(); } };
|
| 53 |
+
state.src = src; state.playing = true; state.started = true; $('#gate').classList.add('hide'); ui(); loop();
|
| 54 |
+
}
|
| 55 |
+
function pause() {
|
| 56 |
+
if (!state.playing) return;
|
| 57 |
+
state.offset = now(); state.playing = false;
|
| 58 |
+
try { state.src.stop(); } catch (e) { }
|
| 59 |
+
state.src = null; ui();
|
| 60 |
+
}
|
| 61 |
+
function seek(t) { const was = state.playing; if (was) pause(); state.offset = Math.max(0, Math.min(DUR, t)); draw(); ui(); if (was) play(); }
|
| 62 |
+
function toggle() { state.playing ? pause() : play(); }
|
| 63 |
+
|
| 64 |
+
/* ================= 绘制 ================= */
|
| 65 |
+
function draw() {
|
| 66 |
+
const [W, H] = logical(); const s = state.scale || 1;
|
| 67 |
+
ctx.setTransform(s, 0, 0, s, 0, 0);
|
| 68 |
+
const t = state.started || state.offset > 0 ? now() : 14.2; // 未开始时显示标题画面作为海报
|
| 69 |
+
renderFrame(ctx, t, W, H, { layerScale: s, subtitles: state.subs && t !== 14.2 });
|
| 70 |
+
}
|
| 71 |
+
let raf = 0;
|
| 72 |
+
function loop() { cancelAnimationFrame(raf); const step = () => { draw(); ui(true); if (state.playing) raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); }
|
| 73 |
+
|
| 74 |
+
/* ================= 控制条 ================= */
|
| 75 |
+
const seekEl = $('#seek');
|
| 76 |
+
let dragging = false;
|
| 77 |
+
seekEl.addEventListener('input', () => { dragging = true; state.offset = seekEl.value / 1000 * DUR; if (!state.playing) draw(); $('#time').textContent = `${fmt(state.offset)} / ${fmt(DUR)}`; });
|
| 78 |
+
seekEl.addEventListener('change', () => { dragging = false; seek(seekEl.value / 1000 * DUR); });
|
| 79 |
+
function ui(light) {
|
| 80 |
+
const t = now();
|
| 81 |
+
if (!dragging) seekEl.value = Math.round(t / DUR * 1000);
|
| 82 |
+
$('#time').textContent = `${fmt(t)} / ${fmt(DUR)}`;
|
| 83 |
+
if (!light) $('#play').innerHTML = state.playing ? '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>' : '<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M7 4.5v15l13-7.5z"/></svg>';
|
| 84 |
+
const id = sceneAt(t).id; document.querySelectorAll('#chapters button').forEach(b => b.classList.toggle('on', b.dataset.id === id));
|
| 85 |
+
}
|
| 86 |
+
$('#play').onclick = toggle; $('#bigplay').onclick = play;
|
| 87 |
+
canvas.onclick = () => { if (state.buffer) toggle(); if (!state.playing) $('#gate').classList.remove('hide'); };
|
| 88 |
+
$('#sub').onclick = e => { state.subs = !state.subs; e.currentTarget.classList.toggle('on', state.subs); draw(); };
|
| 89 |
+
$('#mute').onclick = e => { state.muted = !state.muted; e.currentTarget.classList.toggle('muted', state.muted); if (state.gain) state.gain.gain.value = state.muted ? 0 : 1; };
|
| 90 |
+
$('#fs').onclick = () => { const st = $('#stage'); if (document.fullscreenElement) document.exitFullscreen(); else (st.requestFullscreen || st.webkitRequestFullscreen)?.call(st); };
|
| 91 |
+
document.addEventListener('fullscreenchange', () => setTimeout(() => { sizeCanvas(); draw(); }, 50));
|
| 92 |
+
document.addEventListener('keydown', e => {
|
| 93 |
+
if (e.target.tagName === 'INPUT' && e.target.type !== 'range') return;
|
| 94 |
+
if (e.code === 'Space') { e.preventDefault(); toggle(); }
|
| 95 |
+
if (e.code === 'ArrowRight') seek(now() + 5); if (e.code === 'ArrowLeft') seek(now() - 5);
|
| 96 |
+
});
|
| 97 |
+
TIMELINE.scenes.forEach(sc => { const b = document.createElement('button'); b.dataset.id = sc.id; b.textContent = `${fmt(sc.start)} ${CH_NAMES[sc.id]}`; b.onclick = () => seek(sc.start + .01); $('#chapters').appendChild(b); });
|
| 98 |
+
|
| 99 |
+
/* ================= 启动 ================= */
|
| 100 |
+
// 优先加载预渲染音轨(由 tools/build_audio.mjs 用同一份 audio.js 生成),秒开;失败时回退到浏览器内实时合成
|
| 101 |
+
async function loadPrebuilt() {
|
| 102 |
+
$('#status').textContent = '载入音轨…';
|
| 103 |
+
const res = await fetch('audio/soundtrack.mp3'); if (!res.ok) throw new Error('HTTP ' + res.status);
|
| 104 |
+
const total = +res.headers.get('content-length') || 0; const reader = res.body.getReader(); const parts = []; let got = 0;
|
| 105 |
+
for (;;) { const { done, value } = await reader.read(); if (done) break; parts.push(value); got += value.length; if (total) $('#status').textContent = `载入音轨 ${Math.round(got / total * 100)}%`; }
|
| 106 |
+
const ab = await new Blob(parts).arrayBuffer();
|
| 107 |
+
const oc = new OfflineAudioContext(2, 1, SR);
|
| 108 |
+
const buf = await new Promise((ok, bad) => oc.decodeAudioData(ab, ok, bad));
|
| 109 |
+
if (Math.abs(buf.duration - DUR) > 1.5) throw new Error('音轨时长不匹配');
|
| 110 |
+
return buf;
|
| 111 |
+
}
|
| 112 |
+
// 本地子集化字体(Noto Sans/Serif SC,OFL),预载后画布文字才会使用正确字体
|
| 113 |
+
async function loadFonts() {
|
| 114 |
+
$('#status').textContent = '载入字体…';
|
| 115 |
+
const jobs = [];
|
| 116 |
+
for (const w of [400, 500, 600, 700]) jobs.push(document.fonts.load(`${w} 40px "Gemini Sans"`, '双子座'));
|
| 117 |
+
for (const w of [600, 700, 800, 900]) jobs.push(document.fonts.load(`${w} 40px "Gemini Serif"`, '双子座'));
|
| 118 |
+
try { await Promise.race([Promise.all(jobs), new Promise(r => setTimeout(r, 5000))]); } catch (e) { }
|
| 119 |
+
}
|
| 120 |
+
async function boot() {
|
| 121 |
+
autoOrient(); sizeCanvas();
|
| 122 |
+
await loadFonts();
|
| 123 |
+
draw(); ui();
|
| 124 |
+
try {
|
| 125 |
+
state.buffer = await loadPrebuilt().catch(e => { console.warn('预渲染音轨不可用,改为实时合成', e); return null; })
|
| 126 |
+
|| await buildSoundtrack(s => $('#status').textContent = '实时合成音轨 · ' + s);
|
| 127 |
+
$('#status').textContent = '点击播放 · 建议佩戴耳机';
|
| 128 |
+
$('#bigplay').disabled = false; $('#play').disabled = false; $('#ex-go').disabled = false;
|
| 129 |
+
} catch (e) { console.error(e); $('#status').textContent = '音轨合成失败:' + e.message; }
|
| 130 |
+
buildPublish();
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/* ================= 导出 MP4 ================= */
|
| 134 |
+
const ex = { orient: 'landscape', fps: 30, res: 1, cancel: false };
|
| 135 |
+
function segBind(id, key, cast = v => v) { document.querySelectorAll(`#${id} button`).forEach(b => b.onclick = () => { ex[key] = cast(b.dataset.v); document.querySelectorAll(`#${id} button`).forEach(x => x.classList.toggle('on', x === b)); }); }
|
| 136 |
+
segBind('ex-orient', 'orient'); segBind('ex-fps', 'fps', Number); segBind('ex-res', 'res', Number);
|
| 137 |
+
const exMsg = m => $('#ex-msg').textContent = m;
|
| 138 |
+
const exProg = k => { $('#ex-prog').hidden = false; $('#ex-fill').style.width = (k * 100).toFixed(1) + '%'; };
|
| 139 |
+
|
| 140 |
+
async function pickVideoCodec(w, h, fps, bitrate) {
|
| 141 |
+
// 优先 H.264(兼容性最好);不支持时依次尝试 HEVC / VP9 / AV1(同样封装为 MP4)
|
| 142 |
+
const list = [
|
| 143 |
+
...['avc1.640034', 'avc1.640033', 'avc1.64002A', 'avc1.640028', 'avc1.4D4034', 'avc1.4D402A', 'avc1.42E034', 'avc1.42E02A', 'avc1.42001f'].map(c => [c, 'avc']),
|
| 144 |
+
['hvc1.1.6.L123.B0', 'hevc'], ['hvc1.1.6.L150.B0', 'hevc'],
|
| 145 |
+
['vp09.00.41.08', 'vp9'], ['vp09.00.40.08', 'vp9'], ['av01.0.09M.08', 'av1'], ['av01.0.08M.08', 'av1'],
|
| 146 |
+
];
|
| 147 |
+
for (const [codec, mux] of list) {
|
| 148 |
+
for (const hw of ['prefer-hardware', 'no-preference', 'prefer-software']) {
|
| 149 |
+
const cfg = { codec, width: w, height: h, bitrate, framerate: fps, hardwareAcceleration: hw };
|
| 150 |
+
if (mux === 'avc') cfg.avc = { format: 'avc' };
|
| 151 |
+
if (mux === 'hevc') cfg.hevc = { format: 'hevc' };
|
| 152 |
+
try { const r = await VideoEncoder.isConfigSupported(cfg); if (r.supported) return { cfg, mux }; } catch (e) { }
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
return null;
|
| 156 |
+
}
|
| 157 |
+
async function pickAudioCodec() {
|
| 158 |
+
for (const c of [{ codec: 'mp4a.40.2', mux: 'aac' }, { codec: 'opus', mux: 'opus' }]) {
|
| 159 |
+
const cfg = { codec: c.codec, sampleRate: SR, numberOfChannels: 2, bitrate: 192000 };
|
| 160 |
+
try { const r = await AudioEncoder.isConfigSupported(cfg); if (r.supported) return { cfg, mux: c.mux }; } catch (e) { }
|
| 161 |
+
}
|
| 162 |
+
return null;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
async function exportWebCodecs() {
|
| 166 |
+
const [LW, LH] = ex.orient === 'landscape' ? [1920, 1080] : [1080, 1920];
|
| 167 |
+
const s = ex.res, w = Math.round(LW * s / 2) * 2, h = Math.round(LH * s / 2) * 2, fps = ex.fps;
|
| 168 |
+
const bitrate = Math.round((s === 1 ? 10e6 : 5e6) * (fps >= 60 ? 1.6 : fps >= 30 ? 1 : .85));
|
| 169 |
+
const vpick = await pickVideoCodec(w, h, fps, bitrate);
|
| 170 |
+
if (!vpick) throw new Error('此浏览器没有可用的视频编码器');
|
| 171 |
+
const vcfg = vpick.cfg; state.vcodec = vpick.mux;
|
| 172 |
+
const acodec = await pickAudioCodec();
|
| 173 |
+
const target = new ArrayBufferTarget();
|
| 174 |
+
const muxer = new Muxer({ target, fastStart: 'in-memory', firstTimestampBehavior: 'offset',
|
| 175 |
+
video: { codec: vpick.mux, width: w, height: h, frameRate: fps },
|
| 176 |
+
audio: acodec ? { codec: acodec.mux, numberOfChannels: 2, sampleRate: SR } : undefined });
|
| 177 |
+
let err = null;
|
| 178 |
+
const venc = new VideoEncoder({ output: (c, m) => muxer.addVideoChunk(c, m), error: e => err = e });
|
| 179 |
+
venc.configure(vcfg);
|
| 180 |
+
// 音频
|
| 181 |
+
if (acodec) {
|
| 182 |
+
exMsg(`编码音轨(${acodec.mux.toUpperCase()})…`);
|
| 183 |
+
const aenc = new AudioEncoder({ output: (c, m) => muxer.addAudioChunk(c, m), error: e => err = e });
|
| 184 |
+
aenc.configure(acodec.cfg);
|
| 185 |
+
const L = state.buffer.getChannelData(0), R = state.buffer.getChannelData(1), N = L.length, CH = 4800;
|
| 186 |
+
for (let i = 0; i < N; i += CH) {
|
| 187 |
+
const n = Math.min(CH, N - i), data = new Float32Array(n * 2); data.set(L.subarray(i, i + n), 0); data.set(R.subarray(i, i + n), n);
|
| 188 |
+
const ad = new AudioData({ format: 'f32-planar', sampleRate: state.buffer.sampleRate, numberOfFrames: n, numberOfChannels: 2, timestamp: Math.round(i / state.buffer.sampleRate * 1e6), data });
|
| 189 |
+
aenc.encode(ad); ad.close();
|
| 190 |
+
if (aenc.encodeQueueSize > 20) await new Promise(r => setTimeout(r, 0));
|
| 191 |
+
}
|
| 192 |
+
await aenc.flush(); aenc.close();
|
| 193 |
+
}
|
| 194 |
+
// 视频
|
| 195 |
+
const cv = typeof OffscreenCanvas !== 'undefined' ? new OffscreenCanvas(w, h) : Object.assign(document.createElement('canvas'), { width: w, height: h });
|
| 196 |
+
const c2 = cv.getContext('2d', { alpha: false });
|
| 197 |
+
const total = Math.ceil(DUR * fps), t0 = performance.now();
|
| 198 |
+
for (let i = 0; i < total; i++) {
|
| 199 |
+
if (ex.cancel) { venc.close(); throw new Error('已取消'); }
|
| 200 |
+
if (err) throw err;
|
| 201 |
+
c2.setTransform(s, 0, 0, s, 0, 0);
|
| 202 |
+
renderFrame(c2, i / fps, LW, LH, { layerScale: s, subtitles: true });
|
| 203 |
+
const vf = new VideoFrame(cv, { timestamp: Math.round(i * 1e6 / fps), duration: Math.round(1e6 / fps) });
|
| 204 |
+
venc.encode(vf, { keyFrame: i % (fps * 2) === 0 }); vf.close();
|
| 205 |
+
while (venc.encodeQueueSize > 4) await new Promise(r => setTimeout(r, 1));
|
| 206 |
+
if (i % 6 === 0) {
|
| 207 |
+
const k = (i + 1) / total, el = (performance.now() - t0) / 1000, eta = el / k - el;
|
| 208 |
+
exProg(k); exMsg(`渲染第 ${i + 1}/${total} 帧 · ${fps}fps · ${w}×${h} · 预计剩余 ${fmt(eta)}`);
|
| 209 |
+
await new Promise(r => setTimeout(r, 0));
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
await venc.flush(); venc.close();
|
| 213 |
+
if (err) throw err;
|
| 214 |
+
muxer.finalize();
|
| 215 |
+
return new Blob([target.buffer], { type: 'video/mp4' });
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
async function exportRecorder() {
|
| 219 |
+
// 回退:实时录制(时长等于视频时长)
|
| 220 |
+
const [LW, LH] = ex.orient === 'landscape' ? [1920, 1080] : [1080, 1920];
|
| 221 |
+
const s = ex.res, w = Math.round(LW * s / 2) * 2, h = Math.round(LH * s / 2) * 2, fps = ex.fps;
|
| 222 |
+
const cv = Object.assign(document.createElement('canvas'), { width: w, height: h }); const c2 = cv.getContext('2d');
|
| 223 |
+
const ac = new AudioContext({ sampleRate: SR }); const dest = ac.createMediaStreamDestination();
|
| 224 |
+
const src = ac.createBufferSource(); src.buffer = state.buffer; src.connect(dest);
|
| 225 |
+
const stream = new MediaStream([...cv.captureStream(fps).getVideoTracks(), ...dest.stream.getAudioTracks()]);
|
| 226 |
+
const types = ['video/mp4;codecs=avc1.640028,mp4a.40.2', 'video/mp4;codecs=avc1,mp4a', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm'];
|
| 227 |
+
const mime = types.find(t => window.MediaRecorder && MediaRecorder.isTypeSupported(t));
|
| 228 |
+
if (!mime) throw new Error('此浏览器既不支持 WebCodecs 也不支持 MediaRecorder');
|
| 229 |
+
const rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 8e6 }); const parts = [];
|
| 230 |
+
rec.ondataavailable = e => e.data.size && parts.push(e.data);
|
| 231 |
+
const done = new Promise(r => rec.onstop = r);
|
| 232 |
+
await ac.resume(); rec.start(1000); const t0 = ac.currentTime + .1; src.start(t0);
|
| 233 |
+
await new Promise(resolve => {
|
| 234 |
+
const tick = () => {
|
| 235 |
+
const t = ac.currentTime - t0;
|
| 236 |
+
if (ex.cancel || t >= DUR) { resolve(); return; }
|
| 237 |
+
c2.setTransform(s, 0, 0, s, 0, 0); renderFrame(c2, Math.max(0, t), LW, LH, { layerScale: s });
|
| 238 |
+
exProg(t / DUR); exMsg(`实时录制中 ${fmt(t)} / ${fmt(DUR)}(${mime.split(';')[0]})`); requestAnimationFrame(tick);
|
| 239 |
+
}; tick();
|
| 240 |
+
});
|
| 241 |
+
rec.stop(); src.stop(); await done; ac.close();
|
| 242 |
+
if (ex.cancel) throw new Error('已取消');
|
| 243 |
+
if (!mime.startsWith('video/mp4')) exMsg('提示:此浏览器只能录制 WebM,已按 WebM 保存;需要 MP4 请使用 Chrome / Edge / Safari 新版本。');
|
| 244 |
+
return new Blob(parts, { type: mime.split(';')[0] });
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
$('#ex-go').onclick = async () => {
|
| 248 |
+
if (state.exporting || !state.buffer) return;
|
| 249 |
+
pause(); state.exporting = true; ex.cancel = false; state.vcodec = null;
|
| 250 |
+
$('#ex-go').disabled = true; $('#ex-cancel').hidden = false; $('#ex-dl').hidden = true; exProg(0);
|
| 251 |
+
try {
|
| 252 |
+
const useWC = typeof VideoEncoder !== 'undefined' && typeof AudioEncoder !== 'undefined';
|
| 253 |
+
const blob = useWC ? await exportWebCodecs() : await exportRecorder();
|
| 254 |
+
const ext = blob.type.includes('webm') ? 'webm' : 'mp4';
|
| 255 |
+
const url = URL.createObjectURL(blob); const a = $('#ex-dl');
|
| 256 |
+
a.href = url; a.download = `双子座_${ex.orient === 'landscape' ? '横屏' : '竖屏'}_${ex.fps}fps.${ext}`; a.hidden = false;
|
| 257 |
+
a.textContent = `下载 ${ext.toUpperCase()}(${(blob.size / 1048576).toFixed(1)} MB)`;
|
| 258 |
+
exProg(1); exMsg('导出完成 ✓ 点击“下载”保存到本地。' + (state.vcodec && state.vcodec !== 'avc' ? `(此浏览器不支持 H.264 编码,已使用 ${state.vcodec.toUpperCase()} 编码的 MP4;如需最大兼容性请用桌面版 Chrome / Edge 导出)` : '')); a.click();
|
| 259 |
+
} catch (e) { console.error(e); exMsg('导出未完成:' + e.message); }
|
| 260 |
+
state.exporting = false; $('#ex-go').disabled = false; $('#ex-cancel').hidden = true;
|
| 261 |
+
};
|
| 262 |
+
$('#ex-cancel').onclick = () => { ex.cancel = true; };
|
| 263 |
+
|
| 264 |
+
/* ================= 发布素材:文案 + 封面 ================= */
|
| 265 |
+
function copyText(btn, s) {
|
| 266 |
+
const ok = () => { btn.classList.add('done'); btn.textContent = '已复制'; setTimeout(() => { btn.classList.remove('done'); btn.textContent = '复制'; }, 1400); };
|
| 267 |
+
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(s).then(ok, () => fallback());
|
| 268 |
+
else fallback();
|
| 269 |
+
function fallback() { const ta = document.createElement('textarea'); ta.value = s; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); ok(); } catch (e) { } ta.remove(); }
|
| 270 |
+
}
|
| 271 |
+
function field(label, value, extra = '') {
|
| 272 |
+
const d = document.createElement('div'); d.className = 'field';
|
| 273 |
+
d.innerHTML = `<div class="hd"><span><b>${label}</b> ${extra}</span><button class="copy">复制</button></div><pre></pre>`;
|
| 274 |
+
d.querySelector('pre').textContent = value; d.querySelector('.copy').onclick = e => copyText(e.currentTarget, value);
|
| 275 |
+
return d;
|
| 276 |
+
}
|
| 277 |
+
function renderPlatform(p) {
|
| 278 |
+
const box = $('#pub'); box.innerHTML = '';
|
| 279 |
+
const tl = charLen(p.title), tn = p.tags.length;
|
| 280 |
+
box.appendChild(field('标题', p.title, `<span class="count ${tl <= p.titleMax ? 'ok' : 'bad'}">${tl}/${p.titleMax} 字</span>`));
|
| 281 |
+
box.appendChild(field('描述', p.desc));
|
| 282 |
+
box.appendChild(field('话题', tagText(p), `<span class="count ${tn <= p.tagMax ? 'ok' : 'bad'}">${tn} 个${p.tagMax === Infinity ? '(不限)' : ' / 最多 ' + p.tagMax}</span>`));
|
| 283 |
+
const all = `${p.title}\n\n${p.desc}\n\n${tagText(p)}`;
|
| 284 |
+
box.appendChild(field('一键复制全部', all));
|
| 285 |
+
const grid = document.createElement('div'); grid.className = 'covers'; box.appendChild(grid);
|
| 286 |
+
p.covers.forEach(cv => {
|
| 287 |
+
const card = document.createElement('div'); card.className = 'cover';
|
| 288 |
+
const c = document.createElement('canvas'); c.width = cv.w; c.height = cv.h; card.appendChild(c);
|
| 289 |
+
const hd = document.createElement('div'); hd.className = 'hd'; hd.innerHTML = `<span>${p.name} · ${cv.label} · ${cv.w}×${cv.h}</span>`;
|
| 290 |
+
const b = document.createElement('button'); b.className = 'copy'; b.textContent = '下载 PNG';
|
| 291 |
+
b.onclick = () => c.toBlob(bl => { const a = document.createElement('a'); a.href = URL.createObjectURL(bl); a.download = `双子座封面_${p.name}_${cv.ratio.replace(':', 'x')}.png`; a.click(); }, 'image/png');
|
| 292 |
+
hd.appendChild(b); card.appendChild(hd); grid.appendChild(card);
|
| 293 |
+
requestAnimationFrame(() => drawCover(c.getContext('2d'), cv.w, cv.h, cv));
|
| 294 |
+
});
|
| 295 |
+
}
|
| 296 |
+
function buildPublish() {
|
| 297 |
+
const tabs = $('#tabs');
|
| 298 |
+
PLATFORMS.forEach((p, i) => {
|
| 299 |
+
const b = document.createElement('button'); b.textContent = p.name; if (!i) b.classList.add('on');
|
| 300 |
+
b.onclick = () => { tabs.querySelectorAll('button').forEach(x => x.classList.toggle('on', x === b)); renderPlatform(p); };
|
| 301 |
+
tabs.appendChild(b);
|
| 302 |
+
});
|
| 303 |
+
renderPlatform(PLATFORMS[0]);
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
window.__gemini = { state, ex };
|
| 307 |
+
boot();
|
gemini-stars/audio.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 双子座 · 配乐 / 音效 / 旁白 混音(全部在 OfflineAudioContext 中合成,确定性)
|
| 2 |
+
import { TIMELINE, METEORS, rng } from './scenes.js';
|
| 3 |
+
|
| 4 |
+
export const SR = 48000;
|
| 5 |
+
const mtof = m => 440 * Math.pow(2, (m - 69) / 12);
|
| 6 |
+
|
| 7 |
+
// 和声进行(MIDI)
|
| 8 |
+
const PROG = {
|
| 9 |
+
major: [[50, 54, 57, 61], [47, 50, 54, 57], [43, 47, 50, 54], [45, 50, 52, 57]], // Dmaj7 Bm7 Gmaj7 Asus
|
| 10 |
+
minor: [[47, 50, 54, 59], [43, 47, 50, 55], [40, 43, 47, 52], [42, 46, 49, 54]], // Bm G Em F#
|
| 11 |
+
dream: [[50, 54, 57, 64], [43, 47, 50, 57], [47, 50, 54, 61], [45, 49, 52, 59]], // D G Bm A(add)
|
| 12 |
+
};
|
| 13 |
+
const MOOD = {
|
| 14 |
+
intro: { prog: 'dream', arp: 0.0, pad: .9, bell: .6 },
|
| 15 |
+
find: { prog: 'major', arp: .55, pad: .8, bell: .3 },
|
| 16 |
+
myth: { prog: 'minor', arp: .3, pad: 1, bell: .2, drum: 1 },
|
| 17 |
+
castor: { prog: 'dream', arp: .8, pad: .75, bell: .4 },
|
| 18 |
+
pollux: { prog: 'major', arp: .8, pad: .8, bell: .4 },
|
| 19 |
+
meteor: { prog: 'dream', arp: 1, pad: .8, bell: .5 },
|
| 20 |
+
discover: { prog: 'major', arp: .7, pad: .75, bell: .3 },
|
| 21 |
+
zodiac: { prog: 'dream', arp: .6, pad: .85, bell: .5 },
|
| 22 |
+
outro: { prog: 'major', arp: .5, pad: 1, bell: .7 },
|
| 23 |
+
};
|
| 24 |
+
const BEAT = .6, CHORD = BEAT * 8;
|
| 25 |
+
|
| 26 |
+
function makeIR(ctx, sec = 3.6) {
|
| 27 |
+
const len = Math.floor(SR * sec), b = ctx.createBuffer(2, len, SR), r = rng(4242);
|
| 28 |
+
for (let c = 0; c < 2; c++) { const d = b.getChannelData(c); for (let i = 0; i < len; i++) d[i] = (r() * 2 - 1) * Math.pow(1 - i / len, 2.6) * (i < 200 ? i / 200 : 1); }
|
| 29 |
+
return b;
|
| 30 |
+
}
|
| 31 |
+
function makeNoise(ctx, sec = 4) {
|
| 32 |
+
const len = Math.floor(SR * sec), b = ctx.createBuffer(1, len, SR), d = b.getChannelData(0), r = rng(99);
|
| 33 |
+
for (let i = 0; i < len; i++) d[i] = r() * 2 - 1; return b;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export async function buildSoundtrack(onStatus = () => { }, baseUrl = '') {
|
| 37 |
+
const dur = TIMELINE.duration;
|
| 38 |
+
const ctx = new OfflineAudioContext(2, Math.ceil(SR * dur), SR);
|
| 39 |
+
// 总线
|
| 40 |
+
const master = ctx.createDynamicsCompressor(); master.threshold.value = -14; master.ratio.value = 3; master.attack.value = .01; master.release.value = .25;
|
| 41 |
+
const out = ctx.createGain(); out.gain.value = 1.2; master.connect(out);
|
| 42 |
+
const limiter = ctx.createDynamicsCompressor(); limiter.threshold.value = -1.5; limiter.knee.value = 0; limiter.ratio.value = 20; limiter.attack.value = .002; limiter.release.value = .1;
|
| 43 |
+
out.connect(limiter); limiter.connect(ctx.destination);
|
| 44 |
+
const music = ctx.createGain(); music.gain.value = .55; music.connect(master);
|
| 45 |
+
const sfx = ctx.createGain(); sfx.gain.value = .7; sfx.connect(master);
|
| 46 |
+
const voice = ctx.createGain(); voice.gain.value = 1.15; voice.connect(master);
|
| 47 |
+
const verb = ctx.createConvolver(); verb.buffer = makeIR(ctx); const verbOut = ctx.createGain(); verbOut.gain.value = .55; verb.connect(verbOut); verbOut.connect(master);
|
| 48 |
+
const musicVerb = ctx.createGain(); musicVerb.gain.value = .8; musicVerb.connect(verb);
|
| 49 |
+
const NOISE = makeNoise(ctx);
|
| 50 |
+
|
| 51 |
+
// 旁白闪避(ducking)
|
| 52 |
+
music.gain.setValueAtTime(.55, 0);
|
| 53 |
+
const iv = [];
|
| 54 |
+
for (const sc of TIMELINE.scenes) for (const ln of sc.lines) {
|
| 55 |
+
const a = Math.max(0.01, ln.start - .3), b = ln.end + .2, last = iv[iv.length - 1];
|
| 56 |
+
if (last && a - last[1] < .9) last[1] = b; else iv.push([a, b]);
|
| 57 |
+
}
|
| 58 |
+
for (const [a, b] of iv) { music.gain.setValueAtTime(.55, a); music.gain.linearRampToValueAtTime(.32, a + .3); music.gain.setValueAtTime(.32, b); music.gain.linearRampToValueAtTime(.55, b + .8); }
|
| 59 |
+
// 结尾淡出
|
| 60 |
+
out.gain.setValueAtTime(1.2, Math.max(0, dur - 4)); out.gain.linearRampToValueAtTime(0, dur);
|
| 61 |
+
|
| 62 |
+
/* ---------- 乐器 ---------- */
|
| 63 |
+
const pad = (t0, len, notes, g) => {
|
| 64 |
+
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 900; lp.Q.value = .6;
|
| 65 |
+
lp.frequency.setValueAtTime(600, t0); lp.frequency.linearRampToValueAtTime(1400, t0 + len * .5); lp.frequency.linearRampToValueAtTime(700, t0 + len);
|
| 66 |
+
const env = ctx.createGain(); env.gain.setValueAtTime(0, t0); env.gain.linearRampToValueAtTime(g, t0 + 1.6); env.gain.setValueAtTime(g, t0 + len - .4); env.gain.linearRampToValueAtTime(0, t0 + len + 1.6);
|
| 67 |
+
lp.connect(env); env.connect(music); env.connect(musicVerb);
|
| 68 |
+
notes.forEach((m, i) => [-7, 7].forEach(det => {
|
| 69 |
+
const o = ctx.createOscillator(); o.type = i === 0 ? 'triangle' : 'sawtooth'; o.frequency.value = mtof(m); o.detune.value = det;
|
| 70 |
+
const og = ctx.createGain(); og.gain.value = (i === 0 ? .1 : .035); o.connect(og); og.connect(lp); o.start(t0); o.stop(t0 + len + 1.8);
|
| 71 |
+
}));
|
| 72 |
+
};
|
| 73 |
+
const pluck = (t, m, g, pan = 0) => {
|
| 74 |
+
const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = mtof(m);
|
| 75 |
+
const o2 = ctx.createOscillator(); o2.type = 'sine'; o2.frequency.value = mtof(m) * 2;
|
| 76 |
+
const e = ctx.createGain(); e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(g, t + .008); e.gain.exponentialRampToValueAtTime(.0005, t + 1.4);
|
| 77 |
+
const e2 = ctx.createGain(); e2.gain.value = .3;
|
| 78 |
+
const p = ctx.createStereoPanner(); p.pan.value = pan;
|
| 79 |
+
o.connect(e); o2.connect(e2); e2.connect(e); e.connect(p); p.connect(music); p.connect(musicVerb);
|
| 80 |
+
o.start(t); o2.start(t); o.stop(t + 1.5); o2.stop(t + 1.5);
|
| 81 |
+
};
|
| 82 |
+
const bell = (t, m, g, dest = music) => {
|
| 83 |
+
[[1, 1, 3.2], [2.76, .45, 1.6], [5.4, .25, .9], [8.93, .12, .5]].forEach(([r, a, d]) => {
|
| 84 |
+
if (mtof(m) * r > 16000) return;
|
| 85 |
+
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = mtof(m) * r;
|
| 86 |
+
const e = ctx.createGain(); e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(g * a, t + .005); e.gain.exponentialRampToValueAtTime(.0001, t + d);
|
| 87 |
+
o.connect(e); e.connect(dest); e.connect(verb); o.start(t); o.stop(t + d + .05);
|
| 88 |
+
});
|
| 89 |
+
};
|
| 90 |
+
const sub = (t, f0, f1, len, g) => {
|
| 91 |
+
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f1, t + len);
|
| 92 |
+
const e = ctx.createGain(); e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(g, t + .02); e.gain.exponentialRampToValueAtTime(.0001, t + len);
|
| 93 |
+
o.connect(e); e.connect(sfx); o.start(t); o.stop(t + len + .05);
|
| 94 |
+
};
|
| 95 |
+
const noise = (t, len, type, f0, f1, g, q = 1, pan = 0, attack = .05, toVerb = .5) => {
|
| 96 |
+
const s = ctx.createBufferSource(); s.buffer = NOISE; s.loop = true;
|
| 97 |
+
const f = ctx.createBiquadFilter(); f.type = type; f.Q.value = q; f.frequency.setValueAtTime(f0, t); f.frequency.exponentialRampToValueAtTime(f1, t + len);
|
| 98 |
+
const e = ctx.createGain(); e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(g, t + attack); e.gain.exponentialRampToValueAtTime(.0001, t + len);
|
| 99 |
+
const p = ctx.createStereoPanner(); p.pan.setValueAtTime(-pan, t); p.pan.linearRampToValueAtTime(pan, t + len);
|
| 100 |
+
s.connect(f); f.connect(e); e.connect(p); p.connect(sfx);
|
| 101 |
+
if (toVerb) { const vg = ctx.createGain(); vg.gain.value = toVerb; p.connect(vg); vg.connect(verb); }
|
| 102 |
+
s.start(t, (t * 7.3) % 3); s.stop(t + len + .05);
|
| 103 |
+
};
|
| 104 |
+
const whoosh = (t, g = .5) => noise(t, 1.4, 'bandpass', 250, 3200, g, 1.2, .8, .7);
|
| 105 |
+
const shimmer = (t, g = .12, n = 9, base = 86) => { const r = rng(Math.floor(t * 1000)); for (let i = 0; i < n; i++) bell(t + i * .07 + r() * .05, base + [0, 2, 4, 7, 9, 12][Math.floor(r() * 6)], g * (1 - i / n * .5), sfx); };
|
| 106 |
+
const ping = (t, m = 88, g = .1) => bell(t, m, g, sfx);
|
| 107 |
+
const zip = (t, g = .18, pan = 0) => { noise(t, .7, 'bandpass', 5000, 900, g, 4, pan, .03, .6); const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.setValueAtTime(2400, t); o.frequency.exponentialRampToValueAtTime(500, t + .5); const e = ctx.createGain(); e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(g * .15, t + .02); e.gain.exponentialRampToValueAtTime(.0001, t + .5); o.connect(e); e.connect(sfx); e.connect(verb); o.start(t); o.stop(t + .55); };
|
| 108 |
+
const boom = (t, g = .6) => { sub(t, 90, 32, 2.2, g); noise(t, 1.6, 'lowpass', 400, 60, g * .5, .7, 0, .01, .3); };
|
| 109 |
+
const thunder = (t) => { boom(t, .7); const r = rng(77); for (let i = 0; i < 7; i++) noise(t + r() * 1.2, .4 + r() * .8, 'lowpass', 1800, 120, .25 + r() * .2, .8, r() - .5, .005, .6); };
|
| 110 |
+
const arrow = (t) => { noise(t, .35, 'bandpass', 1500, 4500, .5, 3, -1, .01, .2); };
|
| 111 |
+
|
| 112 |
+
/* ---------- 作曲 ---------- */
|
| 113 |
+
onStatus('作曲中…');
|
| 114 |
+
const S = TIMELINE.scenes;
|
| 115 |
+
S.forEach((sc, si) => {
|
| 116 |
+
const md = MOOD[sc.id], prog = PROG[md.prog];
|
| 117 |
+
const r = rng(500 + si);
|
| 118 |
+
let t = sc.start, ci = 0;
|
| 119 |
+
while (t < sc.end - .5) {
|
| 120 |
+
const len = Math.min(CHORD, sc.end - t + .4);
|
| 121 |
+
const ch = prog[ci % prog.length];
|
| 122 |
+
pad(t, len, ch, md.pad * .9);
|
| 123 |
+
// 低音
|
| 124 |
+
sub(t, mtof(ch[0] - 12), mtof(ch[0] - 12) * .999, len + .5, .16 * md.pad);
|
| 125 |
+
// 琶音
|
| 126 |
+
if (md.arp > 0) {
|
| 127 |
+
const steps = Math.floor(len / (BEAT / 2));
|
| 128 |
+
const pat = [0, 1, 2, 3, 2, 1, 3, 2];
|
| 129 |
+
for (let k = 0; k < steps; k++) {
|
| 130 |
+
if (r() > md.arp) continue;
|
| 131 |
+
const n = ch[pat[k % 8] % ch.length] + 24 + (k % 8 === 6 ? 12 : 0);
|
| 132 |
+
pluck(t + k * BEAT / 2, n, .06 * (k % 4 === 0 ? 1.2 : .8), Math.sin(k * 1.3) * .5);
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
// 钟声点缀
|
| 136 |
+
if (md.bell && r() < md.bell) bell(t + BEAT * 4, ch[2] + 24, .06);
|
| 137 |
+
// 神话:定音鼓式低频
|
| 138 |
+
if (md.drum) for (let k = 0; k < 8; k += 4) sub(t + k * BEAT, 70, 40, .9, .22);
|
| 139 |
+
t += CHORD; ci++;
|
| 140 |
+
}
|
| 141 |
+
});
|
| 142 |
+
// 尾声终止和弦
|
| 143 |
+
const outro = S[S.length - 1];
|
| 144 |
+
const fin = outro.lines[2].start - .3;
|
| 145 |
+
[62, 66, 69, 74, 78].forEach((m, i) => bell(fin + i * .12, m, .1));
|
| 146 |
+
|
| 147 |
+
/* ---------- 音效(与画面时间对齐) ---------- */
|
| 148 |
+
onStatus('布置音效…');
|
| 149 |
+
const at = id => S.find(s => s.id === id);
|
| 150 |
+
const Ls = sc => sc.lines.map(l => l.start);
|
| 151 |
+
S.forEach((sc, i) => { if (i > 0) whoosh(sc.start - .55, .35); });
|
| 152 |
+
{ const sc = at('intro'), L = Ls(sc); ping(sc.start + 1.2, 81, .12); ping(sc.start + 2.2, 85, .12); shimmer(L[1] + .6, .1, 12); boom(L[1] + .5, .35); }
|
| 153 |
+
{ const sc = at('find'), L = Ls(sc); shimmer(L[0] + .8, .07, 6, 93);
|
| 154 |
+
for (let k = 0; k < 15; k++) ping(L[3] - .3 + 4.8 * (k + .5) / 15, 84 + [0, 2, 4, 7, 9][k % 5], .06);
|
| 155 |
+
shimmer(L[2] + .9, .08, 6); shimmer(L[3] + 3, .08, 8); }
|
| 156 |
+
{ const sc = at('myth'), L = Ls(sc); boom(sc.start + .3, .45); ping(L[0] + 2, 74, .1); ping(L[0] + 4, 78, .1);
|
| 157 |
+
arrow(L[2] - .25); boom(L[2] + .42, .8); thunder(L[3] + 2); shimmer(L[3] + 2.6, .07, 14, 88);
|
| 158 |
+
shimmer(L[4] + 4.5, .1, 16, 86); boom(L[4] + 4.4, .35); }
|
| 159 |
+
{ const sc = at('castor'), L = Ls(sc); whoosh(L[0] + .2, .3); ping(L[1] + 1.7, 86, .12); for (let k = 0; k < 4; k++) ping(L[2] + .3 + k * .45, 88 + k * 3, .1); shimmer(L[2] + 2.3, .08, 8); ping(L[2] + 5.2, 79, .1); }
|
| 160 |
+
{ const sc = at('pollux'), L = Ls(sc); boom(sc.start + .4, .4); ping(L[1] + .4, 83, .08); ping(L[1] + .9, 86, .08); shimmer(L[1] + 3, .08, 8); ping(L[2] + .2, 81, .12); }
|
| 161 |
+
{ let n = 0; for (const m of METEORS.list) { if (m.br < .55 || n > 60) continue; const t = METEORS.start + m.t; zip(t, .1 + m.br * .1, Math.cos(m.a) * .7); n++; } }
|
| 162 |
+
{ const sc = at('meteor'), L = Ls(sc); shimmer(L[1] + .2, .08, 10, 91); whoosh(L[2] + 1.2, .3); ping(L[2] + 2.2, 76, .12); }
|
| 163 |
+
{ const sc = at('discover'), L = Ls(sc); ping(L[1] + .2, 79, .12); shimmer(L[1] + .8, .07, 8); ping(L[2] + .2, 76, .12);
|
| 164 |
+
for (let k = 0; k < 8; k++) ping(L[2] + k / 2.2, 96, .03); shimmer(L[2] + 1.4, .07, 8); }
|
| 165 |
+
{ const sc = at('zodiac'), L = Ls(sc); ping(sc.start + .5, 74, .1); shimmer(L[1] - .2, .1, 12, 86); for (let k = 0; k < 3; k++) ping(L[2] + 1 + k * .7, 86 + k * 2, .08); }
|
| 166 |
+
{ const sc = at('outro'), L = Ls(sc); shimmer(sc.start + .5, .08, 10); for (let k = 0; k < 3; k++) ping(sc.start + 1 + k * .9, 84 + k * 3, .08); }
|
| 167 |
+
|
| 168 |
+
/* ---------- 旁白 ---------- */
|
| 169 |
+
const lines = S.flatMap(s => s.lines);
|
| 170 |
+
let done = 0;
|
| 171 |
+
const bufs = await Promise.all(lines.map(async ln => {
|
| 172 |
+
const res = await fetch(baseUrl + ln.file); const ab = await res.arrayBuffer();
|
| 173 |
+
const b = await ctx.decodeAudioData(ab); done++; onStatus(`载入旁白 ${done}/${lines.length}`); return b;
|
| 174 |
+
}));
|
| 175 |
+
const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 90; hp.connect(voice);
|
| 176 |
+
const vVerb = ctx.createGain(); vVerb.gain.value = .08; hp.connect(vVerb); vVerb.connect(verb);
|
| 177 |
+
lines.forEach((ln, i) => { const s = ctx.createBufferSource(); s.buffer = bufs[i]; s.connect(hp); s.start(ln.start); });
|
| 178 |
+
|
| 179 |
+
onStatus('混音渲染中…');
|
| 180 |
+
return await ctx.startRendering();
|
| 181 |
+
}
|
gemini-stars/audio/soundtrack.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e83a0eeef43bb12cc2679d08fcef858a2e4a1ead72b7ef54f03121e5bd2e4b9d
|
| 3 |
+
size 5144876
|
gemini-stars/copy.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 各平台发布文案:标题 / 描述 / 话题(含字数与数量上限,页面会自动校验)
|
| 2 |
+
export const PLATFORMS = [
|
| 3 |
+
{
|
| 4 |
+
id: 'xhs', name: '小红书', titleMax: 20, tagMax: 10, tagStyle: 'hash',
|
| 5 |
+
title: '双子座:天上最浪漫的一对兄弟✨',
|
| 6 |
+
desc: `冬夜抬头,东方那两颗肩并肩的亮星,就是双子座的“头”🌟
|
| 7 |
+
🔭 怎么找:猎户座腰带 → 往东北方看
|
| 8 |
+
🏛 神话:凡人哥哥卡斯托尔 × 不朽弟弟波吕克斯,弟弟把永生分了一半给哥哥
|
| 9 |
+
✨ 北河二:看着是一颗星,其实是 6 颗恒星(3 对双星)
|
| 10 |
+
🧡 北河三:橙色巨星,比“二号”哥哥还亮,身边还有一颗行星
|
| 11 |
+
☄️ 双子座流星雨:12 月中旬,母体竟是小行星法厄同
|
| 12 |
+
🪐 天王星、冥王星的发现都和它有关
|
| 13 |
+
用 3 分半的动画讲给你听,下一个晴夜记得抬头找找它们~`,
|
| 14 |
+
tags: ['双子座', '星座', '天文科普', '星空', '希腊神话', '双子座流星雨', '冬季星空', '观星', '涨知识', '科普动画'],
|
| 15 |
+
covers: [
|
| 16 |
+
{ ratio: '4:3', w: 1440, h: 1080, label: '横封面 4:3', hook: '天上最浪漫的一对兄弟', tags: ['神话', '六星家族', '流星雨'] },
|
| 17 |
+
{ ratio: '3:4', w: 1080, h: 1440, label: '竖封面 3:4', hook: '天上最浪漫的一对兄弟', tags: ['神话', '六星家族', '流星雨'] },
|
| 18 |
+
],
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
id: 'douyin', name: '抖音', titleMax: 30, tagMax: 5, tagStyle: 'hash',
|
| 22 |
+
title: '一颗星藏着6颗恒星?3分半看懂双子座的浪漫与秘密',
|
| 23 |
+
desc: '肉眼看是一颗星,望远镜里却是 6 颗恒星!双子座的兄弟神话、橙色巨星北河三、母体是小行星的流星雨,一次讲清楚。',
|
| 24 |
+
tags: ['双子座', '天文科普', '星空', '双子座流星雨', '涨知识'],
|
| 25 |
+
covers: [
|
| 26 |
+
{ ratio: '4:3', w: 1440, h: 1080, label: '横封面 4:3', hook: '一颗星藏着 6 颗恒星?', tags: ['北河二', '北河三', '流星雨'] },
|
| 27 |
+
{ ratio: '3:4', w: 1080, h: 1440, label: '竖封面 3:4', hook: '一颗星藏着 6 颗恒星?', tags: ['北河二', '北河三', '流星雨'] },
|
| 28 |
+
],
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
id: 'bili', name: '哔哩哔哩', titleMax: 80, tagMax: 10, tagStyle: 'plain',
|
| 32 |
+
title: '【星空动画】双子座有多神奇?一颗星竟是6颗恒星,流星雨母体是小行星,还见证了天王星和冥王星的发现',
|
| 33 |
+
desc: `全程手工编程的星空动画,带你认识双子座:
|
| 34 |
+
00:00 序章 · 冬夜的两颗星
|
| 35 |
+
00:15 怎样找到它(猎户座腰带 → 东北方)
|
| 36 |
+
00:40 神话 · 卡斯托尔与波吕克斯
|
| 37 |
+
01:22 北河二 · 6 颗恒星组成的三对双星(约 51 光年)
|
| 38 |
+
01:45 北河三 · 橙色巨星与它的行星(约 34 光年)
|
| 39 |
+
02:06 双子座流星雨 · 母体为小行星 3200 法厄同
|
| 40 |
+
02:28 星空里的发现 · 1781 天王星 / 1930 冥王星
|
| 41 |
+
02:46 黄道与星座文化(占星部分仅为文化趣味)
|
| 42 |
+
03:12 尾声
|
| 43 |
+
数据为常见天文资料的近似值,欢迎在评论区补充指正~`,
|
| 44 |
+
tags: ['双子座', '天文', '科普', '星座', '希腊神话', '流星雨', '星空', '宇宙', '动画', '知识分享'],
|
| 45 |
+
covers: [
|
| 46 |
+
{ ratio: '4:3', w: 1440, h: 1080, label: '视频封面 4:3', hook: '双子座到底有多神奇?', tags: ['神话', '六星家族', '流星雨'], badge: '星空动画' },
|
| 47 |
+
{ ratio: '16:9', w: 1920, h: 1080, label: '个人空间封面 16:9', hook: '双子座到底有多神奇?', tags: ['神话', '六星家族', '流星雨'], badge: '星空动画' },
|
| 48 |
+
],
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
id: 'wx', name: '微信视频号', titleMax: 16, tagMax: Infinity, tagStyle: 'hash',
|
| 52 |
+
title: '双子座:并肩千年的两颗星',
|
| 53 |
+
desc: '冬夜东方的两颗亮星,是一对兄弟的约定。3 分半动画,带你读懂双子座的神话与科学。',
|
| 54 |
+
tags: ['双子座', '星座', '天文科普', '星空', '希腊神话', '流星雨', '冬季星空', '观星', '科普', '宇宙'],
|
| 55 |
+
covers: [
|
| 56 |
+
{ ratio: '3:4', w: 1080, h: 1440, label: '个人主页卡片 3:4', hook: '并肩千年的两颗星', tags: ['神话', '科学', '流星雨'] },
|
| 57 |
+
{ ratio: '4:3', w: 1440, h: 1080, label: '分享卡片 4:3', hook: '并肩千年的两颗星', tags: ['神话', '科学', '流星雨'] },
|
| 58 |
+
],
|
| 59 |
+
},
|
| 60 |
+
];
|
| 61 |
+
export const charLen = s => Array.from(s).length;
|
| 62 |
+
export const tagText = p => p.tags.map(t => (p.tagStyle === 'hash' ? '#' + t : t)).join(p.tagStyle === 'hash' ? ' ' : ',');
|
gemini-stars/fonts/OFL.txt
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
|
| 2 |
+
|
| 3 |
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
| 4 |
+
This license is copied below, and is also available with a FAQ at:
|
| 5 |
+
https://scripts.sil.org/OFL
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
-----------------------------------------------------------
|
| 9 |
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
| 10 |
+
-----------------------------------------------------------
|
| 11 |
+
|
| 12 |
+
PREAMBLE
|
| 13 |
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
| 14 |
+
development of collaborative font projects, to support the font creation
|
| 15 |
+
efforts of academic and linguistic communities, and to provide a free and
|
| 16 |
+
open framework in which fonts may be shared and improved in partnership
|
| 17 |
+
with others.
|
| 18 |
+
|
| 19 |
+
The OFL allows the licensed fonts to be used, studied, modified and
|
| 20 |
+
redistributed freely as long as they are not sold by themselves. The
|
| 21 |
+
fonts, including any derivative works, can be bundled, embedded,
|
| 22 |
+
redistributed and/or sold with any software provided that any reserved
|
| 23 |
+
names are not used by derivative works. The fonts and derivatives,
|
| 24 |
+
however, cannot be released under any other type of license. The
|
| 25 |
+
requirement for fonts to remain under this license does not apply
|
| 26 |
+
to any document created using the fonts or their derivatives.
|
| 27 |
+
|
| 28 |
+
DEFINITIONS
|
| 29 |
+
"Font Software" refers to the set of files released by the Copyright
|
| 30 |
+
Holder(s) under this license and clearly marked as such. This may
|
| 31 |
+
include source files, build scripts and documentation.
|
| 32 |
+
|
| 33 |
+
"Reserved Font Name" refers to any names specified as such after the
|
| 34 |
+
copyright statement(s).
|
| 35 |
+
|
| 36 |
+
"Original Version" refers to the collection of Font Software components as
|
| 37 |
+
distributed by the Copyright Holder(s).
|
| 38 |
+
|
| 39 |
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
| 40 |
+
or substituting -- in part or in whole -- any of the components of the
|
| 41 |
+
Original Version, by changing formats or by porting the Font Software to a
|
| 42 |
+
new environment.
|
| 43 |
+
|
| 44 |
+
"Author" refers to any designer, engineer, programmer, technical
|
| 45 |
+
writer or other person who contributed to the Font Software.
|
| 46 |
+
|
| 47 |
+
PERMISSION & CONDITIONS
|
| 48 |
+
Permission is hereby granted, free of charge, to any person obtaining
|
| 49 |
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
| 50 |
+
redistribute, and sell modified and unmodified copies of the Font
|
| 51 |
+
Software, subject to the following conditions:
|
| 52 |
+
|
| 53 |
+
1) Neither the Font Software nor any of its individual components,
|
| 54 |
+
in Original or Modified Versions, may be sold by itself.
|
| 55 |
+
|
| 56 |
+
2) Original or Modified Versions of the Font Software may be bundled,
|
| 57 |
+
redistributed and/or sold with any software, provided that each copy
|
| 58 |
+
contains the above copyright notice and this license. These can be
|
| 59 |
+
included either as stand-alone text files, human-readable headers or
|
| 60 |
+
in the appropriate machine-readable metadata fields within text or
|
| 61 |
+
binary files as long as those fields can be easily viewed by the user.
|
| 62 |
+
|
| 63 |
+
3) No Modified Version of the Font Software may use the Reserved Font
|
| 64 |
+
Name(s) unless explicit written permission is granted by the corresponding
|
| 65 |
+
Copyright Holder. This restriction only applies to the primary font name as
|
| 66 |
+
presented to the users.
|
| 67 |
+
|
| 68 |
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
| 69 |
+
Software shall not be used to promote, endorse or advertise any
|
| 70 |
+
Modified Version, except to acknowledge the contribution(s) of the
|
| 71 |
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
| 72 |
+
permission.
|
| 73 |
+
|
| 74 |
+
5) The Font Software, modified or unmodified, in part or in whole,
|
| 75 |
+
must be distributed entirely under this license, and must not be
|
| 76 |
+
distributed under any other license. The requirement for fonts to
|
| 77 |
+
remain under this license does not apply to any document created
|
| 78 |
+
using the Font Software.
|
| 79 |
+
|
| 80 |
+
TERMINATION
|
| 81 |
+
This license becomes null and void if any of the above conditions are
|
| 82 |
+
not met.
|
| 83 |
+
|
| 84 |
+
DISCLAIMER
|
| 85 |
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
| 86 |
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
| 87 |
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
| 88 |
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
| 89 |
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
| 90 |
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
| 91 |
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
| 92 |
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
| 93 |
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
gemini-stars/fonts/gemini-sans.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:93582b984a341bb481a349295ad3940731aa1295f9249f16aaed5f8e0a189dd7
|
| 3 |
+
size 218016
|
gemini-stars/fonts/gemini-serif.woff2
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:dc92f188e2544138a4defe3c38b1e77be2a22516ac2fa68d34d375b84e33f120
|
| 3 |
+
size 285380
|
gemini-stars/index.html
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="theme-color" content="#05060f">
|
| 8 |
+
<link rel="preload" href="fonts/gemini-sans.woff2" as="font" type="font/woff2" crossorigin>
|
| 9 |
+
<link rel="preload" href="fonts/gemini-serif.woff2" as="font" type="font/woff2" crossorigin>
|
| 10 |
+
<link rel="stylesheet" href="style.css">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<header class="top">
|
| 14 |
+
<div class="brand"><span class="glyph">Ⅱ</span><div><h1>双子座 · 星河里的一对兄弟</h1><p>动画解说 · 配乐音效 · 旁白字幕 · 本地导出 MP4</p></div></div>
|
| 15 |
+
<div class="orient" role="group" aria-label="画幅">
|
| 16 |
+
<button data-o="landscape" class="on">横屏 16:9</button><button data-o="portrait">竖屏 9:16</button>
|
| 17 |
+
</div>
|
| 18 |
+
</header>
|
| 19 |
+
|
| 20 |
+
<main>
|
| 21 |
+
<section class="player">
|
| 22 |
+
<div class="stage" id="stage">
|
| 23 |
+
<canvas id="view"></canvas>
|
| 24 |
+
<div class="gate" id="gate">
|
| 25 |
+
<button id="bigplay" disabled><span class="tri"></span></button>
|
| 26 |
+
<p id="status">载入中…</p>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
<div class="bar">
|
| 30 |
+
<button id="play" class="ic" aria-label="播放/暂停" disabled></button>
|
| 31 |
+
<span id="time">00:00 / 00:00</span>
|
| 32 |
+
<input id="seek" type="range" min="0" max="1000" value="0" step="1">
|
| 33 |
+
<button id="sub" class="ic small on" title="字幕">字</button>
|
| 34 |
+
<button id="mute" class="ic small" title="静音"><svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M4 9h4l5-4v14l-5-4H4z"/><path class="wave" d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/><path class="x" d="M16 9l5 6M21 9l-5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
|
| 35 |
+
<button id="fs" class="ic small" title="全屏"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/></svg></button>
|
| 36 |
+
</div>
|
| 37 |
+
<div class="chapters" id="chapters"></div>
|
| 38 |
+
</section>
|
| 39 |
+
|
| 40 |
+
<section class="panel" id="export">
|
| 41 |
+
<h2>导出 MP4 <small>浏览器本地渲染,不上传任何数据</small></h2>
|
| 42 |
+
<div class="row">
|
| 43 |
+
<label>画幅</label>
|
| 44 |
+
<div class="seg" id="ex-orient"><button data-v="landscape" class="on">横屏 1920×1080</button><button data-v="portrait">竖屏 1080×1920</button></div>
|
| 45 |
+
</div>
|
| 46 |
+
<div class="row">
|
| 47 |
+
<label>帧率</label>
|
| 48 |
+
<div class="seg" id="ex-fps"><button data-v="24">24 fps</button><button data-v="30" class="on">30 fps</button><button data-v="60">60 fps</button></div>
|
| 49 |
+
</div>
|
| 50 |
+
<div class="row">
|
| 51 |
+
<label>清晰度</label>
|
| 52 |
+
<div class="seg" id="ex-res"><button data-v="1" class="on">1080p</button><button data-v="0.6667">720p</button></div>
|
| 53 |
+
</div>
|
| 54 |
+
<div class="row">
|
| 55 |
+
<button id="ex-go" class="primary" disabled>开始导出</button>
|
| 56 |
+
<button id="ex-cancel" class="ghost" hidden>取消</button>
|
| 57 |
+
<a id="ex-dl" class="primary" hidden download>下载 MP4</a>
|
| 58 |
+
</div>
|
| 59 |
+
<div class="prog" hidden id="ex-prog"><div class="fill" id="ex-fill"></div></div>
|
| 60 |
+
<p class="note" id="ex-msg">推荐桌面版 Chrome / Edge(支持 WebCodecs,离线逐帧渲染,速度快且帧率精确);不支持时会自动改用实时录制。</p>
|
| 61 |
+
</section>
|
| 62 |
+
|
| 63 |
+
<section class="panel" id="publish">
|
| 64 |
+
<h2>多平台发布素材 <small>标题 · 描述 · 话题 · 封面</small></h2>
|
| 65 |
+
<div class="tabs" id="tabs"></div>
|
| 66 |
+
<div id="pub"></div>
|
| 67 |
+
</section>
|
| 68 |
+
</main>
|
| 69 |
+
<footer>全新原创制作 · 画面由 Canvas 程序实时绘制 · 配乐与音效由 Web Audio 合成 · 旁白为 AI 语音</footer>
|
| 70 |
+
<script type="module" src="app.js"></script>
|
| 71 |
+
</body>
|
| 72 |
+
</html>
|
gemini-stars/scenes.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
| 3 |
import { TIMELINE } from './timeline.js';
|
| 4 |
export { TIMELINE };
|
| 5 |
|
| 6 |
-
export const SERIF = '"Noto Serif SC","Source Han Serif SC","Songti SC","STSong","SimSun",serif';
|
| 7 |
-
export const SANS = '"Noto Sans SC","PingFang SC","Microsoft YaHei","Hiragino Sans GB","Source Han Sans SC",sans-serif';
|
| 8 |
|
| 9 |
/* ---------------- 数学工具 ---------------- */
|
| 10 |
const TAU = Math.PI * 2;
|
|
@@ -13,7 +13,7 @@ export const lerp = (a, b, k) => a + (b - a) * k;
|
|
| 13 |
export const sstep = (a, b, x) => { const k = clamp((x - a) / (b - a)); return k * k * (3 - 2 * k); };
|
| 14 |
const easeIO = k => (k < .5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2);
|
| 15 |
const easeOut = k => 1 - Math.pow(1 - clamp(k), 3);
|
| 16 |
-
const backOut = k => { k = clamp(k); const c = 1.7; return 1 + (c + 1) * Math.pow(k - 1, 3) + c * Math.pow(k - 1, 2); };
|
| 17 |
export function rng(seed) {
|
| 18 |
let a = seed >>> 0;
|
| 19 |
return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
@@ -422,7 +422,7 @@ SC.myth = (ctx, lt, D, W, H, L, t) => {
|
|
| 422 |
const zA = sstep(L[3] + 2, L[3] + 3, lt) * (1 - sstep(L[4] + .8, L[4] + 1.8, lt));
|
| 423 |
const zf = zA * (.7 + .3 * Math.sin(lt * 14) * Math.sin(lt * 5));
|
| 424 |
bolt(ctx, W / 2, H * (port ? .12 : .14), 1.4 * u, zf);
|
| 425 |
-
text(ctx, '宙 斯', W / 2, H * (port ? .12 : .14)
|
| 426 |
// 不朽之光流向哥哥
|
| 427 |
const flow = sstep(L[3] + 2.5, L[3] + 3.5, lt) * (1 - sstep(L[4] + 4, L[4] + 5, lt));
|
| 428 |
if (flow > 0) {
|
|
@@ -617,7 +617,7 @@ export const METEORS = (() => {
|
|
| 617 |
const peak = tt > L[1] - .5 && tt < L[2] + 1.5;
|
| 618 |
const end = L[2] + 2.5;
|
| 619 |
if (tt > end && tt < D - 2) { tt += 1.2; continue; }
|
| 620 |
-
out.push({ t: tt, a: r() *
|
| 621 |
tt += peak ? .05 + r() * .14 : .35 + r() * .7;
|
| 622 |
}
|
| 623 |
return { list: out, start: sc.start };
|
|
@@ -642,7 +642,7 @@ SC.meteor = (ctx, lt, D, W, H, L, t) => {
|
|
| 642 |
const x0 = x - Math.cos(m.a) * tail, y0 = y - Math.sin(m.a) * tail;
|
| 643 |
const a = Math.sin(k * Math.PI) * (.5 + m.br * .5);
|
| 644 |
const gr = ctx.createLinearGradient(x0, y0, x, y); gr.addColorStop(0, 'rgba(255,255,255,0)'); gr.addColorStop(.7, `rgba(190,220,255,${a * .6})`); gr.addColorStop(1, `rgba(255,255,255,${a})`);
|
| 645 |
-
ctx.strokeStyle = gr; ctx.lineWidth = (
|
| 646 |
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x, y); ctx.stroke();
|
| 647 |
drawStar(ctx, x, y, (3 + m.br * 4) * u, '#e7f1ff', a);
|
| 648 |
}
|
|
@@ -731,7 +731,7 @@ SC.discover = (ctx, lt, D, W, H, L, t) => {
|
|
| 731 |
panel(p2[0], p2[1] + (1 - a2) * 40 * u, a2, '1930', '克莱德·汤博 · 比对照相底片');
|
| 732 |
// 天王星 + 望远镜
|
| 733 |
if (a1 > 0) {
|
| 734 |
-
const x = p1[0] + pw * .72, y = p1[1] + ph * .55, r = Math.min(pw, ph) * .2 * backOut(sstep(L[1] + .6, L[1] + 1.8, lt));
|
| 735 |
ctx.save(); ctx.globalAlpha = a1;
|
| 736 |
const g = ctx.createRadialGradient(x - r * .3, y - r * .3, r * .1, x, y, r); g.addColorStop(0, '#e6ffff'); g.addColorStop(.6, '#7fd7e6'); g.addColorStop(1, '#2c7c93');
|
| 737 |
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, Math.max(r, .1), 0, TAU); ctx.fill();
|
|
@@ -757,7 +757,7 @@ SC.discover = (ctx, lt, D, W, H, L, t) => {
|
|
| 757 |
ctx.beginPath(); ctx.arc(dx, dy, 3.5 * u, 0, TAU); ctx.fill();
|
| 758 |
ctx.strokeStyle = '#ff8fbf'; ctx.lineWidth = 2 * u; ctx.beginPath(); ctx.arc(dx, dy, 16 * u, 0, TAU); ctx.stroke(); ctx.restore();
|
| 759 |
});
|
| 760 |
-
const x = p2[0] + pw * .82, y = p2[1] + ph * .58, r = Math.min(pw, ph) * .13 * backOut(sstep(L[2] + 1.2, L[2] + 2.4, lt));
|
| 761 |
ctx.save(); ctx.globalAlpha = a2; const g = ctx.createRadialGradient(x - r * .3, y - r * .3, r * .1, x, y, Math.max(r, .1)); g.addColorStop(0, '#fff1e6'); g.addColorStop(.6, '#d8b29a'); g.addColorStop(1, '#6b4d45');
|
| 762 |
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, Math.max(r, .1), 0, TAU); ctx.fill(); ctx.restore();
|
| 763 |
text(ctx, '冥王星', x, y + r + 30 * u, 26 * u, '#ffc6dc', 'center', a2 * sstep(L[2] + 1.5, L[2] + 2.5, lt), SANS, 700);
|
|
@@ -873,12 +873,12 @@ export function renderFrame(ctx, t, W, H, opt = {}) {
|
|
| 873 |
// 场景(淡入淡出)
|
| 874 |
const fa = Math.min(sstep(0, .9, lt), 1 - sstep(D - .8, D, lt));
|
| 875 |
ctx.save(); ctx.globalAlpha = 1;
|
| 876 |
-
if (sc.id === 'intro') { SC[sc.id](ctx, lt, D, W, H, L, t); }
|
| 877 |
else {
|
| 878 |
// 用离屏层实现整体淡化
|
| 879 |
const layer = getLayer(W, H, opt.layerScale || 1); const lc = layer.getContext('2d');
|
| 880 |
lc.setTransform(1, 0, 0, 1, 0, 0); lc.clearRect(0, 0, layer.width, layer.height); lc.setTransform(layer.width / W, 0, 0, layer.height / H, 0, 0);
|
| 881 |
-
SC[sc.id](lc, lt, D, W, H, L, t);
|
| 882 |
ctx.globalAlpha = sc.id === 'outro' ? Math.max(fa, sstep(D - .8, D, lt)) : fa; ctx.drawImage(layer, 0, 0, W, H);
|
| 883 |
}
|
| 884 |
ctx.restore();
|
|
@@ -947,7 +947,7 @@ export function drawCover(ctx, W, H, cfg) {
|
|
| 947 |
const size = (port ? 190 : wide ? 170 : 160) * u;
|
| 948 |
ctx.save(); ctx.font = `900 ${size}px ${SERIF}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.shadowColor = 'rgba(255,170,70,.85)'; ctx.shadowBlur = 40 * u;
|
| 949 |
ctx.fillStyle = goldGrad(ctx, tx - size * 1.6, ty - size / 2, tx + size * 1.6, ty + size / 2); ctx.fillText('双子座', tx, ty); ctx.restore();
|
| 950 |
-
text(ctx, cfg.hook || '天上最浪漫的一对兄弟', tx, ty + size * .
|
| 951 |
// 卖点标签
|
| 952 |
const tags = cfg.tags || ['神话', '六星家族', '流星雨'];
|
| 953 |
const ty2 = port ? H * .9 : wide ? H * .4 + size * 1.55 : H * .9;
|
|
|
|
| 3 |
import { TIMELINE } from './timeline.js';
|
| 4 |
export { TIMELINE };
|
| 5 |
|
| 6 |
+
export const SERIF = '"Gemini Serif","Noto Serif SC","Source Han Serif SC","Songti SC","STSong","SimSun",serif';
|
| 7 |
+
export const SANS = '"Gemini Sans","Noto Sans SC","PingFang SC","Microsoft YaHei","Hiragino Sans GB","Source Han Sans SC",sans-serif';
|
| 8 |
|
| 9 |
/* ---------------- 数学工具 ---------------- */
|
| 10 |
const TAU = Math.PI * 2;
|
|
|
|
| 13 |
export const sstep = (a, b, x) => { const k = clamp((x - a) / (b - a)); return k * k * (3 - 2 * k); };
|
| 14 |
const easeIO = k => (k < .5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2);
|
| 15 |
const easeOut = k => 1 - Math.pow(1 - clamp(k), 3);
|
| 16 |
+
const backOut = k => { k = clamp(k); const c = 1.7; return Math.max(0, 1 + (c + 1) * Math.pow(k - 1, 3) + c * Math.pow(k - 1, 2)); };
|
| 17 |
export function rng(seed) {
|
| 18 |
let a = seed >>> 0;
|
| 19 |
return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
|
|
| 422 |
const zA = sstep(L[3] + 2, L[3] + 3, lt) * (1 - sstep(L[4] + .8, L[4] + 1.8, lt));
|
| 423 |
const zf = zA * (.7 + .3 * Math.sin(lt * 14) * Math.sin(lt * 5));
|
| 424 |
bolt(ctx, W / 2, H * (port ? .12 : .14), 1.4 * u, zf);
|
| 425 |
+
text(ctx, '宙 斯', W / 2 + 60 * u, H * (port ? .12 : .14), 30 * u, '#fff2b0', 'left', zA, SERIF, 700);
|
| 426 |
// 不朽之光流向哥哥
|
| 427 |
const flow = sstep(L[3] + 2.5, L[3] + 3.5, lt) * (1 - sstep(L[4] + 4, L[4] + 5, lt));
|
| 428 |
if (flow > 0) {
|
|
|
|
| 617 |
const peak = tt > L[1] - .5 && tt < L[2] + 1.5;
|
| 618 |
const end = L[2] + 2.5;
|
| 619 |
if (tt > end && tt < D - 2) { tt += 1.2; continue; }
|
| 620 |
+
out.push({ t: tt, a: -.12 * Math.PI + r() * 1.24 * Math.PI, len: .5 + r() * .5, sp: .9 + r() * .6, br: r(), d0: .03 + r() * .12 });
|
| 621 |
tt += peak ? .05 + r() * .14 : .35 + r() * .7;
|
| 622 |
}
|
| 623 |
return { list: out, start: sc.start };
|
|
|
|
| 642 |
const x0 = x - Math.cos(m.a) * tail, y0 = y - Math.sin(m.a) * tail;
|
| 643 |
const a = Math.sin(k * Math.PI) * (.5 + m.br * .5);
|
| 644 |
const gr = ctx.createLinearGradient(x0, y0, x, y); gr.addColorStop(0, 'rgba(255,255,255,0)'); gr.addColorStop(.7, `rgba(190,220,255,${a * .6})`); gr.addColorStop(1, `rgba(255,255,255,${a})`);
|
| 645 |
+
ctx.strokeStyle = gr; ctx.lineWidth = (2 + m.br * 3) * u; ctx.lineCap = 'round';
|
| 646 |
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x, y); ctx.stroke();
|
| 647 |
drawStar(ctx, x, y, (3 + m.br * 4) * u, '#e7f1ff', a);
|
| 648 |
}
|
|
|
|
| 731 |
panel(p2[0], p2[1] + (1 - a2) * 40 * u, a2, '1930', '克莱德·汤博 · 比对照相底片');
|
| 732 |
// 天王星 + 望远镜
|
| 733 |
if (a1 > 0) {
|
| 734 |
+
const x = p1[0] + pw * .72, y = p1[1] + ph * .55, r = Math.max(.5, Math.min(pw, ph) * .2 * backOut(sstep(L[1] + .6, L[1] + 1.8, lt)));
|
| 735 |
ctx.save(); ctx.globalAlpha = a1;
|
| 736 |
const g = ctx.createRadialGradient(x - r * .3, y - r * .3, r * .1, x, y, r); g.addColorStop(0, '#e6ffff'); g.addColorStop(.6, '#7fd7e6'); g.addColorStop(1, '#2c7c93');
|
| 737 |
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, Math.max(r, .1), 0, TAU); ctx.fill();
|
|
|
|
| 757 |
ctx.beginPath(); ctx.arc(dx, dy, 3.5 * u, 0, TAU); ctx.fill();
|
| 758 |
ctx.strokeStyle = '#ff8fbf'; ctx.lineWidth = 2 * u; ctx.beginPath(); ctx.arc(dx, dy, 16 * u, 0, TAU); ctx.stroke(); ctx.restore();
|
| 759 |
});
|
| 760 |
+
const x = p2[0] + pw * .82, y = p2[1] + ph * .58, r = Math.max(.5, Math.min(pw, ph) * .13 * backOut(sstep(L[2] + 1.2, L[2] + 2.4, lt)));
|
| 761 |
ctx.save(); ctx.globalAlpha = a2; const g = ctx.createRadialGradient(x - r * .3, y - r * .3, r * .1, x, y, Math.max(r, .1)); g.addColorStop(0, '#fff1e6'); g.addColorStop(.6, '#d8b29a'); g.addColorStop(1, '#6b4d45');
|
| 762 |
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, Math.max(r, .1), 0, TAU); ctx.fill(); ctx.restore();
|
| 763 |
text(ctx, '冥王星', x, y + r + 30 * u, 26 * u, '#ffc6dc', 'center', a2 * sstep(L[2] + 1.5, L[2] + 2.5, lt), SANS, 700);
|
|
|
|
| 873 |
// 场景(淡入淡出)
|
| 874 |
const fa = Math.min(sstep(0, .9, lt), 1 - sstep(D - .8, D, lt));
|
| 875 |
ctx.save(); ctx.globalAlpha = 1;
|
| 876 |
+
if (sc.id === 'intro') { try { SC[sc.id](ctx, lt, D, W, H, L, t); } catch (e) { console.warn('frame', t, e); } }
|
| 877 |
else {
|
| 878 |
// 用离屏层实现整体淡化
|
| 879 |
const layer = getLayer(W, H, opt.layerScale || 1); const lc = layer.getContext('2d');
|
| 880 |
lc.setTransform(1, 0, 0, 1, 0, 0); lc.clearRect(0, 0, layer.width, layer.height); lc.setTransform(layer.width / W, 0, 0, layer.height / H, 0, 0);
|
| 881 |
+
try { SC[sc.id](lc, lt, D, W, H, L, t); } catch (e) { console.warn('frame', t, e); }
|
| 882 |
ctx.globalAlpha = sc.id === 'outro' ? Math.max(fa, sstep(D - .8, D, lt)) : fa; ctx.drawImage(layer, 0, 0, W, H);
|
| 883 |
}
|
| 884 |
ctx.restore();
|
|
|
|
| 947 |
const size = (port ? 190 : wide ? 170 : 160) * u;
|
| 948 |
ctx.save(); ctx.font = `900 ${size}px ${SERIF}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.shadowColor = 'rgba(255,170,70,.85)'; ctx.shadowBlur = 40 * u;
|
| 949 |
ctx.fillStyle = goldGrad(ctx, tx - size * 1.6, ty - size / 2, tx + size * 1.6, ty + size / 2); ctx.fillText('双子座', tx, ty); ctx.restore();
|
| 950 |
+
text(ctx, cfg.hook || '天上最浪漫的一对兄弟', tx, ty + size * .9, (port ? 54 : 48) * u, '#fff4dc', 'center', 1, SERIF, 800, 'rgba(0,0,0,.9)');
|
| 951 |
// 卖点标签
|
| 952 |
const tags = cfg.tags || ['神话', '六星家族', '流星雨'];
|
| 953 |
const ty2 = port ? H * .9 : wide ? H * .4 + size * 1.55 : H * .9;
|
gemini-stars/style.css
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@font-face{font-family:"Gemini Sans";src:url(fonts/gemini-sans.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
| 2 |
+
@font-face{font-family:"Gemini Serif";src:url(fonts/gemini-serif.woff2) format("woff2");font-weight:200 900;font-display:swap}
|
| 3 |
+
:root{--bg:#05060f;--card:#0d1028;--line:rgba(255,214,140,.22);--gold:#f5c66a;--txt:#eef1ff;--mut:#9aa3c7}
|
| 4 |
+
*{box-sizing:border-box}
|
| 5 |
+
html,body{margin:0;background:radial-gradient(1200px 600px at 70% -10%,#1a1440 0,transparent 60%),var(--bg);color:var(--txt);font-family:"Gemini Sans","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif}
|
| 6 |
+
button{font:inherit;color:inherit;cursor:pointer}
|
| 7 |
+
.top{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 24px;flex-wrap:wrap}
|
| 8 |
+
.brand{display:flex;gap:14px;align-items:center}
|
| 9 |
+
.glyph{width:48px;height:48px;border-radius:14px;display:grid;place-items:center;font:900 26px "Gemini Serif","Noto Serif SC",serif;color:#1b1030;background:linear-gradient(135deg,#fff1c8,#f0b54e)}
|
| 10 |
+
h1{font:800 20px "Gemini Serif","Noto Serif SC",serif;margin:0;letter-spacing:.04em}
|
| 11 |
+
.brand p{margin:2px 0 0;color:var(--mut);font-size:13px}
|
| 12 |
+
.orient,.seg{display:inline-flex;background:#0b0e24;border:1px solid var(--line);border-radius:999px;padding:3px}
|
| 13 |
+
.orient button,.seg button{background:none;border:0;padding:7px 14px;border-radius:999px;color:var(--mut);font-size:14px;white-space:nowrap}
|
| 14 |
+
.orient button.on,.seg button.on{background:linear-gradient(135deg,#ffe2a0,#e9a945);color:#1b1030;font-weight:700}
|
| 15 |
+
main{max-width:1280px;margin:0 auto;padding:0 20px 40px;display:grid;gap:22px}
|
| 16 |
+
.player{background:#000;border-radius:18px;overflow:hidden;border:1px solid var(--line);box-shadow:0 30px 80px rgba(0,0,0,.5)}
|
| 17 |
+
.stage{position:relative;width:100%;aspect-ratio:16/9;background:#000;margin:0 auto}
|
| 18 |
+
body.portrait .stage{aspect-ratio:9/16;width:min(100%,calc(82vh * 9 / 16));height:auto}
|
| 19 |
+
canvas#view{width:100%;height:100%;display:block}
|
| 20 |
+
.gate{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;background:radial-gradient(circle,rgba(5,6,15,.25),rgba(5,6,15,.7));transition:opacity .4s}
|
| 21 |
+
.gate.hide{opacity:0;pointer-events:none}
|
| 22 |
+
#bigplay{width:92px;height:92px;border-radius:50%;border:1px solid rgba(255,230,180,.6);background:rgba(255,214,140,.15);backdrop-filter:blur(6px);display:grid;place-items:center}
|
| 23 |
+
#bigplay:disabled{opacity:.4;cursor:wait}
|
| 24 |
+
.tri{width:0;height:0;border-left:28px solid #ffe6b0;border-top:17px solid transparent;border-bottom:17px solid transparent;margin-left:8px}
|
| 25 |
+
#status{margin:0;color:#ffe6b0;font-size:14px;text-shadow:0 1px 4px #000}
|
| 26 |
+
.bar{display:flex;align-items:center;gap:10px;padding:10px 14px;background:#090b1d}
|
| 27 |
+
.ic{background:#141838;border:1px solid var(--line);border-radius:10px;width:40px;height:36px;display:grid;place-items:center}
|
| 28 |
+
.ic.small{width:36px;font-size:14px}
|
| 29 |
+
.ic.on{border-color:var(--gold);color:var(--gold)}
|
| 30 |
+
.ic:disabled{opacity:.4}
|
| 31 |
+
#time{white-space:nowrap;font-variant-numeric:tabular-nums;color:var(--mut);font-size:13px;min-width:104px}
|
| 32 |
+
#seek{flex:1;accent-color:var(--gold)}
|
| 33 |
+
.chapters{display:flex;gap:6px;overflow-x:auto;padding:8px 14px 12px;background:#090b1d;scrollbar-width:thin}
|
| 34 |
+
.chapters button{flex:none;background:#10142f;border:1px solid transparent;color:var(--mut);border-radius:999px;padding:5px 12px;font-size:12px}
|
| 35 |
+
.chapters button.on{border-color:var(--gold);color:#ffe6b0}
|
| 36 |
+
.panel{background:linear-gradient(180deg,#0e1130,#0a0c22);border:1px solid var(--line);border-radius:18px;padding:18px 20px}
|
| 37 |
+
.panel h2{margin:0 0 14px;font:800 18px "Gemini Serif","Noto Serif SC",serif}
|
| 38 |
+
.panel h2 small{font:400 12px "Gemini Sans","Noto Sans SC",sans-serif;color:var(--mut);margin-left:8px}
|
| 39 |
+
.row{display:flex;align-items:center;gap:12px;margin:10px 0;flex-wrap:wrap}
|
| 40 |
+
.row>label{width:56px;color:var(--mut);font-size:14px}
|
| 41 |
+
.primary{background:linear-gradient(135deg,#ffe2a0,#e9a945);color:#1b1030;border:0;border-radius:12px;padding:10px 22px;font-weight:800;text-decoration:none;display:inline-block}
|
| 42 |
+
.primary:disabled{opacity:.4}
|
| 43 |
+
.ghost{background:none;border:1px solid var(--line);border-radius:12px;padding:9px 18px}
|
| 44 |
+
.prog{height:10px;background:#141838;border-radius:99px;overflow:hidden;margin-top:6px}
|
| 45 |
+
.fill{height:100%;width:0;background:linear-gradient(90deg,#8fb5ff,#ffd48a);transition:width .2s}
|
| 46 |
+
.note{color:var(--mut);font-size:13px;line-height:1.6;margin:10px 0 0}
|
| 47 |
+
.tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px}
|
| 48 |
+
.tabs button{background:#10142f;border:1px solid var(--line);border-radius:12px;padding:8px 16px;color:var(--mut)}
|
| 49 |
+
.tabs button.on{background:linear-gradient(135deg,#ffe2a0,#e9a945);color:#1b1030;font-weight:800;border-color:transparent}
|
| 50 |
+
.field{background:#080a1c;border:1px solid rgba(255,255,255,.06);border-radius:14px;padding:12px 14px;margin-bottom:12px}
|
| 51 |
+
.field .hd{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px;font-size:13px;color:var(--mut)}
|
| 52 |
+
.field .hd b{color:#ffe6b0;font-weight:700}
|
| 53 |
+
.count.ok{color:#8fe3a8}.count.bad{color:#ff8f8f}
|
| 54 |
+
.field pre{margin:0;white-space:pre-wrap;word-break:break-all;font:15px/1.7 "Gemini Sans","Noto Sans SC",sans-serif;color:var(--txt)}
|
| 55 |
+
.copy{background:#1a1f45;border:1px solid var(--line);border-radius:9px;padding:4px 12px;font-size:12px;color:#ffe6b0}
|
| 56 |
+
.copy.done{background:#1f4a33;color:#bff5cf}
|
| 57 |
+
.covers{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;align-items:start}
|
| 58 |
+
.cover{background:#080a1c;border:1px solid rgba(255,255,255,.06);border-radius:14px;padding:10px}
|
| 59 |
+
.cover canvas{width:100%;height:auto;border-radius:8px;display:block}
|
| 60 |
+
.cover .hd{display:flex;justify-content:space-between;align-items:center;margin-top:8px;font-size:13px;color:var(--mut)}
|
| 61 |
+
footer{text-align:center;color:#5d6488;font-size:12px;padding:10px 20px 30px}
|
| 62 |
+
:fullscreen .stage,.stage:fullscreen{width:100vw!important;height:100vh!important;aspect-ratio:auto;max-width:none}
|
| 63 |
+
:fullscreen canvas#view{object-fit:contain}
|
| 64 |
+
@media (max-width:640px){
|
| 65 |
+
.top{padding:12px 14px}
|
| 66 |
+
h1{font-size:16px}
|
| 67 |
+
main{padding:0 10px 30px;gap:14px}
|
| 68 |
+
body.portrait .stage{width:min(100%,calc(86svh * 9 / 16))}
|
| 69 |
+
.row>label{width:100%}
|
| 70 |
+
.seg button{padding:7px 10px;font-size:13px}
|
| 71 |
+
#time{min-width:auto;font-size:12px}
|
| 72 |
+
}
|
| 73 |
+
[hidden]{display:none!important}
|
| 74 |
+
#mute .x{display:none}#mute.muted .x{display:inline}#mute.muted .wave{display:none}
|
gemini-stars/tools/build_audio.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 预渲染完整音轨(配乐 + 音效 + 旁白)为 audio/soundtrack.mp3,网页可秒开播放;
|
| 2 |
+
// 与浏览器内实时合成使用同一份 audio.js,结果一致。
|
| 3 |
+
// 依赖: npm i node-web-audio-api ;ffmpeg 可执行文件路径通过环境变量 FFMPEG 指定
|
| 4 |
+
// 用法: FFMPEG=/path/to/ffmpeg node tools/build_audio.mjs
|
| 5 |
+
import fs from 'fs';
|
| 6 |
+
import path from 'path';
|
| 7 |
+
import { execFileSync } from 'child_process';
|
| 8 |
+
import { fileURLToPath, pathToFileURL } from 'url';
|
| 9 |
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
| 10 |
+
const wa = await import(process.env.WEBAUDIO || 'node-web-audio-api');
|
| 11 |
+
globalThis.OfflineAudioContext = wa.OfflineAudioContext;
|
| 12 |
+
globalThis.fetch = async f => ({ arrayBuffer: async () => { const b = fs.readFileSync(path.join(ROOT, f)); return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength); } });
|
| 13 |
+
const { buildSoundtrack } = await import(pathToFileURL(path.join(ROOT, 'audio.js')).href);
|
| 14 |
+
const buf = await buildSoundtrack(s => process.stdout.write(s + '\r'));
|
| 15 |
+
const L = buf.getChannelData(0), R = buf.getChannelData(1), n = L.length;
|
| 16 |
+
const wav = Buffer.alloc(44 + n * 4);
|
| 17 |
+
wav.write('RIFF', 0); wav.writeUInt32LE(36 + n * 4, 4); wav.write('WAVEfmt ', 8); wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(2, 22);
|
| 18 |
+
wav.writeUInt32LE(buf.sampleRate, 24); wav.writeUInt32LE(buf.sampleRate * 4, 28); wav.writeUInt16LE(4, 32); wav.writeUInt16LE(16, 34); wav.write('data', 36); wav.writeUInt32LE(n * 4, 40);
|
| 19 |
+
const q = v => Math.max(-32767, Math.min(32767, Math.round(v * 32767)));
|
| 20 |
+
for (let i = 0; i < n; i++) { wav.writeInt16LE(q(L[i]), 44 + i * 4); wav.writeInt16LE(q(R[i]), 46 + i * 4); }
|
| 21 |
+
const tmp = path.join(ROOT, 'soundtrack.tmp.wav'); fs.writeFileSync(tmp, wav);
|
| 22 |
+
execFileSync(process.env.FFMPEG || 'ffmpeg', ['-y', '-loglevel', 'error', '-i', tmp, '-c:a', 'libmp3lame', '-b:a', '192k', '-ar', String(buf.sampleRate), path.join(ROOT, 'audio', 'soundtrack.mp3')]);
|
| 23 |
+
fs.unlinkSync(tmp);
|
| 24 |
+
console.log('\nsoundtrack.mp3 written,', buf.duration.toFixed(2), 's');
|
gemini-stars/tools/subset_fonts.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""把 Noto Sans SC / Noto Serif SC(SIL OFL 1.1)按项目实际用到的字符子集化为 woff2,
|
| 2 |
+
保证画布文字在任何网络环境(包括无法访问 Google Fonts 的地区)都使用统一字体。
|
| 3 |
+
用法: python tools/subset_fonts.py <NotoSansSC.ttf> <NotoSerifSC.ttf>
|
| 4 |
+
依赖: pip install fonttools brotli
|
| 5 |
+
"""
|
| 6 |
+
import sys, os, glob
|
| 7 |
+
from fontTools import subset
|
| 8 |
+
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 9 |
+
txt = ''
|
| 10 |
+
for f in ['scenes.js', 'copy.js', 'timeline.js', 'app.js', 'index.html']:
|
| 11 |
+
txt += open(os.path.join(HERE, f), encoding='utf-8').read()
|
| 12 |
+
chars = set(c for c in txt if ord(c) > 127) | set(chr(i) for i in range(32, 127)) | set(',。、;:!?“”‘’()—…·《》【】≈≥★–')
|
| 13 |
+
text = ''.join(sorted(chars))
|
| 14 |
+
for src, out in [(sys.argv[1], 'gemini-sans.woff2'), (sys.argv[2], 'gemini-serif.woff2')]:
|
| 15 |
+
opts = subset.Options(); opts.flavor = 'woff2'; opts.layout_features = ['*']; opts.name_IDs = ['*']; opts.notdef_outline = True
|
| 16 |
+
f = subset.load_font(src, opts); s = subset.Subsetter(opts); s.populate(text=text); s.subset(f)
|
| 17 |
+
subset.save_font(f, os.path.join(HERE, 'fonts', out), opts)
|
| 18 |
+
print(out, os.path.getsize(os.path.join(HERE, 'fonts', out)), 'bytes,', len(text), 'chars')
|