README 精简(中文 528 → 364 行,-31%;英文 552 → 390 行,-29%): - 删除使用者不需要的深层细节,改为指向 references/ 与 SKILL.md: 横向对比全表、八条契约表、project.json 逐字段说明、仓库目录树、B() 代码详解 - 第一定位改为「Agent Skill」,而不是「一条渲染流水线」 - 新增「安装」章:一句话安装 prompt + 九个智能体的技能目录对照表 + 环境要求 - 新增「参考思路与姊妹项目」:anything2explainer 与 html-video,并明确标注 两者为不同作者的独立项目,本项目运行时不依赖其代码 文档语言统一为中文(README.en.md 除外): CHANGELOG.md、CONTRIBUTING.md、THIRD_PARTY_NOTICES.md、PR/issue 模板、CI 注释。 许可证原文(MIT / Apache-2.0)与 GSAP 自带 README 属第三方逐字文本,未翻译。 跨平台可移植性修复(原先隐含 WorkBuddy 专属路径): - peek_frame.mjs 的技能根兜底改为遍历各智能体技能目录 (.workbuddy / .claude / .codex / .gemini / .cursor / .agents),已用隔离夹具正反验证 - setup_env.sh 标注哪几个候选是 WorkBuddy 托管路径 - SKILL.md 新增「跨 Agent 安装」章,安装路径不再写死单一平台 修正一处事实错误:THIRD_PARTY_NOTICES 原称两个方法来源项目「均为作者本人作品」, 与事实不符,已改为「他人独立项目」并补充来源链接。
193 lines
7.9 KiB
JavaScript
193 lines
7.9 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* peek_frame.mjs —— 单帧速览(不渲全片,直接给某个场景截图看设计)。
|
||
*
|
||
* 用途:场景构建阶段/修复阶段的「照镜子」工具。全片渲染要跑 5000 帧,
|
||
* 而这里只对指定帧 seek 到几个时点各截一张 —— 秒级出图,先看设计对不对。
|
||
*
|
||
* 用法:
|
||
* node script/peek_frame.mjs <项目目录> <帧id> [--at 80] [--n 3] [--scale 1]
|
||
* node script/peek_frame.mjs . hook --at 40,80 # 指定百分比时点
|
||
* node script/peek_frame.mjs . --all --at 85 # 所有帧都截一张
|
||
*
|
||
* 产出:<项目>/render/peek/<id>@<pct>.png
|
||
*
|
||
* ★ 与 render_video.mjs 同款确定性 seek:__MG_RENDER__ 先置位(帧据此跳过自动起播),
|
||
* 再 tl.pause(t, false) + 同步 CSS 动画 currentTime。**第二参必须是 false**,
|
||
* 否则 onUpdate 类回调被静默抑制(see lessons #27)。
|
||
*/
|
||
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));
|
||
|
||
// 本脚本放在**项目**的 script/ 下(不是技能目录里),所以不能靠相对路径找技能根。
|
||
// 解析顺序:env → 向上逐级找含 node/playwright-core 的目录 → 各智能体技能目录下的同名落点。
|
||
// 与平台无关:任何一处命中即可,命中不了就报错退出(不会静默用错目录)。
|
||
const SKILL_SUBDIRS = [
|
||
['.workbuddy', 'skills'], // WorkBuddy
|
||
['.claude', 'skills'], // Claude Code
|
||
['.codex', 'skills'], // OpenAI Codex
|
||
['.gemini', 'skills'], // Gemini CLI
|
||
['.cursor', 'skills'], // Cursor
|
||
['.agents', 'skills'], // 通用约定
|
||
];
|
||
function resolveSkillRoot() {
|
||
const cands = [];
|
||
if (process.env.HTML_EXPLAINER_ROOT) cands.push(process.env.HTML_EXPLAINER_ROOT);
|
||
let d = __dirname;
|
||
for (let i = 0; i < 6; i++) {
|
||
cands.push(d);
|
||
const p = path.dirname(d);
|
||
if (p === d) break;
|
||
d = p;
|
||
}
|
||
const homes = [process.env.USERPROFILE, process.env.HOME].filter(Boolean);
|
||
for (const home of homes) {
|
||
for (const seg of SKILL_SUBDIRS) {
|
||
cands.push(path.join(home, ...seg, 'html-explainer'));
|
||
}
|
||
}
|
||
for (const c of cands) {
|
||
try {
|
||
if (fs.existsSync(path.join(c, 'node', 'node_modules', 'playwright-core', 'package.json'))) return c;
|
||
} catch { /* ignore */ }
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const SKILL_ROOT = resolveSkillRoot();
|
||
const require = createRequire(SKILL_ROOT
|
||
? path.join(SKILL_ROOT, 'node', 'package.json')
|
||
: path.join(__dirname, 'package.json'));
|
||
|
||
const log = (m) => process.stderr.write(`[peek] ${m}\n`);
|
||
const die = (m) => { process.stderr.write(`[peek] ✗ ${m}\n`); process.exit(1); };
|
||
|
||
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 === '--n') a.n = parseInt(argv[++i], 10);
|
||
else if (t === '--scale') a.scale = parseFloat(argv[++i]);
|
||
else if (t === '--all') a.all = true;
|
||
else if (!t.startsWith('--')) a._.push(t);
|
||
}
|
||
return a;
|
||
}
|
||
|
||
function resolveBrowserExec() {
|
||
if (process.env.BROWSER_PATH && fs.existsSync(process.env.BROWSER_PATH)) return process.env.BROWSER_PATH;
|
||
const cands = process.platform === 'win32' ? [
|
||
'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',
|
||
] : ['/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;
|
||
}
|
||
|
||
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(); }
|
||
})`;
|
||
|
||
// ★ 真函数字面量(不能传字符串,否则只求值不执行 → 每帧静止且零报错)
|
||
const seek = async (o) => {
|
||
const tl = window.__tl || (window.__timelines && Object.values(window.__timelines)[0]) || null;
|
||
const pct = o.pct;
|
||
let seekTo = 0, dur = null;
|
||
if (tl && typeof tl.duration === 'function') {
|
||
dur = tl.duration() || 0;
|
||
seekTo = dur * (pct / 100);
|
||
tl.pause(0, false);
|
||
tl.pause(seekTo, false); // ← 第二参必须 false
|
||
}
|
||
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, dur };
|
||
};
|
||
|
||
async function main() {
|
||
const args = parseArgs(process.argv.slice(2));
|
||
const projectDir = path.resolve(args._[0] || '.');
|
||
const pj = JSON.parse(fs.readFileSync(path.join(projectDir, 'project.json'), 'utf8'));
|
||
const W = pj.width || 1920, H = pj.height || 1080;
|
||
|
||
const framesDir = path.join(projectDir, 'frames');
|
||
let ids = [];
|
||
if (args.all) {
|
||
ids = (pj.order || []).filter((id) => fs.existsSync(path.join(framesDir, `${id}.html`)));
|
||
} else {
|
||
const id = args._[1];
|
||
if (!id) die('用法:node peek_frame.mjs <项目> <帧id> | <项目> --all');
|
||
if (!fs.existsSync(path.join(framesDir, `${id}.html`))) die(`没有 frames/${id}.html`);
|
||
ids = [id];
|
||
}
|
||
if (!ids.length) die('没有可截的帧');
|
||
|
||
const pcts = args.at
|
||
? String(args.at).split(',').map((s) => parseFloat(s)).filter((x) => !Number.isNaN(x))
|
||
: (args.n ? Array.from({ length: args.n }, (_, i) => Math.round(((i + 1) / (args.n + 1)) * 100)) : [85]);
|
||
|
||
const outDir = path.join(projectDir, 'render', 'peek');
|
||
fs.mkdirSync(outDir, { recursive: true });
|
||
|
||
let chromium;
|
||
try { ({ chromium } = require('playwright-core')); }
|
||
catch { die('playwright-core 缺失:技能目录 node/ 下 npm install playwright-core'); }
|
||
|
||
const launchOpts = {};
|
||
const be = resolveBrowserExec();
|
||
if (be) { launchOpts.executablePath = be; log(`浏览器:${be}`); }
|
||
|
||
const browser = await chromium.launch(launchOpts);
|
||
let n = 0;
|
||
for (const id of ids) {
|
||
const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: args.scale || 1 });
|
||
try {
|
||
await page.addInitScript(`window.__MG_RENDER__ = true;`);
|
||
await page.goto(pathToFileURL(path.join(framesDir, `${id}.html`)).href, { waitUntil: 'domcontentloaded' });
|
||
await page.evaluate(FONT_WAIT);
|
||
for (const pct of pcts) {
|
||
const r = await page.evaluate(seek, { pct });
|
||
const out = path.join(outDir, `${id}@${String(pct).replace('.', '_')}.png`);
|
||
await page.screenshot({ path: out, type: 'png', scale: 'device' });
|
||
log(`✓ ${id} @${pct}% seek ${r.seekTo.toFixed(2)}s / 轴长 ${r.dur == null ? '无' : r.dur.toFixed(2)}s → ${path.basename(out)}`);
|
||
n++;
|
||
}
|
||
} catch (e) {
|
||
log(`✗ ${id}: ${e.message}`);
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
await browser.close();
|
||
log(`共 ${n} 张 → ${outDir}`);
|
||
}
|
||
|
||
main().catch((e) => die(e.stack || String(e)));
|