Spaces:
Running
Running
solana-clock: 竖屏改为普通构图 + 4:3/3:4 封面
Browse files- README.md +9 -9
- solana-clock/covers/cover-landscape.png +2 -2
- solana-clock/covers/cover-portrait.png +2 -2
- solana-clock/css/app.css +1 -1
- solana-clock/fonts/sol-grotesk.woff2 +0 -0
- solana-clock/fonts/sol-mono.woff2 +0 -0
- solana-clock/fonts/sol-sc-400.woff2 +2 -2
- solana-clock/fonts/sol-sc-700.woff2 +2 -2
- solana-clock/fonts/sol-sc-900.woff2 +2 -2
- solana-clock/index.html +3 -4
- solana-clock/js/cover.js +19 -18
- solana-clock/js/engine.js +0 -13
- solana-clock/js/exporter.js +1 -1
- solana-clock/js/main.js +2 -6
- solana-clock/js/publish.js +1 -1
- solana-clock/js/scenes-b.js +1 -1
- solana-clock/js/timeline.js +6 -5
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 双布局 + 烧录字幕
|
| 25 |
| 4 | 程序化音乐(15 段编曲:和弦垫 / 贝斯 / 琶音 / 鼓 / 时钟滴答)+ 57 种合成音效(258 个触发点)+ 旁白闪避混音 + 混响 + 限幅;按场景分段渲染,约 3 秒即可出声 | ✅ 完成 |
|
| 26 |
-
| 5 | 播放器 UI(章节跳转 / 拖动进度 / 横竖切换 /
|
| 27 |
| 6 | 抖音 / B站 发布标题、简介、标签(一键复制,含章节时间戳与字数统计) | ✅ 完成 |
|
| 28 |
-
| 7 | 横
|
| 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/ 横竖
|
| 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 |
-
- 「封面」面板:下载
|
| 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
|
|
Git LFS Details
|
solana-clock/covers/cover-portrait.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
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:
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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>抖音
|
| 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>横
|
| 127 |
</figure>
|
| 128 |
<figure>
|
| 129 |
<canvas id="coverP" class="cover-p"></canvas>
|
| 130 |
-
<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
|
| 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
|
| 50 |
ctx.save();
|
| 51 |
bg(ctx, W, H, P);
|
| 52 |
if (!P) {
|
| 53 |
-
const { mark } = chartBand(ctx, { x:
|
| 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,
|
| 59 |
-
pill(ctx, '7 分钟动画史 · 2017 → 2026',
|
| 60 |
-
gradText(ctx, 'SOLANA',
|
| 61 |
-
text(ctx, '给区块链装上时钟的人',
|
| 62 |
-
text(ctx, '400 毫秒一个块 · 跌 96% 又爬回来',
|
| 63 |
} else {
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
text(ctx, '
|
| 68 |
-
|
| 69 |
-
|
| 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,
|
| 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] =
|
| 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动画史_
|
| 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
|
| 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,
|
| 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
|
| 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 导出后直接上传,
|
| 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.
|
| 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
|
| 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:
|
|
|
|
| 42 |
return {
|
| 43 |
orient, W, H, portrait: true,
|
| 44 |
safe,
|
| 45 |
-
head: { x:
|
| 46 |
-
stage: { x: safe.l, y:
|
| 47 |
-
sub: { cx: (safe.l + safe.r) / 2, y: safe.b -
|
| 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 |
}
|