Ethscriptions commited on
Commit
0dd44c1
·
verified ·
1 Parent(s): 9ec8fb6

solana-clock: 竖屏改为普通构图 + 4:3/3:4 封面

Browse files
README.md CHANGED
@@ -21,12 +21,13 @@ pinned: false
21
  |---|------|------|
22
  | 1 | 写计划 / 大纲 / 步骤(本节) | ✅ 完成 |
23
  | 2 | 旁白文案定稿(15 场景 / 72 句)+ TTS 配音(纯语音 370 秒)+ 时长清单 + SOL 真实价格数据(2372 个日线点) | ✅ 完成 |
24
- | 3 | 动画引擎 + 全部场景(横屏 16:9 / 竖屏 9:16 双布局 + 烧录字幕 + 安全区)+ 播放器页面 + 字体子集;无头浏览器 90 帧截图 + 逐 0.5 秒排版检查通过 | ✅ 完成 |
25
  | 4 | 程序化音乐(15 段编曲:和弦垫 / 贝斯 / 琶音 / 鼓 / 时钟滴答)+ 57 种合成音效(258 个触发点)+ 旁白闪避混音 + 混响 + 限幅;按场景分段渲染,约 3 秒即可出声 | ✅ 完成 |
26
- | 5 | 播放器 UI(章节跳转 / 拖动进度 / 横竖切换 / 安全区显示 / 快捷键)+ 浏览器本地 MP4 导出(24 / 30 / 60 fps,横屏 1920×1080 / 竖屏 1080×1920,H.264 优先,支持边生成边写盘、进度预览与取消) | ✅ 完成 |
27
  | 6 | 抖音 / B站 发布标题、简介、标签(一键复制,含章节时间戳与字数统计) | ✅ 完成 |
28
- | 7 | 横屏 1920×1080 / 竖屏 1080×1920 封面图(页面内可下载 + covers/ 静态 PNG) | ✅ 完成 |
29
  | 8 | 无头浏览器自动化测试(横竖屏播放、全部面板、导出 MP4 解码校验、排版检查)+ 修复 + 最终推送 | ✅ 完成 |
 
30
 
31
  ## 1. 创意核心
32
 
@@ -64,8 +65,7 @@ pinned: false
64
  | 模块 | 方案 |
65
  |---|---|
66
  | 画面 | 原生 Canvas 2D,**纯函数式渲染 `render(t)`**:任意时间点都能精确重绘 → 预览与导出完全一致、可逐帧离线渲染 |
67
- | 布局 | 横屏 1920×1080(B站)与竖屏 1080×1920(抖音)两套布局,所有元素按「安全区」排布 |
68
- | 竖屏安全区 | 顶部 ≈ 230px(刘海/状态栏/抖音顶栏),底部 ≈ 440px(标题文案/音乐条/底部导航),右侧 ≈ 150px(点赞评论按钮栏);预览中可开启参考线 |
69
  | 字幕 | 烧录进画面,横屏在底部,竖屏在底部安全线之上 |
70
  | 旁白 | Edge 神经网络 TTS(zh-CN-YunxiNeural 主旁白 + zh-CN-XiaoxiaoNeural 新闻播报腔)预生成 MP3,附时长清单;画面动画按旁白每句的起点对齐 |
71
  | 音乐 | Web Audio 程序化合成(Pad / 琶音 / 贝斯 / 鼓组 / 上升音效),按场景切换情绪与和弦;固定随机种子保证每次一致 |
@@ -75,7 +75,7 @@ pinned: false
75
  | 数据 | SOL 历史日线价格来自 Surf 数据 API 的真实快照(2020-04-10 → 2026-10-07),打包为静态 JS |
76
  | 字体 | Noto Sans SC 按全片用字做子集(woff2),保证导出画面在任何电脑上字形一致 |
77
  | 发布文案 | 抖音 / B站 标题、简介(B站自动生成章节时间戳)、标签,一键复制 |
78
- | 封面 | 网页内实时绘制并下载 PNG;同时提供静态 `covers/cover-landscape.png`、`covers/cover-portrait.png` |
79
 
80
  ## 4. 目录结构
81
 
@@ -95,7 +95,7 @@ solana-clock/
95
  │ └── data-sol.js SOL 真实价格数据
96
  ├── audio/ 旁白 MP3 + manifest.json
97
  ├── fonts/ 子集字体
98
- ├── covers/ 横竖屏封面 PNG
99
  ├── vendor/mediabunny.min.mjs
100
  └── tools/ 配音生成 / 字体子集 / 截图测试脚本
