Spaces:
Running
Running
solana-clock: 发布文案 + 横竖屏封面
Browse files- .gitattributes +2 -0
- README.md +3 -3
- solana-clock/covers/cover-landscape.png +3 -0
- solana-clock/covers/cover-portrait.png +3 -0
- 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/js/cover.js +95 -0
- solana-clock/js/publish.js +97 -0
- solana-clock/tools/covers.py +18 -0
.gitattributes
CHANGED
|
@@ -9,3 +9,5 @@ guozu/covers/cover-portrait.png filter=lfs diff=lfs merge=lfs -text
|
|
| 9 |
solana-clock/fonts/sol-sc-900.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 10 |
solana-clock/fonts/sol-sc-700.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 11 |
solana-clock/fonts/sol-sc-400.woff2 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 9 |
solana-clock/fonts/sol-sc-900.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 10 |
solana-clock/fonts/sol-sc-700.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 11 |
solana-clock/fonts/sol-sc-400.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
solana-clock/covers/cover-landscape.png filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
solana-clock/covers/cover-portrait.png filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -24,9 +24,9 @@ pinned: false
|
|
| 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 封面图 |
|
| 29 |
-
| 8 | 无头浏览器自动化测试 + 修复 + 最终推送 |
|
| 30 |
|
| 31 |
## 1. 创意核心
|
| 32 |
|
|
|
|
| 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 | 无头浏览器自动化测试 + 修复 + 最终推送 | ⏳ 进行中 |
|
| 30 |
|
| 31 |
## 1. 创意核心
|
| 32 |
|
solana-clock/covers/cover-landscape.png
ADDED
|
Git LFS Details
|
solana-clock/covers/cover-portrait.png
ADDED
|
Git LFS Details
|
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:30b9331388c3fb7bf2daab2464a825a2acce436ab7affe32c7c29535d6418a90
|
| 3 |
+
size 137196
|
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:2585dfd8acb1283c16ecbdf5470a71cb714de115e5f37c416f6e4ed80cde9c8b
|
| 3 |
+
size 137292
|
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:94a8026a83f635762a62180c51575b47a927e87ab306b4955f32c076fab5c736
|
| 3 |
+
size 136976
|
solana-clock/js/cover.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 5 |
+
function bg(ctx, W, H, P) {
|
| 6 |
+
const g = ctx.createLinearGradient(0, 0, W, H); g.addColorStop(0, '#160a35'); g.addColorStop(.55, '#07040f'); g.addColorStop(1, '#03110c');
|
| 7 |
+
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
|
| 8 |
+
glow(ctx, W * .18, H * .2, Math.max(W, H) * .55, PURPLE, .5);
|
| 9 |
+
glow(ctx, W * .9, H * .85, Math.max(W, H) * .5, GREEN, .28);
|
| 10 |
+
glow(ctx, W * .6, H * .05, Math.max(W, H) * .3, PINK, .18);
|
| 11 |
+
const r = rng(7); ctx.fillStyle = '#fff';
|
| 12 |
+
for (let i = 0; i < 220; i++) { ctx.globalAlpha = .15 + r() * .6; const s = r() * 2.2 + .4; ctx.fillRect(r() * W, r() * H, s, s); }
|
| 13 |
+
ctx.globalAlpha = 1;
|
| 14 |
+
// perspective grid floor
|
| 15 |
+
const hy = H * (P ? .78 : .74);
|
| 16 |
+
ctx.save(); ctx.globalAlpha = .35;
|
| 17 |
+
const fg = ctx.createLinearGradient(0, hy, 0, H); fg.addColorStop(0, hexA(GREEN, 0)); fg.addColorStop(1, hexA(GREEN, .8));
|
| 18 |
+
ctx.strokeStyle = fg; ctx.lineWidth = 2;
|
| 19 |
+
for (let i = -16; i <= 16; i++) { ctx.beginPath(); ctx.moveTo(W / 2 + i * 26, hy); ctx.lineTo(W / 2 + i * W * .2, H); ctx.stroke(); }
|
| 20 |
+
for (let j = 1; j < 12; j++) { const z = j / 12, y = hy + (H - hy) * z * z; ctx.globalAlpha = .35 * z; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
|
| 21 |
+
ctx.restore();
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function clockMark(ctx, cx, cy, R) {
|
| 25 |
+
glow(ctx, cx, cy, R * 2.4, PURPLE, .55);
|
| 26 |
+
ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.fillStyle = 'rgba(10,6,24,.85)'; ctx.fill(); ctx.restore();
|
| 27 |
+
// gradient rim
|
| 28 |
+
ctx.save(); const g = ctx.createLinearGradient(cx - R, cy + R, cx + R, cy - R); g.addColorStop(0, PINK); g.addColorStop(.5, PURPLE); g.addColorStop(1, GREEN);
|
| 29 |
+
ctx.strokeStyle = g; ctx.lineWidth = R * .06; ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.stroke(); ctx.restore();
|
| 30 |
+
dial(ctx, cx, cy, R * .9, { hand: .335, minute: .87, ticks: 60, lw: R * .018, accent: GREEN });
|
| 31 |
+
solLogo(ctx, cx, cy + R * .36, R * .5, 1, 1);
|
| 32 |
+
// 400ms arc
|
| 33 |
+
ctx.save(); ctx.strokeStyle = GREEN; ctx.lineWidth = R * .05; ctx.lineCap = 'round'; ctx.shadowBlur = 30; ctx.shadowColor = GREEN;
|
| 34 |
+
ctx.beginPath(); ctx.arc(cx, cy, R * 1.12, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * .4 / 6); ctx.stroke(); ctx.restore();
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function chartBand(ctx, rect, lw) {
|
| 38 |
+
const r = drawChart(ctx, rect, { from: 0, to: LAST, prog: 1, log: true, grid: false, dates: false, head: false, headLabel: false, lw, yMin: .4, yMax: 420, color: GREEN, color2: PURPLE });
|
| 39 |
+
const mark = (date, label, color, up) => {
|
| 40 |
+
const i = idx(date), x = r.X(i), y = r.Y(Math.max(.5, priceAt(i)));
|
| 41 |
+
glow(ctx, x, y, 46, color, .9); ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(x, y, lw * 1.3, 0, 7); ctx.fill();
|
| 42 |
+
text(ctx, label, x, y + (up ? -42 : 44), { size: lw * 5.4, fam: F_MONO, weight: 800, color, align: 'center', stroke: 6, strokeColor: 'rgba(0,0,0,.8)' });
|
| 43 |
+
};
|
| 44 |
+
return { r, mark };
|
| 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);
|
| 78 |
+
ctx.restore();
|
| 79 |
+
}
|
| 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') };
|
| 87 |
+
document.querySelectorAll('[data-dl]').forEach(b => b.onclick = () => {
|
| 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 |
+
});
|
| 95 |
+
}
|
solana-clock/js/publish.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Publish copy for Douyin / Bilibili with one-click copy. Chapter timestamps are computed from the live timeline.
|
| 2 |
+
import { TL } from './engine.js';
|
| 3 |
+
|
| 4 |
+
const fmt = s => { s = Math.floor(s); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; };
|
| 5 |
+
const len = s => Array.from(s).length;
|
| 6 |
+
const chapters = () => TL.scenes.map(s => `${fmt(s.start)} ${s.chapter}${s.year ? `(${s.year})` : ''}`).join('\n');
|
| 7 |
+
|
| 8 |
+
export function buildCopy() {
|
| 9 |
+
const dur = `${Math.floor(TL.total / 60)} 分 ${Math.round(TL.total % 60)} 秒`;
|
| 10 |
+
return {
|
| 11 |
+
douyin: {
|
| 12 |
+
label: '抖音',
|
| 13 |
+
titles: [
|
| 14 |
+
'400 毫秒一个块!Solana 凭什么这么快?7 分钟看懂它的九年',
|
| 15 |
+
'给区块链装上时钟的人:Solana 从深夜灵感到华尔街 ETF',
|
| 16 |
+
'跌掉 96% 被宣判死刑,它又爬回来了|Solana 动画史',
|
| 17 |
+
'一杯啤酒想出的点子,造出了最快的公链之一',
|
| 18 |
+
],
|
| 19 |
+
titleMax: 55,
|
| 20 |
+
desc: `2017 年一个深夜,两杯咖啡加一杯啤酒,一位高通工程师想到:如果给区块链装上一个时钟呢?
|
| 21 |
+
从「历史证明」到 312 逆风上线,从 2021 狂飙 170 倍、宕机 17 小时,到 FTX 暴雷跌去 96%,再到 Meme 狂欢、历史新高与华尔街 ETF——
|
| 22 |
+
${dur}动画,带你看完 Solana 的完整故事。
|
| 23 |
+
|
| 24 |
+
📌 仅为科普回顾,不构成任何投资建议
|
| 25 |
+
👉 你觉得 Solana 下一个十年会怎样?评论区聊聊`,
|
| 26 |
+
descMax: 1000,
|
| 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 站',
|
| 34 |
+
titles: [
|
| 35 |
+
'【动画科普】给区块链装上时钟的人:Solana 九年沉浮全史(2017–2026)',
|
| 36 |
+
'400 毫秒一个块,跌 96% 又爬回来:一口气看懂 Solana 的前世今生',
|
| 37 |
+
'从一杯啤酒到华尔街 ETF:Solana 是怎么成为“最快公链”的?【加密史动画】',
|
| 38 |
+
],
|
| 39 |
+
titleMax: 80,
|
| 40 |
+
desc: `用 ${dur}的动画,讲完 Solana(SOL)从 2017 年一个深夜的灵感,到 2026 年的完整发展史。
|
| 41 |
+
|
| 42 |
+
⏱ 章节
|
| 43 |
+
${chapters()}
|
| 44 |
+
|
| 45 |
+
📊 价格数据:SOL 日线收盘价(2020-04 至 2026-10);画面中标注“示意”的为演示性可视化。
|
| 46 |
+
🎙 旁白、配乐、音效与画面均为程序实时生成。
|
| 47 |
+
⚠️ 本视频仅为科普回顾,不构成任何投资建议。
|
| 48 |
+
|
| 49 |
+
如果觉得有帮助,一键三连支持一下~ 你还想看哪条链的故事?评论区告诉我!`,
|
| 50 |
+
descMax: 2000,
|
| 51 |
+
tags: ['Solana', 'SOL', '区块链', '加密货币', '科普', '动画', '历史证明', 'FTX', 'Meme币', '比特币'],
|
| 52 |
+
tagFmt: t => t,
|
| 53 |
+
tagJoin: ',',
|
| 54 |
+
tips: ['分区建议:知识 → 财经商业(或 科学科普);类型选「自制」。', '横屏 1920×1080 导出后上传;简介里的章节时间点会自动显示为可点击跳转。', 'B 站标签最多 10 个,每个不超过 20 字,已按此准备好。'],
|
| 55 |
+
},
|
| 56 |
+
};
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const esc = s => s.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
| 60 |
+
|
| 61 |
+
async function copyText(str, toast) {
|
| 62 |
+
try { await navigator.clipboard.writeText(str); }
|
| 63 |
+
catch {
|
| 64 |
+
const ta = document.createElement('textarea'); ta.value = str; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
| 65 |
+
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
| 66 |
+
}
|
| 67 |
+
toast('已复制到剪贴板');
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export function initPublishPanel({ toast }) {
|
| 71 |
+
const C = buildCopy();
|
| 72 |
+
const box = document.querySelector('#publishBox');
|
| 73 |
+
let plat = 'douyin';
|
| 74 |
+
const store = {};
|
| 75 |
+
function render() {
|
| 76 |
+
const c = C[plat];
|
| 77 |
+
const tagStr = c.tags.map(c.tagFmt).join(c.tagJoin);
|
| 78 |
+
const all = `${c.titles[0]}\n\n${c.desc}\n\n${plat === 'douyin' ? tagStr : '标签:' + tagStr}`;
|
| 79 |
+
Object.assign(store, { all, desc: c.desc, tags: tagStr });
|
| 80 |
+
c.titles.forEach((t, i) => store['t' + i] = t);
|
| 81 |
+
box.innerHTML = `
|
| 82 |
+
<div class="pub-block"><div class="pub-head"><b>标题(任选一条)</b><span class="count">上限 ${c.titleMax} 字</span></div>
|
| 83 |
+
<div class="pub-alt">${c.titles.map((t, i) => `<div class="row"><span>${esc(t)} <em class="count ${len(t) > c.titleMax ? 'over' : ''}">${len(t)}字</em></span><button class="copy" data-k="t${i}">复制</button></div>`).join('')}</div></div>
|
| 84 |
+
<div class="pub-block"><div class="pub-head"><b>简介</b><span><span class="count ${len(c.desc) > c.descMax ? 'over' : ''}">${len(c.desc)} / ${c.descMax}</span> <button class="copy" data-k="desc">复制</button></span></div>
|
| 85 |
+
<div class="pub-text">${esc(c.desc)}</div></div>
|
| 86 |
+
<div class="pub-block"><div class="pub-head"><b>标签 / 话题</b><button class="copy" data-k="tags">复制</button></div>
|
| 87 |
+
<div class="tags">${c.tags.map(t => `<span>${esc(c.tagFmt(t))}</span>`).join('')}</div></div>
|
| 88 |
+
<div class="pub-block"><button class="primary" data-k="all" style="width:100%">一键复制全部(标题 + 简介 + 标签)</button></div>
|
| 89 |
+
<div class="pub-block"><div class="pub-head"><b>发布小贴士</b></div><ul class="muted" style="padding-left:18px;margin:0">${c.tips.map(t => `<li>${esc(t)}</li>`).join('')}</ul></div>`;
|
| 90 |
+
}
|
| 91 |
+
box.addEventListener('click', e => { const b = e.target.closest('[data-k]'); if (b) copyText(store[b.dataset.k], toast); });
|
| 92 |
+
document.querySelector('#segPlat').addEventListener('click', e => {
|
| 93 |
+
const b = e.target.closest('button'); if (!b) return;
|
| 94 |
+
plat = b.dataset.v; document.querySelectorAll('#segPlat button').forEach(x => x.classList.toggle('on', x === b)); render();
|
| 95 |
+
});
|
| 96 |
+
render();
|
| 97 |
+
}
|
solana-clock/tools/covers.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Render covers/cover-landscape.png and covers/cover-portrait.png with headless Chromium."""
|
| 3 |
+
import asyncio, base64, pathlib, sys
|
| 4 |
+
from playwright.async_api import async_playwright
|
| 5 |
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
| 6 |
+
base = sys.argv[1] if len(sys.argv) > 1 else 'http://localhost:8765/solana-clock/index.html'
|
| 7 |
+
async def main():
|
| 8 |
+
async with async_playwright() as p:
|
| 9 |
+
b = await p.chromium.launch(); pg = await b.new_page(viewport={'width': 1500, 'height': 950})
|
| 10 |
+
errs = []; pg.on('pageerror', lambda e: errs.append(str(e)))
|
| 11 |
+
await pg.goto(base + '?nomix=1&tab=cover', wait_until='networkidle')
|
| 12 |
+
await pg.wait_for_function('window.__ready'); await pg.wait_for_timeout(1200)
|
| 13 |
+
for o, sel in [('landscape', '#coverL'), ('portrait', '#coverP')]:
|
| 14 |
+
d = await pg.evaluate(f'(async()=>{{const m=await import("./js/cover.js");const c=document.querySelector("{sel}");m.drawCover(c.getContext("2d"),"{o}");return c.toDataURL("image/png")}})()')
|
| 15 |
+
(ROOT / 'covers' / f'cover-{o}.png').write_bytes(base64.b64decode(d.split(',')[1]))
|
| 16 |
+
await pg.screenshot(path='/tmp/ui-cover.png')
|
| 17 |
+
print('errors:', errs or 'none'); await b.close()
|
| 18 |
+
asyncio.run(main())
|