Ethscriptions commited on
Commit
0cc4b6d
·
verified ·
1 Parent(s): f5c411e

solana-clock: 发布文案 + 横竖屏封面

Browse files
.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

  • SHA256: d8cb1e85f0b6370b4e4e2de5593f3c21d27c12fa8b08ba38fe6d6a659758b6f6
  • Pointer size: 132 Bytes
  • Size of remote file: 2.38 MB
solana-clock/covers/cover-portrait.png ADDED

Git LFS Details

  • SHA256: 40e770aa7c78285c9906129b1922b39845eef39fa65c3ede3ced4a9d55a92313
  • Pointer size: 132 Bytes
  • Size of remote file: 2.15 MB
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:710128990e82a6e9b800590829c4aafc04858adb4e5e7d2c759c52d38b5dca93
3
- size 129180
 
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:7187fcdff06944959bf4237af6c05857fcfee7e2347acf8a90bd6189a7e2c8ef
3
- size 129048
 
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:2d88e49addb97e22470ed9f8b25b2fa7d3c574f3765eeba7fd3c34f679586750
3
- size 127684
 
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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())