101
  ```
@@ -121,10 +121,10 @@ solana-clock/
121
  ### 使用说明
122
 
123
  - 打开 `solana-clock/index.html`,点击播放(首次约 3 秒生成音乐与音效,之后边播边渲染)。
124
- - 顶部切换「横屏 / 竖屏」;勾选「安全区」可查看抖音刘海、底部文案栏与右侧按钮的避让区域。
125
  - 「导出」面板:选择 B站横屏 / 抖音竖屏、24 / 30 / 60 fps、画质,在浏览器本地生成 MP4(推荐桌面版 Chrome / Edge,支持直接边生成边保存到硬盘)。
126
  - 「发布」面板:抖音 / B站 标题、简介(含章节时间戳)、标签一键复制。
127
- - 「封面」面板:下载 1920×1080 横屏与 1080×1920 竖屏封面 PNG(静态版本也在 `solana-clock/covers/`)。
128
  - 快捷键:空格 播放/暂停,← → 快退/快进,[ ] 上一章/下一章,M 静音,F 全屏。
129
 
130
  <!-- solana-clock:end -->
 
21
  |---|------|------|
22
  | 1 | 写计划 / 大纲 / 步骤(本节) | ✅ 完成 |
23
  | 2 | 旁白文案定稿(15 场景 / 72 句)+ TTS 配音(纯语音 370 秒)+ 时长清单 + SOL 真实价格数据(2372 个日线点) | ✅ 完成 |
24
+ | 3 | 动画引擎 + 全部场景(横屏 16:9 / 竖屏 9:16 双布局 + 烧录字幕)+ 播放器页面 + 字体子集;无头浏览器 90 帧截图 + 逐 0.5 秒排版检查通过 | ✅ 完成 |
25
  | 4 | 程序化音乐(15 段编曲:和弦垫 / 贝斯 / 琶音 / 鼓 / 时钟滴答)+ 57 种合成音效(258 个触发点)+ 旁白闪避混音 + 混响 + 限幅;按场景分段渲染,约 3 秒即可出声 | ✅ 完成 |
26
+ | 5 | 播放器 UI(章节跳转 / 拖动进度 / 横竖切换 / 快捷键)+ 浏览器本地 MP4 导出(24 / 30 / 60 fps,横屏 1920×1080 / 竖屏 1080×1920,H.264 优先,支持边生成边写盘、进度预览与取消) | ✅ 完成 |
27
  | 6 | 抖音 / B站 发布标题、简介、标签(一键复制,含章节时间戳与字数统计) | ✅ 完成 |
28
+ | 7 | 横封面 4:3(1440×1080)/ 竖封面 3:4(1080×1440)(页面内可下载 + covers/ 静态 PNG) | ✅ 完成 |
29
  | 8 | 无头浏览器自动化测试(横竖屏播放、全部面板、导出 MP4 解码校验、排版检查)+ 修复 + 最终推送 | ✅ 完成 |
30
+ | 9 | 调整:竖屏改为普通全画幅 9:16 构图(取消抖音安全区避让);封面改为横 4:3 / 竖 3:4 | ✅ 完成 |
31
 
32
  ## 1. 创意核心
33
 
 
65
  | 模块 | 方案 |
66
  |---|---|
67
  | 画面 | 原生 Canvas 2D,**纯函数式渲染 `render(t)`**:任意时间点都能精确重绘 → 预览与导出完全一致、可逐帧离线渲染 |
68
+ | 布局 | 横屏 1920×1080(B站)与竖屏 1080×1920(抖音)两套布局;竖屏为普通全画幅 9:16 构图(上下左右对称留边),不再为抖音刘海 / 底部文案栏 / 右侧按钮额外避让 |
 
69
  | 字幕 | 烧录进画面,横屏在底部,竖屏在底部安全线之上 |
70
  | 旁白 | Edge 神经网络 TTS(zh-CN-YunxiNeural 主旁白 + zh-CN-XiaoxiaoNeural 新闻播报腔)预生成 MP3,附时长清单;画面动画按旁白每句的起点对齐 |
71
  | 音乐 | Web Audio 程序化合成(Pad / 琶音 / 贝斯 / 鼓组 / 上升音效),按场景切换情绪与和弦;固定随机种子保证每次一致 |
 
75
  | 数据 | SOL 历史日线价格来自 Surf 数据 API 的真实快照(2020-04-10 → 2026-10-07),打包为静态 JS |
76
  | 字体 | Noto Sans SC 按全片用字做子集(woff2),保证导出画面在任何电脑上字形一致 |
77
  | 发布文案 | 抖音 / B站 标题、简介(B站自动生成章节时间戳)、标签,一键复制 |
78
+ | 封面 | 横封面 4:3(1440×1080)、竖封面 3:4(1080×1440),网页内实时绘制并下载 PNG;同时提供静态 `covers/cover-landscape.png`、`covers/cover-portrait.png` |
79
 
80
  ## 4. 目录结构
81
 
 
95
  │ └── data-sol.js SOL 真实价格数据
96
  ├── audio/ 旁白 MP3 + manifest.json
97
  ├── fonts/ 子集字体
98
+ ├── covers/ 横 4:3 / 竖 3:4 封面 PNG
99
  ├── vendor/mediabunny.min.mjs
100
  └── tools/ 配音生成 / 字体子集 / 截图测试脚本
101
  ```
 
