First public release of the html-explainer skill: deterministic HTML-to-MP4 explainer video pipeline for AI coding agents. Additions since v1.2.1: - scripts/lint_frames.py: pre-render audit of the eight frame-contract rules - scripts/peek_frame.mjs: single-scene screenshot preview (seconds, not minutes) - scripts/check_integrity.py: version/catalog/count consistency + stray-CDN scan - Bilingual README (README.md Chinese default, README.en.md English) - LICENSE (MIT), THIRD_PARTY_NOTICES.md, licenses/Apache-2.0.txt - CHANGELOG.md, CONTRIBUTING.md, .gitignore, .gitattributes - GitHub CI workflow and issue/PR templates Fixes and docs: - document lint_frames.py and peek_frame.mjs, previously absent from the file table - add the lint stage to the pipeline order - remove references to private client projects so the skill can ship publicly - record the full upstream attribution chain for the style catalog - package_skill.py now excludes repo-hygiene files and keeps licence files
274 lines
13 KiB
JavaScript
274 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* cover_build.mjs —— 封面双方案渲染器(抖音主封面 16:9 + 兼容 3:4)。
|
||
*
|
||
* 【为什么不用裁切】
|
||
* 成片是 16:9。从 16:9 居中裁 3:4,只能留 3/4 的宽(丢掉两侧各 12.5%)……
|
||
* 不对——重算:h=1080,3:4 → w=810,丢掉 1110px = 57.8% 的画面宽度。
|
||
* 大字钩子必然被切。所以"兼容 3:4"必须是**重新排版的另一张图**,
|
||
* 不是同一张图的裁切版。本脚本因此渲染**两份独立的 HTML**:
|
||
* frames/cover_169.html → out/cover_169.png (1920×1080)
|
||
* frames/cover_34.html → out/cover_34.png (1440×1080)
|
||
* 两份共享同一套视觉基因(背景/配色/主视觉/钩子文案),只是构图重排。
|
||
*
|
||
* 【和场景帧的关系】
|
||
* 复用 render_video.mjs 同一套底座:依赖探测链(ffmpeg/浏览器)、
|
||
* Playwright seek、确定性截图。差别只有三点:
|
||
* ① 不注入字幕层 / 进度条(封面不要字幕)
|
||
* ② 不逐帧,只 seek 到一个"封面时刻"(默认片尾/静置态,可 --at 指定)
|
||
* ③ 输出单张 PNG(可选 --jpg)
|
||
* 封面帧同样遵守 GSAP 契约(window.__tl),所以它也能被 --at 定位到动画中间态。
|
||
*
|
||
* 【封面时刻怎么选】
|
||
* 封面要"完整态"而不是入场中间态。默认 --at last:seek 到时间轴末尾
|
||
* (tl.duration()),即所有元素都到位的那一帧。也可以用 --at 2.4 指定绝对秒。
|
||
*
|
||
* 用法(项目目录 = 含 project.json 的目录):
|
||
* node cover_build.mjs <projectDir> [--at last|<秒>] [--jpg] [--keep-frames]
|
||
* [--only 169|34] [--scale 2]
|
||
*
|
||
* 帧作者只要在 frames/ 下放 cover_169.html / cover_34.html 即可;
|
||
* 缺哪个就跳过哪个(不报错),两份都在就出两张。
|
||
*
|
||
* 环境解析顺序(与 render_video.mjs 完全一致):
|
||
* 浏览器 env BROWSER_PATH → Chrome → Edge → playwright 自带 chromium
|
||
* ffmpeg 仅在需要后处理时用(--jpg 走 Playwright 直出,不依赖 ffmpeg)
|
||
*/
|
||
import { createRequire } from 'node:module';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const SKILL_ROOT = path.resolve(__dirname, '..');
|
||
const require = createRequire(path.join(SKILL_ROOT, 'node', 'package.json'));
|
||
|
||
const log = (m) => process.stderr.write(`[cover] ${m}\n`);
|
||
const die = (m) => { process.stderr.write(`[cover] ✗ ${m}\n`); process.exit(1); };
|
||
|
||
// ---------- 封面规格(改这里就能加新平台尺寸) ----------
|
||
const SPECS = {
|
||
'169': {
|
||
key: '169',
|
||
label: '抖音主封面 · 16:9',
|
||
file: 'cover_169.html',
|
||
out: 'cover_169.png',
|
||
width: 1920,
|
||
height: 1080,
|
||
note: '信息流/播放页主封面',
|
||
},
|
||
'34': {
|
||
key: '34',
|
||
label: '兼容 3:4 · 主页栅格',
|
||
file: 'cover_34.html',
|
||
out: 'cover_34.png',
|
||
width: 1440,
|
||
height: 1080,
|
||
note: '个人主页封面栅格按 3:4 裁 —— 独立排版,防切字',
|
||
},
|
||
};
|
||
|
||
function parseArgs(argv) {
|
||
const a = { _: [] };
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const t = argv[i];
|
||
if (t === '--at') a.at = argv[++i];
|
||
else if (t === '--jpg') a.jpg = true;
|
||
else if (t === '--only') a.only = argv[++i];
|
||
else if (t === '--scale') a.scale = parseFloat(argv[++i]);
|
||
else if (t === '--keep-frames') a.keepFrames = true;
|
||
else if (t === '--browser') a.browser = argv[++i];
|
||
else if (!t.startsWith('--')) a._.push(t);
|
||
}
|
||
return a;
|
||
}
|
||
|
||
// ---------- 浏览器解析(与 render_video.mjs 同一套探测链) ----------
|
||
function resolveBrowserExec() {
|
||
if (process.env.BROWSER_PATH && fs.existsSync(process.env.BROWSER_PATH)) return process.env.BROWSER_PATH;
|
||
const cands = [];
|
||
if (process.platform === 'win32') {
|
||
cands.push(
|
||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||
path.join(process.env.LOCALAPPDATA || '', 'Google\\Chrome\\Application\\chrome.exe'),
|
||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||
);
|
||
} else if (process.platform === 'darwin') {
|
||
cands.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||
'/Applications/Chromium.app/Contents/MacOS/Chromium');
|
||
} else {
|
||
cands.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
||
'/usr/bin/microsoft-edge');
|
||
}
|
||
for (const p of cands) { try { if (p && fs.existsSync(p)) return p; } catch { /* ignore */ } }
|
||
return null;
|
||
}
|
||
|
||
// ---------- 字体就绪等待(与渲染器同款,5s 硬顶防 CDN 卡死) ----------
|
||
const FONT_WAIT = `new Promise(function (resolve) {
|
||
var cap = setTimeout(resolve, 5000);
|
||
try {
|
||
var links = Array.prototype.slice.call(document.querySelectorAll('link[rel="stylesheet"]'));
|
||
Promise.all(links.map(function (l) {
|
||
return new Promise(function (r) {
|
||
l.addEventListener('load', r, { once: true });
|
||
l.addEventListener('error', r, { once: true });
|
||
try { if (l.sheet) r(); } catch (e) {}
|
||
});
|
||
})).then(function () {
|
||
var step = function () { requestAnimationFrame(function () { requestAnimationFrame(resolve); }); };
|
||
if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
|
||
document.fonts.ready.then(function () { clearTimeout(cap); step(); });
|
||
} else { clearTimeout(cap); step(); }
|
||
});
|
||
} catch (e) { clearTimeout(cap); resolve(); }
|
||
})`;
|
||
|
||
// —— 封面 seek:★必须是真正的函数字面量,不能是字符串 ——
|
||
// (字符串形式会被 Playwright 当表达式求值一次,函数体不执行、静默返回 undefined,
|
||
// 症状是封面永远停在首帧。见 lessons.md #17。)
|
||
// 封面比场景帧宽松一点:允许没有 GSAP 时间轴(纯静态封面也可以),
|
||
// 但只要有,就 seek 到指定时刻;同时同步所有 CSS 动画。
|
||
const coverSeek = async (o) => {
|
||
const { at } = o; // at === null 表示"到时间轴末尾"
|
||
let tl = window.__tl || null;
|
||
if (!tl && window.__timelines) {
|
||
for (const k in window.__timelines) { if (window.__timelines[k]) { tl = window.__timelines[k]; break; } }
|
||
}
|
||
let seekTo = 0;
|
||
if (tl) {
|
||
if (typeof tl.pause === 'function') tl.pause(); // 先停(防自动起播)
|
||
const dur = (typeof tl.duration === 'function' && tl.duration()) || 0;
|
||
seekTo = at == null ? dur : Math.min(at, dur || at);
|
||
// ★ 必须传 suppressEvents=false:否则本次 seek 的 onUpdate/onComplete 被掐掉,
|
||
// 用 onUpdate 写 DOM 的元素会停在初值(封面常见:数字标签)。见 lessons.md #27。
|
||
tl.pause(seekTo, false);
|
||
} else if (at != null) {
|
||
seekTo = at;
|
||
}
|
||
if (document.getAnimations) {
|
||
document.getAnimations().forEach((a) => {
|
||
try { a.pause(); a.currentTime = Math.round(seekTo * 1000); } catch (e) { /* ignore */ }
|
||
});
|
||
}
|
||
await new Promise((r) => { requestAnimationFrame(() => { requestAnimationFrame(r); }); });
|
||
return { seekTo, duration: tl && typeof tl.duration === 'function' ? tl.duration() : null };
|
||
};
|
||
|
||
async function renderOne(browser, projectDir, spec, opts) {
|
||
const htmlPath = path.join(projectDir, 'frames', spec.file);
|
||
if (!fs.existsSync(htmlPath)) return { spec, skipped: `没有 frames/${spec.file}` };
|
||
|
||
// ★ deviceScaleFactor 必须传给 newPage(= 建 context),**不能**传给 browser.launch ——
|
||
// launch 不接受该选项且静默忽略,结果封面出成 1 倍图(1920×1080 而非 3840×2160)。
|
||
// 这是 lessons #18 的同一类坑,见 #28。
|
||
const page = await browser.newPage({
|
||
viewport: { width: spec.width, height: spec.height },
|
||
deviceScaleFactor: opts.scale || 2,
|
||
});
|
||
try {
|
||
await page.addInitScript(`window.__MG_RENDER__ = true;`);
|
||
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'domcontentloaded' });
|
||
await page.evaluate(FONT_WAIT);
|
||
|
||
// 封面帧允许纯静态(无 GSAP)—— 有就 seek,没有就用文档原样
|
||
const at = opts.at === 'last' || opts.at == null ? null : parseFloat(opts.at);
|
||
if (Number.isNaN(at)) throw new Error(`--at 只能是 last 或秒数,收到:${opts.at}`);
|
||
const r = await page.evaluate(coverSeek, { at });
|
||
|
||
const outDir = path.join(projectDir, 'out');
|
||
fs.mkdirSync(outDir, { recursive: true });
|
||
const outName = opts.jpg ? spec.out.replace(/\.png$/, '.jpg') : spec.out;
|
||
const outPath = path.join(outDir, outName);
|
||
await page.screenshot({
|
||
path: outPath,
|
||
type: opts.jpg ? 'jpeg' : 'png',
|
||
quality: opts.jpg ? 92 : undefined,
|
||
scale: 'device', // ★ 显式取设备像素,否则可能回落到 css 像素
|
||
});
|
||
return {
|
||
spec, outPath,
|
||
detail: `seek ${r.seekTo.toFixed(2)}s${r.duration != null ? ` / 轴长 ${r.duration.toFixed(2)}s` : '(无时间轴·静态)'}`,
|
||
};
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
const args = parseArgs(process.argv.slice(2));
|
||
const projectDir = path.resolve(args._[0] || '.');
|
||
const pjPath = path.join(projectDir, 'project.json');
|
||
if (!fs.existsSync(pjPath)) die(`没有 project.json:${pjPath}`);
|
||
const pj = JSON.parse(fs.readFileSync(pjPath, 'utf8'));
|
||
|
||
// 只渲指定规格
|
||
let specs = Object.values(SPECS);
|
||
if (args.only) {
|
||
const want = String(args.only).split(',').map(s => s.trim());
|
||
specs = want.map(k => {
|
||
if (!SPECS[k]) die(`未知规格 --only ${k}(可用:${Object.keys(SPECS).join(' / ')})`);
|
||
return SPECS[k];
|
||
});
|
||
}
|
||
|
||
// 有没有封面帧?一个都没有就直接说清楚怎么建
|
||
const present = specs.filter(s => fs.existsSync(path.join(projectDir, 'frames', s.file)));
|
||
if (!present.length) {
|
||
die(`frames/ 下没有封面帧。封面要**单独排版**(不是从成片抽帧、更不是裁切)——\n` +
|
||
` 复制 assets/cover-template.html 分别做两份:\n` +
|
||
specs.map(s => ` frames/${s.file} → ${s.width}×${s.height} ${s.label}`).join('\n'));
|
||
}
|
||
|
||
let chromium;
|
||
try { ({ chromium } = require('playwright-core')); }
|
||
catch { die('playwright-core 缺失:在技能目录 node/ 下 npm install playwright-core(见 setup_env.sh)'); }
|
||
|
||
const browserExec = args.browser || resolveBrowserExec();
|
||
// ★ launch 只放浏览器进程级选项;viewport/deviceScaleFactor 属于 context,必须给 newPage。
|
||
const launchOpts = {};
|
||
if (browserExec) { launchOpts.executablePath = browserExec; log(`浏览器:${browserExec}`); }
|
||
else log('浏览器:用 playwright 自带 chromium(首次需 npx playwright install chromium)');
|
||
|
||
log(`项目:${projectDir}`);
|
||
log(`封面时刻:${args.at === 'last' || args.at == null ? '时间轴末尾(完整态)' : `绝对 ${args.at}s`}`);
|
||
log(`输出倍率:${args.scale || 2}x(deviceScaleFactor)· ${args.jpg ? 'JPEG' : 'PNG'}`);
|
||
|
||
const browser = await chromium.launch(launchOpts);
|
||
const results = [], errors = [];
|
||
for (const spec of specs) {
|
||
try {
|
||
const r = await renderOne(browser, projectDir, spec, args);
|
||
if (r.skipped) log(`– 跳过 ${spec.label}:${r.skipped}`);
|
||
else { results.push(r); log(`✓ ${spec.label} ${spec.width}×${spec.height} → ${r.outPath} (${r.detail})`); }
|
||
} catch (e) {
|
||
errors.push(`${spec.label}: ${e.message}`);
|
||
}
|
||
}
|
||
await browser.close();
|
||
|
||
if (errors.length) die(`封面渲染失败:\n ${errors.join('\n ')}`);
|
||
|
||
// ---------- 自检:两张图的构图禁区 ----------
|
||
// 3:4 版本会被平台再裁,最要命的是「大字钩子贴边」——
|
||
// 这里只报尺寸与体积,几何禁区靠 out/cover_report.md 的人工清单(见 references/cover-guide.md)。
|
||
const lines = [`# 封面自检 · ${pj.slug || 'video'}`, ''];
|
||
for (const r of results) {
|
||
const st = fs.statSync(r.outPath);
|
||
lines.push(`- **${r.spec.label}** \`${path.basename(r.outPath)}\` ${r.spec.width}×${r.spec.height} · ${(st.size / 1024).toFixed(0)}KB · ${r.spec.note}`);
|
||
}
|
||
lines.push('', '> 两份封面共享同一视觉基因、各自独立排版(不是裁切关系)。',
|
||
'> 上传前核对:① 大字钩子距四边 ≥ 96px ② 钩子在任何画幅下都不被切',
|
||
'> ③ 16:9 版主体偏左/右留出信息流裁切余量 ④ 封面文字与视频首帧钩子一致(观众预期)');
|
||
const repPath = path.join(projectDir, 'out', 'cover_report.md');
|
||
fs.writeFileSync(repPath, lines.join('\n'), 'utf-8');
|
||
log(`✓ 自检:${repPath}`);
|
||
log(`★ 提醒:3:4 版是独立排版,上传时平台上选「竖版封面」;` +
|
||
`若平台只允许一张,用 3:4 版(多数主页栅格按 3:4 裁,16:9 会被切两侧)。`);
|
||
}
|
||
|
||
main().catch(e => die(e.stack || String(e)));
|