Ethscriptions commited on
Commit
42e39a2
·
verified ·
1 Parent(s): ebf35a7

gemini-stars: 配乐音效与预渲染音轨、播放器、MP4 导出(24/30/60fps)、多平台封面与文案、本地字体,自检完成(step 4–7)

Browse files
.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 # 深空主题 UI,手机自适应
44
- ├── app.js # 入口:播放器、UI、导出、封面、文案
45
- ├── scenes.js # 所有场景的绘制函数(纯函数:draw(ctx, t, W, H))
46
- ├── audio.js # 配乐 / 音效合成 + 旁白混音(OfflineAudioContext)
47
- ├── timeline.js # 由 tools/make_voice.py 生成:每句旁白的起止时间、字幕
48
- ├── copy.js # 各平台标题 / 描述 / 话题
49
- ├── vendor/mp4-muxer.mjs# MIT 许可的 MP4 封装库(本地化,离线可用)
50
- ├── audio/*.mp3 # 旁白音频
51
- └── tools/make_voice.py # 旁白生成与时间轴计算脚本
 
 
 
 
 
52
  ```
53
 
54
  关键设计:
55
- - **确定性渲染**:每一帧只由时间 `t` 决定(随机数用固定种子),所以实时播放与离线导出画面一致。
56
- - **单一混音缓冲**:配乐 + 音效 + 旁白在 `OfflineAudioContext` 中一次混成完整音轨,播放和导出共用,保证音画同步。
57
  - **画内字幕**:字幕绘制在 Canvas 内,导出的 MP4 自带字幕。
 
58
 
59
  ## 4. 步骤与进度看板
60
 
61
  - [x] Step 1:撰写本计划(本文件)并推送
62
  - [x] Step 2:撰写旁白文案,生成 TTS 音频与 `timeline.js`,推送
63
  - [x] Step 3:场景动画引擎与全部场景(横/竖屏自适应),推送
64
- - [ ] Step 4:配乐与音效合成、混音、播放器,推送
65
- - [ ] Step 5:MP4 本地导出(24/30/60fps,横/竖屏),推送
66
- - [ ] Step 6:多平台封面 + 文案复制面板,推送
67
- - [ ] Step 7:自检、修正与最终推送
 
 
 
 
 
 
68
 
69
  ## 5. 使用
70
 
71
- 直接打开 Space 中的 `gemini-stars/index.html`(需通过 http(s) 访问以加载模块与音频)。
72
- 导出 MP4 推荐使用桌面版 Chrome / Edge(WebCodecs 支持最好)。
 
 
 
 
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) + 110 * u, 30 * u, '#fff2b0', 'center', 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,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() * TAU, len: .5 + r() * .5, sp: .9 + r() * .6, br: r(), d0: .05 + r() * .2 });
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 = (1.5 + m.br * 2.5) * 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,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 * .78, (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;
 
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')