121
  ### 使用说明
122
 
123
  - 打开 `solana-clock/index.html`,点击播放(首次约 3 秒生成音乐与音效,之后边播边渲染)。
124
+ - 顶部切换「横屏 / 竖屏」(竖屏为普通 9:16 全画幅构图)。
125
  - 「导出」面板:选择 B站横屏 / 抖音竖屏、24 / 30 / 60 fps、画质,在浏览器本地生成 MP4(推荐桌面版 Chrome / Edge,支持直接边生成边保存到硬盘)。
126
  - 「发布」面板:抖音 / B站 标题、简介(含章节时间戳)、标签一键复制。
127
+ - 「封面」面板:下载横封面 4:3(1440×1080)与竖封面 3:4(1080×1440)PNG(静态版本也在 `solana-clock/covers/`)。
128
  - 快捷键:空格 播放/暂停,← → 快退/快进,[ ] 上一章/下一章,M 静音,F 全屏。
129
 
130
  <!-- solana-clock:end -->
solana-clock/covers/cover-landscape.png CHANGED

Git LFS Details

  • SHA256: d8cb1e85f0b6370b4e4e2de5593f3c21d27c12fa8b08ba38fe6d6a659758b6f6
  • Pointer size: 132 Bytes
  • Size of remote file: 2.38 MB

Git LFS Details

  • SHA256: 7f929647a0b0969573bdb3888d892085ea17eb5e23206fa2ce07fea4609401da
  • Pointer size: 132 Bytes
  • Size of remote file: 1.83 MB
solana-clock/covers/cover-portrait.png CHANGED

Git LFS Details

  • SHA256: 40e770aa7c78285c9906129b1922b39845eef39fa65c3ede3ced4a9d55a92313
  • Pointer size: 132 Bytes
  • Size of remote file: 2.15 MB

Git LFS Details

  • SHA256: 921419013067bee808a3a8924432c93e0cab392a545c8061911d782f40d366bf
  • Pointer size: 132 Bytes
  • Size of remote file: 1.73 MB
solana-clock/css/app.css CHANGED
@@ -126,7 +126,7 @@ button { font: inherit; color: inherit; cursor: pointer; }
126
  .covers { display: grid; gap: 14px; }
127
  .covers figure { margin: 0; }
128
  .covers canvas { width: 100%; border-radius: 10px; border: 1px solid var(--line); display: block; background: #000; }
129
- .covers .cover-p { width: 56%; margin: 0 auto; }
130
  .covers figcaption { font-size: 12px; color: var(--muted); margin-top: 6px; display: flex; align-items: center; justify-content: center; }
131
 
132
  .foot { margin-top: 22px; font-size: 12px; color: #5f5878; text-align: center; line-height: 1.6; }
 
126
  .covers { display: grid; gap: 14px; }
127
  .covers figure { margin: 0; }
128
  .covers canvas { width: 100%; border-radius: 10px; border: 1px solid var(--line); display: block; background: #000; }
129
+ .covers .cover-p { width: 75%; margin: 0 auto; }
130
  .covers figcaption { font-size: 12px; color: var(--muted); margin-top: 6px; display: flex; align-items: center; justify-content: center; }
131
 
132
  .foot { margin-top: 22px; font-size: 12px; color: #5f5878; text-align: center; line-height: 1.6; }
solana-clock/fonts/sol-grotesk.woff2 CHANGED
Binary files a/solana-clock/fonts/sol-grotesk.woff2 and b/solana-clock/fonts/sol-grotesk.woff2 differ
 
solana-clock/fonts/sol-mono.woff2 CHANGED
Binary files a/solana-clock/fonts/sol-mono.woff2 and b/solana-clock/fonts/sol-mono.woff2 differ
 
solana-clock/fonts/sol-sc-400.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:30b9331388c3fb7bf2daab2464a825a2acce436ab7affe32c7c29535d6418a90
3
- size 137196
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:22249c585acf1ce89117a90c9dbc9af1349aac264128f062d44fd8301ac9a17c
3
+ size 136488
solana-clock/fonts/sol-sc-700.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:2585dfd8acb1283c16ecbdf5470a71cb714de115e5f37c416f6e4ed80cde9c8b
3
- size 137292
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7ae2b8271c56b84be7f575d66d55974e2ede3cadd43a709bbef1460bec08a927
3
+ size 136520
solana-clock/fonts/sol-sc-900.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:94a8026a83f635762a62180c51575b47a927e87ab306b4955f32c076fab5c736
3
- size 136976
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:87e9bae6dca820fbc64d1e43a71527ebfda4661b0e24540971e53c594b40d55c
3
+ size 136180
solana-clock/index.html CHANGED
@@ -53,7 +53,6 @@
53
  <button data-v="portrait">▯ 竖屏 9:16 · 抖音</button>
54
  </div>
55
  <label class="chk"><input type="checkbox" id="chkSubs" checked> 字幕</label>
56
- <label class="chk" id="safeWrap"><input type="checkbox" id="chkSafe"> 显示抖音安全区</label>
57
  <span class="hint">快捷键:空格 播放 · ←/→ 跳 5 秒 · [ / ] 切章节</span>
58
  </div>
59
  </section>
@@ -77,7 +76,7 @@
77
  <span>画面</span>
78
  <div class="seg small" id="segExpOrient">
79
  <button data-v="landscape" class="on">横屏 1920×1080<br><em>B 站</em></button>
80
- <button data-v="portrait">竖屏 1080×1920<br><em>抖音(已留安全区)</em></button>
81
  </div>
82
  </div>
83
  <div class="field">
@@ -123,11 +122,11 @@
123
  <div class="covers">
124
  <figure>
125
  <canvas id="coverL" class="cover-l"></canvas>
126
- <figcaption>横屏封面 1920×1080(B 站)<button class="ghost small" data-dl="landscape">下载 PNG</button></figcaption>
127
  </figure>
128
  <figure>
129
  <canvas id="coverP" class="cover-p"></canvas>
130
- <figcaption>竖屏封面 1080×1920(抖音)<button class="ghost small" data-dl="portrait">下载 PNG</button></figcaption>
131
  </figure>
132
  </div>
133
  </div>
 
53
  <button data-v="portrait">▯ 竖屏 9:16 · 抖音</button>
54
  </div>
55
  <label class="chk"><input type="checkbox" id="chkSubs" checked> 字幕</label>
 
56
  <span class="hint">快捷键:空格 播放 · ←/→ 跳 5 秒 · [ / ] 切章节</span>
57
  </div>
58
  </section>
 
76
  <span>画面</span>
77
  <div class="seg small" id="segExpOrient">
78
  <button data-v="landscape" class="on">横屏 1920×1080<br><em>B 站</em></button>
79
+ <button data-v="portrait">竖屏 1080×1920<br><em>抖音 / 视频号 / 小红书</em></button>
80
  </div>
81
  </div>
82
  <div class="field">
 
122
  <div class="covers">
123
  <figure>
124
  <canvas id="coverL" class="cover-l"></canvas>
125
+ <figcaption>横封面 4:3 · 1440×1080<button class="ghost small" data-dl="landscape">下载 PNG</button></figcaption>
126
  </figure>
127
  <figure>
128
  <canvas id="coverP" class="cover-p"></canvas>
129
+ <figcaption>竖封面 3:4 · 1080×1440<button class="ghost small" data-dl="portrait">下载 PNG</button></figcaption>
130
  </figure>
131
  </div>
132
  </div>
solana-clock/js/cover.js CHANGED
@@ -1,4 +1,4 @@
1
- // Cover art (landscape 1920×1080 for Bilibili, portrait 1080×1920 for Douyin), drawn with the same toolkit as the film.
2
  import { text, gradText, glow, solLogo, dial, pill, rr, hexA, rng, F_SC, F_GR, F_MONO, PURPLE, GREEN, PINK, GOLD, RED, WHITE } from './draw.js';
3
  import { drawChart, idx, LAST } from './chart.js';
4
 
@@ -45,33 +45,34 @@ function chartBand(ctx, rect, lw) {
45
  }
46
  import { price as priceAt } from './chart.js';
47
 
 
 
48
  export function drawCover(ctx, orient) {
49
- const P = orient === 'portrait', W = P ? 1080 : 1920, H = P ? 1920 : 1080;
50
  ctx.save();
51
  bg(ctx, W, H, P);
52
  if (!P) {
53
- const { mark } = chartBand(ctx, { x: 80, y: 600, w: 1760, h: 360 }, 6);
54
  mark('2020-04-10', '$0.74', '#c9b6ff', true);
55
  mark('2021-11-06', '$259', GREEN, true);
56
  mark('2022-12-29', '$9.6', RED, false);
57
  mark('2025-01-19', '$262', GOLD, true);
58
- clockMark(ctx, 1500, 330, 210);
59
- pill(ctx, '7 分钟动画史 · 2017 → 2026', 110, 120, { size: 32, align: 'left', color: GREEN });
60
- gradText(ctx, 'SOLANA', 104, 268, { size: 150, fam: F_GR, weight: 700, ls: 10 });
61
- text(ctx, '给区块链装上时钟的人', 108, 420, { size: 104, weight: 900, color: WHITE, shadow: 30, shadowColor: 'rgba(153,69,255,.8)' });
62
- text(ctx, '400 毫秒一个块 · 跌 96% 又爬回来', 112, 528, { size: 46, weight: 800, color: hexA(WHITE, .82) });
63
  } else {
64
- // Douyin grids crop covers to a centered 3:4 area (y ≈ 240…1680) — keep the key text inside it.
65
- pill(ctx, '7 分钟动画史', W / 2, 330, { size: 40, color: GREEN });
66
- gradText(ctx, 'SOLANA', W / 2, 470, { size: 170, fam: F_GR, weight: 700, ls: 10, align: 'center' });
67
- text(ctx, '给区块链', W / 2, 640, { size: 130, weight: 900, color: WHITE, align: 'center', shadow: 30, shadowColor: 'rgba(153,69,255,.85)' });
68
- text(ctx, '装上时钟的人', W / 2, 790, { size: 130, weight: 900, color: WHITE, align: 'center', shadow: 30, shadowColor: 'rgba(153,69,255,.85)' });
69
- clockMark(ctx, W / 2, 1110, 190);
70
- const { mark } = chartBand(ctx, { x: 90, y: 1360, w: 900, h: 250 }, 5);
71
  mark('2021-11-06', '$259', GREEN, true);
72
  mark('2022-12-29', '$9.6', RED, false);
73
  mark('2025-01-19', '$262', GOLD, true);
74
- text(ctx, '2017 → 2026 · 九年沉浮全史', W / 2, 1650, { size: 44, weight: 800, color: hexA(WHITE, .85), align: 'center' });
75
  }
76
  // thin frame
77
  ctx.strokeStyle = 'rgba(255,255,255,.08)'; ctx.lineWidth = 4; ctx.strokeRect(2, 2, W - 4, H - 4);
@@ -80,7 +81,7 @@ export function drawCover(ctx, orient) {
80
 
81
  export function initCoverPanel({ toast }) {
82
  const make = (id, o) => {
83
- const c = document.querySelector(id); const [W, H] = o === 'portrait' ? [1080, 1920] : [1920, 1080];
84
  c.width = W; c.height = H; drawCover(c.getContext('2d'), o); return c;
85
  };
86
  const cs = { landscape: make('#coverL', 'landscape'), portrait: make('#coverP', 'portrait') };
@@ -88,7 +89,7 @@ export function initCoverPanel({ toast }) {
88
  const o = b.dataset.dl;
89
  cs[o].toBlob(blob => {
90
  const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
91
- a.download = o === 'portrait' ? 'Solana动画史_抖音竖屏封面_1080x1920.png' : 'Solana动画史_B站横屏封面_1920x1080.png';
92
  document.body.appendChild(a); a.click(); a.remove(); toast('封面已下载');
93
  }, 'image/png');
94
  });
 
1
+ // Cover art (landscape 4:3 1440×1080, portrait 3:4 1080×1440), drawn with the same toolkit as the film.
2
  import { text, gradText, glow, solLogo, dial, pill, rr, hexA, rng, F_SC, F_GR, F_MONO, PURPLE, GREEN, PINK, GOLD, RED, WHITE } from './draw.js';
3
  import { drawChart, idx, LAST } from './chart.js';
4
 
 
45
  }
46
  import { price as priceAt } from './chart.js';
47
 
48
+ export const COVER_SIZE = { landscape: [1440, 1080], portrait: [1080, 1440] };
49
+
50
  export function drawCover(ctx, orient) {
51
+ const P = orient === 'portrait', [W, H] = COVER_SIZE[orient];
52
  ctx.save();
53
  bg(ctx, W, H, P);
54
  if (!P) {
55
+ const { mark } = chartBand(ctx, { x: 120, y: 770, w: 1230, h: 240 }, 6);
56
  mark('2020-04-10', '$0.74', '#c9b6ff', true);
57
  mark('2021-11-06', '$259', GREEN, true);
58
  mark('2022-12-29', '$9.6', RED, false);
59
  mark('2025-01-19', '$262', GOLD, true);
60
+ clockMark(ctx, 1130, 270, 170);
61
+ pill(ctx, '7 分钟动画史 · 2017 → 2026', 92, 104, { size: 32, align: 'left', color: GREEN });
62
+ gradText(ctx, 'SOLANA', 86, 250, { size: 150, fam: F_GR, weight: 700, ls: 10 });
63
+ text(ctx, '给区块链装上时钟的人', 90, 548, { size: 112, weight: 900, color: WHITE, shadow: 30, shadowColor: 'rgba(153,69,255,.8)' });
64
+ text(ctx, '400 毫秒一个块 · 跌 96% 又爬回来', 94, 662, { size: 48, weight: 800, color: hexA(WHITE, .82) });
65
  } else {
66
+ pill(ctx, '7 分钟动画史', W / 2, 112, { size: 40, color: GREEN });
67
+ gradText(ctx, 'SOLANA', W / 2, 250, { size: 170, fam: F_GR, weight: 700, ls: 10, align: 'center' });
68
+ text(ctx, '给区块链', W / 2, 410, { size: 128, weight: 900, color: WHITE, align: 'center', shadow: 30, shadowColor: 'rgba(153,69,255,.85)' });
69
+ text(ctx, '装上时钟的人', W / 2, 556, { size: 128, weight: 900, color: WHITE, align: 'center', shadow: 30, shadowColor: 'rgba(153,69,255,.85)' });
70
+ clockMark(ctx, W / 2, 810, 140);
71
+ const { mark } = chartBand(ctx, { x: 90, y: 1070, w: 900, h: 200 }, 5);
 
72
  mark('2021-11-06', '$259', GREEN, true);
73
  mark('2022-12-29', '$9.6', RED, false);
74
  mark('2025-01-19', '$262', GOLD, true);
75
+ text(ctx, '2017 → 2026 · 九年沉浮全史', W / 2, 1360, { size: 44, weight: 800, color: hexA(WHITE, .85), align: 'center' });
76
  }
77
  // thin frame
78
  ctx.strokeStyle = 'rgba(255,255,255,.08)'; ctx.lineWidth = 4; ctx.strokeRect(2, 2, W - 4, H - 4);
 
81
 
82
  export function initCoverPanel({ toast }) {
83
  const make = (id, o) => {
84
+ const c = document.querySelector(id); const [W, H] = COVER_SIZE[o];
85
  c.width = W; c.height = H; drawCover(c.getContext('2d'), o); return c;
86
  };
87
  const cs = { landscape: make('#coverL', 'landscape'), portrait: make('#coverP', 'portrait') };
 
89
  const o = b.dataset.dl;
90
  cs[o].toBlob(blob => {
91
  const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
92
+ a.download = o === 'portrait' ? 'Solana动画史_竖封面_3x4_1080x1440.png' : 'Solana动画史_横封面_4x3_1440x1080.png';
93
  document.body.appendChild(a); a.click(); a.remove(); toast('封面已下载');
94
  }, 'image/png');
95
  });
solana-clock/js/engine.js CHANGED
@@ -196,22 +196,9 @@ export function renderFrame(ctx, orient, T, opts = {}) {
196
  const fi = 1 - inv(0, .6, T), fo = inv(TL.total - 1.6, TL.total - .1, T);
197
  const k = Math.max(fi, fo);
198
  if (k > 0) { ctx.fillStyle = `rgba(0,0,0,${k})`; ctx.fillRect(0, 0, L.W, L.H); }
199
- if (opts.safe && L.portrait) safeGuides(ctx, L);
200
  ctx.restore();
201
  }
202
 
203
- export function safeGuides(ctx, L) {
204
- const { W, H, safe } = L;
205
- ctx.save();
206
- ctx.fillStyle = 'rgba(255,40,80,.18)';
207
- ctx.fillRect(0, 0, W, safe.t); ctx.fillRect(0, safe.b, W, H - safe.b); ctx.fillRect(safe.r, safe.t, W - safe.r, safe.b - safe.t);
208
- ctx.setLineDash([14, 10]); ctx.strokeStyle = '#ff4d6d'; ctx.lineWidth = 3; ctx.strokeRect(safe.l, safe.t, safe.r - safe.l, safe.b - safe.t);
209
- text(ctx, '刘海 / 状态栏 / 顶部标签', W / 2, safe.t / 2, { size: 30, align: 'center', color: '#ffb3c1' });
210
- text(ctx, '抖音标题文案 / 音乐条 / 底部导航', W / 2, safe.b + (H - safe.b) / 2, { size: 30, align: 'center', color: '#ffb3c1' });
211
- ctx.translate(safe.r + (W - safe.r) / 2, (safe.t + safe.b) / 2); ctx.rotate(Math.PI / 2);
212
- text(ctx, '点赞 / 评论 / 收藏 / 分享', 0, 0, { size: 28, align: 'center', color: '#ffb3c1' });
213
- ctx.restore();
214
- }
215
 
216
  /** all SFX events (absolute time) collected from scenes */
217
  export function allSfx() {
 
196
  const fi = 1 - inv(0, .6, T), fo = inv(TL.total - 1.6, TL.total - .1, T);
197
  const k = Math.max(fi, fo);
198
  if (k > 0) { ctx.fillStyle = `rgba(0,0,0,${k})`; ctx.fillRect(0, 0, L.W, L.H); }
 
199
  ctx.restore();
200
  }
201
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
  /** all SFX events (absolute time) collected from scenes */
204
  export function allSfx() {
solana-clock/js/exporter.js CHANGED
@@ -92,7 +92,7 @@ export function initExportPanel({ state, getMix, pausePlayer, toast }) {
92
  if (cancelled) throw new Error('cancelled');
93
  const T = i * dt;
94
  ctx.setTransform(1, 0, 0, 1, 0, 0);
95
- renderFrame(ctx, opt.orient, T, { subs: opt.subs, safe: false });
96
  await vs.add(T, dt);
97
  if (i % 15 === 0 || i === N - 1) {
98
  pctx.drawImage(canvas, 0, 0, pv.width, pv.height);
 
92
  if (cancelled) throw new Error('cancelled');
93
  const T = i * dt;
94
  ctx.setTransform(1, 0, 0, 1, 0, 0);
95
+ renderFrame(ctx, opt.orient, T, { subs: opt.subs });
96
  await vs.add(T, dt);
97
  if (i % 15 === 0 || i === N - 1) {
98
  pctx.drawImage(canvas, 0, 0, pv.width, pv.height);
solana-clock/js/main.js CHANGED
@@ -7,11 +7,10 @@ const view = $('#view'), stage = $('#stage'), stageWrap = $('#stageWrap');
7
  const ctx = view.getContext('2d');
8
  const fmt = (s) => { s = Math.max(0, s); return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; };
9
 
10
- export const state = { T: 0, playing: false, orient: 'landscape', subs: true, safe: false, muted: false, dirty: true };
11
  const qs = new URLSearchParams(location.search);
12
  if (qs.get('o') === 'portrait') state.orient = 'portrait';
13
  if (qs.has('t')) state.T = parseFloat(qs.get('t')) || 0;
14
- if (qs.get('safe') === '1') state.safe = true;
15
 
16
  export function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('on'); clearTimeout(toast._h); toast._h = setTimeout(() => t.classList.remove('on'), 1800); }
17
 
@@ -28,7 +27,7 @@ new ResizeObserver(resize).observe(stage);
28
  function draw() {
29
  const [W] = SIZES[state.orient];
30
  ctx.setTransform(view.width / W, 0, 0, view.width / W, 0, 0);
31
- renderFrame(ctx, state.orient, state.T, { subs: state.subs, safe: state.safe });
32
  }
33
 
34
  // ---------- audio (progressive, per-scene segments) ----------
@@ -157,13 +156,10 @@ function setOrient(o) {
157
  state.orient = o;
158
  stageWrap.classList.toggle('portrait', o === 'portrait');
159
  document.querySelectorAll('#segOrient button').forEach(b => b.classList.toggle('on', b.dataset.v === o));
160
- $('#safeWrap').style.opacity = o === 'portrait' ? 1 : .4;
161
  resize();
162
  }
163
  $('#segOrient').addEventListener('click', e => { const b = e.target.closest('button'); if (b) setOrient(b.dataset.v); });
164
  $('#chkSubs').onchange = e => { state.subs = e.target.checked; state.dirty = true; };
165
- $('#chkSafe').checked = state.safe;
166
- $('#chkSafe').onchange = e => { state.safe = e.target.checked; if (state.safe && state.orient !== 'portrait') setOrient('portrait'); state.dirty = true; };
167
 
168
  window.addEventListener('keydown', e => {
169
  if (e.target.closest('input,textarea')) return;
 
7
  const ctx = view.getContext('2d');
8
  const fmt = (s) => { s = Math.max(0, s); return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; };
9
 
10
+ export const state = { T: 0, playing: false, orient: 'landscape', subs: true, muted: false, dirty: true };
11
  const qs = new URLSearchParams(location.search);
12
  if (qs.get('o') === 'portrait') state.orient = 'portrait';
13
  if (qs.has('t')) state.T = parseFloat(qs.get('t')) || 0;
 
14
 
15
  export function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('on'); clearTimeout(toast._h); toast._h = setTimeout(() => t.classList.remove('on'), 1800); }
16
 
 
27
  function draw() {
28
  const [W] = SIZES[state.orient];
29
  ctx.setTransform(view.width / W, 0, 0, view.width / W, 0, 0);
30
+ renderFrame(ctx, state.orient, state.T, { subs: state.subs });
31
  }
32
 
33
  // ---------- audio (progressive, per-scene segments) ----------
 
156
  state.orient = o;
157
  stageWrap.classList.toggle('portrait', o === 'portrait');
158
  document.querySelectorAll('#segOrient button').forEach(b => b.classList.toggle('on', b.dataset.v === o));
 
159
  resize();
160
  }
161
  $('#segOrient').addEventListener('click', e => { const b = e.target.closest('button'); if (b) setOrient(b.dataset.v); });
162
  $('#chkSubs').onchange = e => { state.subs = e.target.checked; state.dirty = true; };
 
 
163
 
164
  window.addEventListener('keydown', e => {
165
  if (e.target.closest('input,textarea')) return;
solana-clock/js/publish.js CHANGED
@@ -27,7 +27,7 @@ ${dur}动画,带你看完 Solana 的完整故事。
27
  tags: ['Solana', 'SOL', '区块链', '加密货币', '比特币', '科普动画', 'Web3', '币圈故事', '涨知识', '财经'],
28
  tagFmt: t => `#${t}`,
29
  tagJoin: ' ',
30
- tips: ['竖屏 1080×1920 导出后直接上传,画面关键内容已避开顶部状态栏、右侧按钮和底部文案区。', '封面请选「封面」页里的竖屏封面,标题建议选第 1 条(带数字更容易点开)。', '发布时间推荐工作日 12:00 或 20:00–22:00。'],
31
  },
32
  bilibili: {
33
  label: 'B 站',
 
27
  tags: ['Solana', 'SOL', '区块链', '加密货币', '比特币', '科普动画', 'Web3', '币圈故事', '涨知识', '财经'],
28
  tagFmt: t => `#${t}`,
29
  tagJoin: ' ',
30
+ tips: ['竖屏 1080×1920 导出后直接上传即可,全片自带中文字幕。', '封面请选「封面」页里的竖屏 3:4 封面,标题建议选第 1 条(带数字更容易点开)。', '发布时间推荐工作日 12:00 或 20:00–22:00。'],
31
  },
32
  bilibili: {
33
  label: 'B 站',
solana-clock/js/scenes-b.js CHANGED
@@ -115,7 +115,7 @@ const outage = {
115
  };
116
  function newsBar(ctx, L, tag, msg, a, t) {
117
  const P = L.portrait;
118
- const y = P ? L.safe.b - 230 : L.H - 230, x = P ? L.safe.l : 0, w = P ? L.safe.r - L.safe.l : L.W;
119
  ctx.save(); ctx.globalAlpha = a;
120
  ctx.fillStyle = 'rgba(10,10,20,.92)'; ctx.fillRect(x, y, w, 70);
121
  ctx.fillStyle = RED; ctx.fillRect(x, y, P ? 120 : 190, 70);
 
115
  };
116
  function newsBar(ctx, L, tag, msg, a, t) {
117
  const P = L.portrait;
118
+ const y = P ? L.sub.y - 170 : L.H - 230, x = 0, w = L.W;
119
  ctx.save(); ctx.globalAlpha = a;
120
  ctx.fillStyle = 'rgba(10,10,20,.92)'; ctx.fillRect(x, y, w, 70);
121
  ctx.fillStyle = RED; ctx.fillRect(x, y, P ? 120 : 190, 70);
solana-clock/js/timeline.js CHANGED
@@ -25,7 +25,7 @@ export function buildTimeline() {
25
 
26
  export const SIZES = { landscape: [1920, 1080], portrait: [1080, 1920] };
27
 
28
- /** Layout regions. Portrait keeps clear of notch/status bar (top), Douyin caption + nav (bottom) and action buttons (right). */
29
  export function layout(orient) {
30
  const [W, H] = SIZES[orient];
31
  if (orient === 'landscape') {
@@ -38,13 +38,14 @@ export function layout(orient) {
38
  unit: 1,
39
  };
40
  }
41
- const safe = { l: 64, t: 236, r: W - 156, b: H - 450 };
 
42
  return {
43
  orient, W, H, portrait: true,
44
  safe,
45
- head: { x: 72, y: safe.t + 52 },
46
- stage: { x: safe.l, y: safe.t + 170, w: safe.r - safe.l, h: 880, cx: (safe.l + safe.r) / 2, cy: safe.t + 170 + 440 },
47
- sub: { cx: (safe.l + safe.r) / 2, y: safe.b - 76, maxW: safe.r - safe.l - 10, size: 50 },
48
  unit: 1,
49
  };
50
  }
 
25
 
26
  export const SIZES = { landscape: [1920, 1080], portrait: [1080, 1920] };
27
 
28
+ /** Layout regions. Portrait is a normal full-frame 9:16 layout with symmetric margins. */
29
  export function layout(orient) {
30
  const [W, H] = SIZES[orient];
31
  if (orient === 'landscape') {
 
38
  unit: 1,
39
  };
40
  }
41
+ const safe = { l: 72, t: 110, r: W - 72, b: H - 110 };
42
+ const sy = safe.t + 200, sh = 1160;
43
  return {
44
  orient, W, H, portrait: true,
45
  safe,
46
+ head: { x: 80, y: safe.t + 52 },
47
+ stage: { x: safe.l, y: sy, w: safe.r - safe.l, h: sh, cx: (safe.l + safe.r) / 2, cy: sy + sh / 2 },
48
+ sub: { cx: (safe.l + safe.r) / 2, y: safe.b - 120, maxW: safe.r - safe.l - 10, size: 54 },
49
  unit: 1,
50
  };
51
  }