chore: prepare html-explainer v1.2.2 for public release

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
This commit is contained in:
MohZhuang committed 2026-09-21 12:46:23 +08:00
commit 66b61002f2
42 files changed
+8004

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
check_integrity.py —— 仓库自洽性检查(CI 与本地都能跑,零第三方依赖)。
检查项都对应真实发生过的漂移风险:
1. SKILL.md 的 version 与 CHANGELOG.md 最新版本号一致
—— 发布时最容易漏:改了 SKILL.md 忘写 CHANGELOG(或反之)。
2. style-catalog.json 与 style-catalog.md 的风格 ID 集合完全一致
—— 两者由 import_styles.py 同时生成,不一致 = 有人只手改了其中一个。
3. style-catalog.json 的条目数 == SKILL.md 里声明的风格数
—— 更新风格库时 SKILL.md 的硬编码数字最容易忘记同步。
4. JSON 每条都有 id / license 字段,且 id 唯一
—— 授权信息缺失会让 THIRD_PARTY_NOTICES 失真。
5. 帧模板 / 封面模板不得出现外链字体或 CDN 资源
—— 离线渲染硬要求,见 frame-contract.md 契约 2。
用法:
python scripts/check_integrity.py # 以脚本所在技能的根目录为准
python scripts/check_integrity.py --root . # 指定仓库根
退出码:0 = 全过,1 = 有失败项。
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
OK = " \u2713"
BAD = " \u2717"
# 外链资源(离线渲染禁止)
REMOTE = re.compile(r"""(?:<link[^>]+href|@import\s+url\(|<script[^>]+src)\s*=?\s*["'(]?https?://""", re.I)
def read(path: str) -> str:
with open(path, encoding="utf-8") as fh:
return fh.read()
def check_version(root: str, fails: list[str]) -> None:
"""SKILL.md version 必须与 CHANGELOG.md 最新版本号一致。"""
skill = os.path.join(root, "SKILL.md")
changelog = os.path.join(root, "CHANGELOG.md")
if not os.path.isfile(skill) or not os.path.isfile(changelog):
fails.append("version: 找不到 SKILL.md 或 CHANGELOG.md")
return
m = re.search(r"^version:\s*([0-9]+\.[0-9]+\.[0-9]+)\s*$", read(skill), re.M)
if not m:
fails.append("version: SKILL.md 里没有合法的 version 字段")
return
skill_ver = m.group(1)
# CHANGELOG 的第一个 "## [x.y.z]" 标题即最新发布版本
m2 = re.search(r"^##\s*\[([0-9]+\.[0-9]+\.[0-9]+)\]", read(changelog), re.M)
if not m2:
fails.append("version: CHANGELOG.md 里找不到 '## [x.y.z]' 标题")
return
log_ver = m2.group(1)
if skill_ver == log_ver:
print(f"{OK} 版本一致:{skill_ver}")
else:
fails.append(
f"version: SKILL.md 是 {skill_ver},CHANGELOG.md 最新是 {log_ver} —— 发布前必须同步"
)
def check_catalog(root: str, fails: list[str]) -> int | None:
"""两个风格目录必须描述同一组风格。"""
md_path = os.path.join(root, "references", "style-catalog.md")
js_path = os.path.join(root, "references", "style-catalog.json")
if not os.path.isfile(md_path) or not os.path.isfile(js_path):
fails.append("catalog: 找不到 references/style-catalog.md 或 .json")
return None
try:
entries = json.loads(read(js_path))
except Exception as exc: # noqa: BLE001
fails.append(f"catalog: style-catalog.json 解析失败 —— {exc}")
return None
if not isinstance(entries, list):
fails.append("catalog: style-catalog.json 顶层应为数组")
return None
# ---- 结构完整性 ----
ids: list[str] = []
for i, e in enumerate(entries):
if not isinstance(e, dict):
fails.append(f"catalog: 第 {i} 条不是对象")
continue
eid = e.get("id")
if not eid:
fails.append(f"catalog: 第 {i} 条缺少 id")
continue
if not e.get("license"):
fails.append(f"catalog: {eid} 缺少 license 字段(授权信息不完整)")
ids.append(eid)
dupes = sorted({x for x in ids if ids.count(x) > 1})
if dupes:
fails.append(f"catalog: id 重复 —— {', '.join(dupes)}")
# ---- .md 与 .json 的 ID 集合比对(md 用反引号包 id)----
md_ids = set(re.findall(r"`((?:frame|vfx)-[a-z0-9-]+|frame-product-promo-30s)`", read(md_path)))
json_ids = set(ids)
only_md = sorted(md_ids - json_ids)
only_json = sorted(json_ids - md_ids)
if only_md or only_json:
if only_md:
fails.append(f"catalog: 只在 .md 里出现 —— {', '.join(only_md)}")
if only_json:
fails.append(f"catalog: 只在 .json 里出现 —— {', '.join(only_json)}")
else:
print(f"{OK} 风格目录一致:{len(json_ids)} 个风格,.md 与 .json 同步")
return len(json_ids)
def check_catalog_count(root: str, count: int | None, fails: list[str]) -> None:
"""SKILL.md 里声明的风格数必须等于实际条数。"""
if count is None:
return
skill = os.path.join(root, "SKILL.md")
if not os.path.isfile(skill):
return
body = read(skill)
# 匹配「共 23 种风格」/「23 个模板」;用后顾断言排除「2–4 种风格轮换」这类区间
nums = {int(n) for n in re.findall(r"(?<![\d\u2013\u2014-])(\d+)\s*(?:个|种)\s*(?:模板|风格)", body)}
if not nums:
print(f" · 计数:SKILL.md 未声明风格数量,跳过(实际 {count})")
return
if count in nums:
print(f"{OK} 计数一致:SKILL.md 声明 {sorted(nums)},实际 {count}")
else:
fails.append(
f"count: SKILL.md 声明 {sorted(nums)} 个风格,实际 {count} 个 —— 更新风格库后忘记同步文案"
)
def check_no_remote_assets(root: str, fails: list[str]) -> None:
"""模板里不得有外链资源(离线渲染硬要求)。"""
targets = []
assets = os.path.join(root, "assets")
if os.path.isdir(assets):
targets += [os.path.join(assets, f) for f in sorted(os.listdir(assets))
if f.endswith((".html", ".htm"))]
bad = []
for p in targets:
for m in REMOTE.finditer(read(p)):
bad.append(f"{os.path.basename(p)}: {m.group(0)[:70]}")
if bad:
for b in bad:
fails.append(f"remote: 模板含外链资源(离线渲染会失败/退化)—— {b}")
elif targets:
print(f"{OK} 模板无外链资源:{len(targets)} 个模板检查通过")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--root", default=None, help="仓库根目录(默认 = 本脚本所在技能的根)")
args = ap.parse_args()
root = args.root or os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
root = os.path.abspath(root)
print(f"== html-explainer 自洽性检查 ==\n根目录:{root}\n")
fails: list[str] = []
check_version(root, fails)
count = check_catalog(root, fails)
check_catalog_count(root, count, fails)
check_no_remote_assets(root, fails)
print("-" * 40)
if fails:
print(f"✗ {len(fails)} 项失败:\n")
for f in fails:
print(f" · {f}")
return 1
print("全部检查通过 ✓")
return 0
if __name__ == "__main__":
sys.exit(main())
+273
View File
@@ -0,0 +1,273 @@
#!/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)));
+397
View File
@@ -0,0 +1,397 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
import_styles.py —— 【移植期一次性工具,非运行时依赖】
把已安装的 html-video 仓库里的模板设计规范(`SKILL.md` 的画布/字体/时间轴/配色
纪律 + 每个模板的元数据)**抄成纯文本**,输出到本技能的:
references/style-catalog.md
references/style-catalog.json
【为什么叫"抄"不叫"导入"】
这是**知识提取**,不是代码依赖。跑完本脚本,技能就永久拥有了 23 个模板的风格知识,
之后**做视频完全不需要 html-video 存在** —— 类比:去餐厅把菜谱抄回家,
以后自己做菜不需要那家餐厅营业。
验证零依赖:
grep -rn "html-video" scripts/ assets/ references/ \\
--include="*.py" --include="*.mjs" --include="*.html" --include="*.md"
→ 只会命中注释署名,没有 import / 没有读文件 / 没有 subprocess
【什么时候才需要跑它】
仅当你想**从上游 html-video 更新风格目录**时。日常做视频永远不需要。
目标机器上没装 html-video → 本脚本用不了 → 但技能照跑不误。
用法:
python import_styles.py --from "<html-video 仓库>/templates" [--out <技能>/references]
python import_styles.py --from "D:/tools/html-video/templates" # 示例(换成你的路径)
python import_styles.py --from <dir> --out <技能>/references
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
SKILL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 分类中文名(模板 yaml 的 category → 人话)
CAT_ZH = {
"presentation": "演示 / 标题卡",
"data-viz": "数据可视化",
"explainer": "图解 / 流程",
"ambient": "氛围 / 空镜",
"marketing": "营销 / Hero",
"intro-outro": "片头片尾",
"social-shorts": "社媒竖版",
"product-demo": "产品演示",
"audio": "音频驱动",
}
def parse_yaml_lite(text: str) -> dict:
"""
极简 YAML 读取(只取点分路径的标量),不依赖 pyyaml。
· 支持任意层级(按缩进栈推路径),所以 `output.duration.min_sec` 能取到
· 支持 `>` / `|` 块标量(取后续更深缩进的正文行,拼成一行)
· 支持行内数组 `[a, b]`(原样返回字符串)
"""
out: dict = {}
stack: list[tuple[int, str]] = [] # (indent, key)
i = 0
lines = text.splitlines()
while i < len(lines):
raw = lines[i]
i += 1
if not raw.strip() or raw.lstrip().startswith("#"):
continue
indent = len(raw) - len(raw.lstrip())
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", raw.strip())
if not m:
continue
key, val = m.group(1), m.group(2).strip()
# 弹栈:同层或更浅层的旧键
while stack and stack[-1][0] >= indent:
stack.pop()
path = ".".join([k for _, k in stack] + [key])
if val in (">", "|", ">-", "|-", ">+", "|+"):
# 块标量:收后续缩进更深(或空行)的行
buf = []
while i < len(lines):
nxt = lines[i]
if nxt.strip() == "":
buf.append("")
i += 1
continue
nind = len(nxt) - len(nxt.lstrip())
if nind <= indent:
break
buf.append(nxt.strip())
i += 1
out[path] = " ".join(x for x in buf if x).strip()
continue
out[path] = val
if val == "":
stack.append((indent, key))
return out
def unquote(s: str) -> str:
return (s or "").strip().strip('"').strip("'")
def clean_list(s: str) -> str:
"""`["16:9", "9:16"]` → `16:9 · 9:16`"""
s = (s or "").strip()
s = s.strip("[]")
parts = [p.strip().strip('"').strip("'") for p in s.split(",")]
return " · ".join(p for p in parts if p)
def parse_frontmatter(md: str) -> dict:
"""取 SKILL.md frontmatter 的标量键(含 zh_name / emoji / recommended)。"""
m = re.match(r"^---\s*\n(.*?)\n---", md, re.S)
if not m:
return {}
fm = {}
for line in m.group(1).splitlines():
mm = re.match(r"^([a-zA-Z_][\w-]*):\s*(.*)$", line)
if mm:
fm[mm.group(1)] = unquote(mm.group(2))
return fm
def parse_style_body(md: str) -> dict:
"""从 SKILL.md 正文提炼画布/字体/时间轴/配色纪律要点。"""
body = re.sub(r"^---\s*\n.*?\n---", "", md, flags=re.S)
info = {}
for key, pat in [
("canvas", r"【画布】\s*(.+?)(?:\n\n|\n【|$)"),
("fonts", r"【字体】\s*(.+?)(?:\n\n|\n【|$)"),
("timeline", r"【主结构[^】]*】\s*(.+?)(?:\n\n|\n【|$)"),
("palette", r"【配色纪律】\s*(.+?)(?:\n\n|\n【|$)"),
("content", r"【内容纪律】\s*(.+?)(?:\n\n|\n【|$)"),
]:
m = re.search(pat, body, re.S)
if m:
info[key] = " ".join(m.group(1).split())
return info
def collect(tpl_root: str) -> list[dict]:
rows = []
for name in sorted(os.listdir(tpl_root)):
d = os.path.join(tpl_root, name)
yml = os.path.join(d, "template.html-video.yaml")
if not os.path.isfile(yml):
continue
try:
ytext = open(yml, encoding="utf-8").read()
except OSError:
continue
y = parse_yaml_lite(ytext)
skill_md = os.path.join(d, "SKILL.md")
fm, body = {}, {}
if os.path.isfile(skill_md):
md = open(skill_md, encoding="utf-8").read()
fm = parse_frontmatter(md)
body = parse_style_body(md)
# best_for 是 yaml 里的字符串列表:
# best_for:
# - "Section / chapter divider"
best_for: list[str] = []
m_bf = re.search(r"^best_for:\s*$", ytext, re.M)
if m_bf:
for line in ytext[m_bf.end():].splitlines():
s = line.strip()
if s.startswith("- "):
best_for.append(unquote(s[2:]))
elif s and not s.startswith("#"):
break
# 上游来源(via_skill.name)——用于署名
via_skill = ""
m_via = re.search(r"via_skill:\s*\n(?:.*\n)*?\s+name:\s*(.+)", ytext)
if m_via:
via_skill = unquote(m_via.group(1))
src = None
for cand in (os.path.join(d, "source", "index.html"),
os.path.join(d, "index.html")):
if os.path.isfile(cand):
src = cand
break
kf_count, color_sample = 0, ""
multi = False
if src:
html = open(src, encoding="utf-8", errors="replace").read()
kf_count = html.count("@keyframes")
# 多 composition 结构:index.html 引 compositions/*.html 或 data-composition-src
multi = bool(re.search(r'data-composition-src|compositions/[a-z\-]+\.html', html))
# 抓几个十六进制色值当配色线索(最多 5 个)
cols = re.findall(r"#[0-9a-fA-F]{6}\b", html)
seen, uniq = set(), []
for c in cols:
cl = c.lower()
if cl not in seen and cl not in ("#ffffff", "#000000"):
seen.add(cl)
uniq.append(c)
if len(uniq) >= 5:
break
color_sample = " ".join(uniq)
rows.append({
"id": unquote(y.get("id", name)),
"name": unquote(y.get("name", name)),
"zh_name": fm.get("zh_name", ""),
"emoji": fm.get("emoji", ""),
"category": unquote(y.get("category", "")),
"subcategory": unquote(y.get("subcategory", "")),
"engine": unquote(y.get("engine", "hyperframes")),
"description": unquote(y.get("description", fm.get("description", ""))),
"zh_description": fm.get("zh_description", ""),
"dmin": unquote(y.get("output.duration.min_sec", "")),
"dmax": unquote(y.get("output.duration.max_sec", "")),
"aspects": clean_list(unquote(y.get("output.resolution.supported_aspects", "")))
or "16:9",
"recommended": fm.get("recommended", ""),
"style": body,
"best_for": best_for,
"kf": kf_count,
"colors": color_sample,
"multi": multi,
"license": unquote(y.get("license.spdx", "")),
"via": via_skill,
"has_src": bool(src),
"has_skill_md": bool(fm),
})
return rows
def classify(r: dict) -> str:
"""模板的复刻难度分级。
rich —— 单文件 + 纯 CSS @keyframes 时间轴。**可直接 seek 渲染,零改动**。
gsap —— 多 composition + GSAP(CDN 加载),或 Remotion 引擎。需重写为单文件
(见 `lessons.md` #9 的 `__hvUnfreeze` 整类坑)。
"""
if r.get("engine") == "remotion":
return "gsap"
if r.get("multi"):
return "gsap"
if r["kf"] >= 1 and r["has_src"]:
return "rich"
return "gsap"
def to_markdown(rows: list[dict]) -> str:
L = []
L.append("# 画面风格目录(从 html-video 模板库导入)")
L.append("")
L.append("> **这是风格知识,不是代码依赖。**")
L.append("> html-explainer 运行时不引用 html-video 仓库;本目录用于挑选画面语言,")
L.append("> 复刻时须由 agent 改写成「离线 + 纯 CSS keyframes + 字幕带禁区」的合规帧。")
L.append("> 复刻契约见 `references/frame-contract.md`;本目录由 `scripts/import_styles.py` 生成。")
L.append("")
n_rich = sum(1 for r in rows if classify(r) == "rich")
n_gsap = len(rows) - n_rich
L.append(f"共 **{len(rows)}** 个风格,覆盖 {len({r['category'] for r in rows})} 个类别。")
L.append("")
L.append("### 两类模板(复刻成本不同,先看这个)")
L.append("")
L.append(f"| 类型 | 数量 | 结构 | 适配 seek 渲染器 |")
L.append("|---|---|---|---|")
L.append(f"| **rich(推荐)** | {n_rich} | 单文件 + 纯 CSS `@keyframes` 时间轴;"
f"带完整风格规范(时间轴/配色/字体)| **零改动可渲染**,只需换字体栈 + 让出字幕带 |")
L.append(f"| **gsap** | {n_gsap} | 多 composition + CDN 加载 GSAP | "
f"须重写为单文件 + 本地 GSAP(见 `lessons.md` #9 的 `__hvUnfreeze` 整类坑)|")
L.append("")
L.append("> **rich 为什么零改动可用**:seek 渲染器用 "
"`document.getAnimations().currentTime = t*1000` 驱动 CSS 动画,"
"时间轴已由 CSS `animation-delay` 声明,天然确定性。"
"gsap 型的多 composition 结构在单帧里没有起播钩子,"
"正是 `__hvUnfreeze` 静默失败的来源。")
L.append("")
# 速查表
L.append("## 速查表")
L.append("")
L.append("| id | 名称 | 类别 | 时长档 | 画幅 | 类型 | 说明 |")
L.append("|---|---|---|---|---|---|---|")
for r in rows:
dur = f"{r['dmin']}–{r['dmax']}s" if r["dmin"] else "可变"
typ = "★rich" if classify(r) == "rich" else "gsap"
L.append(f"| `{r['id']}` | {r['emoji']} {r['zh_name'] or r['name']} | "
f"{CAT_ZH.get(r['category'], r['category'])}/{r['subcategory']} | {dur} | "
f"{r['aspects']} | {typ} | {(r['zh_description'] or r['description'])[:40]} |")
L.append("")
# 分类索引
L.append("## 按用途挑风格")
L.append("")
bycat: dict[str, list[dict]] = {}
for r in rows:
bycat.setdefault(r["category"], []).append(r)
for cat, items in sorted(bycat.items()):
L.append(f"### {CAT_ZH.get(cat, cat)}")
L.append("")
for r in items:
L.append(f"- **`{r['id']}`** — {r['zh_name'] or r['name']}")
if r["zh_description"] or r["description"]:
L.append(f" - {r['zh_description'] or r['description']}")
L.append("")
# 风格基因详表
L.append("## 风格基因(复刻依据)")
L.append("")
L.append("每个风格给出画布 / 字体 / 时间轴 / 配色纪律 —— 这四项足以复刻视觉签名。")
L.append("")
for r in rows:
cls = classify(r)
L.append(f"### {r['emoji']} `{r['id']}` — {r['zh_name'] or r['name']}")
L.append("")
L.append(f"- **复刻类型**:{'★ rich(零改动可渲染)' if cls == 'rich' else 'gsap(需重写为单文件)'}")
L.append(f"- **引擎**:{r['engine']} · **CSS 动画数**:{r['kf']}")
dur = f"{r['dmin']}–{r['dmax']}s" if r["dmin"] else "可变"
L.append(f"- **时长档**:{dur} · **画幅**:{r['aspects']}")
if r.get("best_for"):
L.append(f"- **适合**:{';'.join(r['best_for'])}")
if r.get("colors"):
L.append(f"- **源码色值线索**:{r['colors']}")
st = r["style"]
if st.get("canvas"):
L.append(f"- **画布**:{st['canvas']}")
if st.get("fonts"):
L.append(f"- **字体**:{st['fonts']}")
if st.get("timeline"):
L.append(f"- **时间轴**:{st['timeline']}")
if st.get("palette"):
L.append(f"- **配色纪律**:{st['palette']}")
if st.get("content"):
L.append(f"- **内容纪律**:{st['content']}")
if cls != "rich" and not st:
L.append("- ⚠️ 该模板为多 composition + GSAP 结构,**无现成风格规范** —— "
"复刻前先读 `source/index.html` 与 `compositions/*.html` 提取视觉 DNA。")
L.append("")
# 署名
L.append("## 来源与署名")
L.append("")
L.append("画面风格提炼自 html-video 模板库(全部 Apache-2.0 / MIT,允许再分发与商用)。")
L.append("上游设计来源见下(部分模板直接原创于 html-video 作者):")
L.append("")
via_map: dict[str, list[str]] = {}
for r in rows:
if r.get("via"):
via_map.setdefault(r["via"], []).append(r["id"])
for via, ids in sorted(via_map.items()):
L.append(f"- **{via}** → {', '.join('`' + i + '`' for i in ids)}")
L.append("")
L.append("> 改编自模板的帧,建议在片尾或说明里保留风格署名。")
L.append("")
return "\n".join(L)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--from", dest="src", required=True,
help="html-video 的 templates/ 目录")
ap.add_argument("--out", default=None)
ap.add_argument("--json", default=None, help="同时导出 JSON 目录")
args = ap.parse_args()
src = os.path.abspath(args.src)
if not os.path.isdir(src):
print(f"✗ 不是目录:{src}", file=sys.stderr)
return 1
rows = collect(src)
if not rows:
print(f"✗ 在 {src} 没找到 template.html-video.yaml", file=sys.stderr)
return 1
out = args.out or os.path.join(SKILL_ROOT, "references", "style-catalog.md")
os.makedirs(os.path.dirname(out), exist_ok=True)
open(out, "w", encoding="utf-8", newline="\n").write(to_markdown(rows))
print(f"✓ 风格目录:{out}({len(rows)} 个风格)")
if args.json:
slim = [{k: v for k, v in r.items() if k != "style"} | {"style": r["style"]}
for r in rows]
open(args.json, "w", encoding="utf-8", newline="\n").write(
json.dumps(slim, ensure_ascii=False, indent=2))
print(f"✓ JSON 目录:{args.json}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
lint_frames.py —— 场景帧静态体检(渲染前跑,比渲完全片再发现问题便宜得多)。
检查项(全部来自 references/frame-contract.md 的八条契约 + 已知踩坑):
1. 画布:html,body 是否声明 1920×1080(或 project.json 的尺寸)
2. 外部字体 / CDN:<link href="http...">、fonts.googleapis、@import url(http
3. 色值字面量:#RRGGBB / rgb() / rgba() 出现在 CSS 里(theme.css 变量之外)
—— 例外:纯黑/纯白阴影 rgba(0,0,0,x) 允许(阴影不是"配色")
4. 时间轴注册:window.__tl = (或 window.__timelines)
5. GSAP 本地引用:../assets/gsap.min.js(禁 CDN)
6. 墙钟逻辑:setInterval / setTimeout(数字) / requestAnimationFrame 计数
7. CSS transition 入场
8. B() 兜底:B('x') || 数字
9. 字幕带禁区:CSS 里 bottom < 176px 的规则且带内容属性(启发式,转人工确认)
10. 字体栈:必须含中文字体族(Microsoft YaHei / PingFang 等)
用法:
python lint_frames.py --project . # 全量
python lint_frames.py --project . --only hook,decision
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
HEX = re.compile(r"#[0-9a-fA-F]{3,8}\b")
RGBA = re.compile(r"\brgba?\([^)]*\)")
FONT_LINK = re.compile(r"<link[^>]+href\s*=\s*[\"']https?://", re.I)
IMPORT_HTTP = re.compile(r"@import\s+url\(\s*[\"']?https?://", re.I)
GSAP_CDN = re.compile(r"<script[^>]+src\s*=\s*[\"']https?://", re.I)
B_FALLBACK = re.compile(r"B\([^)]*\)\s*\|\|")
WALLCLOCK = re.compile(r"setInterval\s*\(|setTimeout\s*\(\s*[a-zA-Z_]|requestAnimationFrame\s*\(\s*(?!function)")
CSS_TRANSITION = re.compile(r"transition\s*:", re.I)
BOTTOM_RULE = re.compile(r"(?<![-\w])bottom\s*:\s*(-?\d+)(px|%)")
CJK_FONT = re.compile(r"YaHei|PingFang|Noto Sans CJK|Source Han|Hiragino|SimHei|SimSun")
# 允许出现的颜色字面量(阴影/透视遮罩,不参与配色语义)
ALLOWED_RGBA_PREFIX = ("rgba(0,0,0", "rgba(0, 0, 0", "rgba(255,255,255", "rgba(255, 255, 255")
def check_file(path: str, want_w: int, want_h: int, is_cover: bool = False) -> list[str]:
src = open(path, encoding="utf-8").read()
name = os.path.basename(path)
out: list[str] = []
# 1. 画布(封面有自己的规格:16:9 版 1920×1080 / 3:4 版 1440×1080)
if is_cover:
if not re.search(r"width\s*:\s*\d+px", src) or not re.search(r"height\s*:\s*\d+px", src):
out.append("封面:未见 width/height 像素声明")
elif not re.search(rf"width\s*:\s*{want_w}px", src) or not re.search(rf"height\s*:\s*{want_h}px", src):
out.append(f"画布:未见 width:{want_w}px / height:{want_h}px 声明")
# 2. 外部字体 / 远程资源
if FONT_LINK.search(src):
out.append("外部 <link href=http…>(字体/样式)→ 违反契约 2(渲染器 5s 字体硬顶)")
if IMPORT_HTTP.search(src):
out.append("@import url(http…) → 违反契约 2")
if GSAP_CDN.search(src):
out.append("<script src=http…> → 违反契约 8(必须用 ../assets/gsap.min.js)")
# 3. 色值字面量
for m in HEX.finditer(src):
val = m.group(0)
# 允许在注释里说明用(如出处署名),其余算违规
out.append(f"色值字面量 {val} → 违反契约 3(颜色只取 theme.css 变量)")
for m in RGBA.finditer(src):
v = m.group(0).replace(" ", "").lower()
if v.startswith(ALLOWED_RGBA_PREFIX):
continue
if "var(" in v: # rgba(var(--signal-rgb), .4) —— 取自主题变量,合规
continue
out.append(f"色值字面量 {m.group(0)} → 违反契约 3")
# 4/5. 时间轴 + GSAP
if "window.__tl" not in src and "window.__timelines" not in src:
out.append("未见 window.__tl 注册 → 渲染器找不到时间轴(契约 4)")
if "gsap" in src.lower() and "../assets/gsap.min.js" not in src:
out.append("用了 gsap 但没引 ../assets/gsap.min.js")
# 6/7. 墙钟 / transition
if WALLCLOCK.search(src):
out.append("疑似墙钟逻辑(setInterval / setTimeout / rAF)→ seek 渲染下必然失控")
if CSS_TRANSITION.search(src):
out.append("CSS transition: → 逐帧 seek 不生效,入场必须用 GSAP")
# 8. B() 兜底
if B_FALLBACK.search(src):
out.append("B('…') || 数字 兜底写法 → 禁止(beats 缺失要在构建期暴露)")
# 9. 字幕带禁区(启发式)
for m in BOTTOM_RULE.finditer(src):
v = int(m.group(1))
unit = m.group(2)
if unit == "px" and 0 <= v < 176:
# 取该行上下文,判断是不是有内容的容器
line_start = src.rfind("\n", 0, m.start()) + 1
line_end = src.find("\n", m.end())
line = src[line_start:line_end].strip()
out.append(f"bottom:{v}px 落在字幕带/进度条禁区 → {line[:70]}")
for m in re.finditer(r"bottom\s*:\s*calc\([^)]*\)", src):
out.append(f"bottom:calc(…) 请人工核对是否 ≥176px → {m.group(0)[:70]}")
# 10. 中文字体栈
if CJK_FONT.search(src) is None:
out.append("字体栈里没看到中文字体族 → 可能落到默认字体")
return [f"{name}: {x}" for x in sorted(set(out))]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--project", default=".")
ap.add_argument("--only", default="")
ap.add_argument("--include-covers", action="store_true")
args = ap.parse_args()
proj = os.path.abspath(args.project)
pj = json.load(open(os.path.join(proj, "project.json"), encoding="utf-8"))
W, H = pj.get("width", 1920), pj.get("height", 1080)
frames_dir = os.path.join(proj, "frames")
names = sorted(f for f in os.listdir(frames_dir) if f.endswith(".html"))
skip = {"_template.html"} | (set() if args.include_covers else {"cover_169.html", "cover_34.html"})
only = {s.strip() for s in args.only.split(",") if s.strip()}
names = [n for n in names if n not in skip and (not only or n[:-5] in only)]
all_issues: list[str] = []
for n in names:
all_issues += check_file(os.path.join(frames_dir, n), W, H, is_cover=n.startswith("cover_"))
print(f"[lint] 检查 {len(names)} 个帧 · 画布 {W}×{H}")
if not all_issues:
print("[lint] ✓ 无契约违规")
return 0
for line in all_issues:
print(" ✗", line)
print(f"[lint] 共 {len(all_issues)} 条待确认")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
make_theme.py —— 主题配色:预设四档 + 按主题词现推一套,写 theme.css。
【血统】配色语义与色值预设移植自 anything2explainer 的 theme.ts /
make_theme.py(violet/cyan/amber/mono 四档 + custom 生成器),输出从
TS 模块改成 **CSS 变量单文件**(theme.css)——HTML 场景一律
<link rel="stylesheet" href="../theme.css">,画面代码禁止写死色值,
换主题 = 重写这一个文件。
颜色即语义(全主题一致):
accent = 重点/我们的 signal = 指标/另一方 warn = 警示/错误
ok = 正确 灰系 = 未激活(中性色不参与换肤)
三条硬约束(custom 生成时校验,不满足自动修):
① accent 饱和度 > 0.25(柔光必须带色,灰光不算光)
② accent 与 signal/warn/ok 的色相距离 ≥ 60°(暖色主题用亮度分离兜底)
③ accent 亮度(0.299r+0.587g+0.114b)∈ (55, 175)
用法:
python make_theme.py --project . --list # 主题联想表 + 预设
python make_theme.py --project . --preset amber # 用预设
python make_theme.py --project . --topic "医疗" # 按主题推导(推荐)
python make_theme.py --project . --hue 190 --name 深青 # 直接给色相
python make_theme.py --project . --seed "#1FA8C9" # 从色号反推
"""
from __future__ import annotations
import argparse
import json
import os
import re
# ---------------- 颜色数学(与 anything2explainer 同口径) ----------------
def clamp(v, lo, hi):
return lo if v < lo else hi if v > hi else v
def hsl_to_rgb(h, s, l):
h = h % 360 / 360.0
if s <= 0:
v = int(round(l * 255))
return (v, v, v)
q = l * (1 + s) if l < 0.5 else l + s - l * s
p = 2 * l - q
def ch(t):
t = t % 1.0
if t < 1 / 6:
return p + (q - p) * 6 * t
if t < 1 / 2:
return q
if t < 2 / 3:
return p + (q - p) * (2 / 3 - t) * 6
return p
return tuple(int(round(ch(h + o) * 255)) for o in (1 / 3, 0, -1 / 3))
def hex2rgb(h):
h = h.lstrip('#')
if len(h) == 3:
h = h[0] * 2 + h[1] * 2 + h[2] * 2
n = int(h, 16)
return ((n >> 16) & 255, (n >> 8) & 255, n & 255)
def rgb2hex(rgb):
return '#%02X%02X%02X' % tuple(clamp(int(round(c)), 0, 255) for c in rgb)
def lum255(rgb):
r, g, b = rgb
return 0.299 * r + 0.587 * g + 0.114 * b
def sat_of(rgb):
mx, mn = max(rgb), min(rgb)
return (mx - mn) / mx if mx else 0.0
def hue_of(rgb):
r, g, b = [c / 255 for c in rgb]
mx, mn = max(r, g, b), min(r, g, b)
d = mx - mn
if d == 0:
return 0.0
if mx == r:
h = ((g - b) / d) % 6
elif mx == g:
h = (b - r) / d + 2
else:
h = (r - g) / d + 4
return (h * 60) % 360
def hdist(a, b):
d = abs(a - b) % 360
return min(d, 360 - d)
def fit_lum(rgb, lo=55, hi=175):
l = lum255(rgb)
if lo <= l <= hi:
return rgb
target = lo if l < lo else hi
s = 1.0
for _ in range(40):
cand = hsl_to_rgb(hue_of(rgb), sat_of(rgb), target / 255.0 * s)
if abs(lum255(cand) - target) < 2:
return cand
s *= 0.97
return cand
# ---------------- 预设(色值移植自 anything2explainer theme.ts) ----------------
PRESETS = {
'violet': {
'label': '紫', 'fit': '科技 / AI / 互联网 / 数字产品',
'bg0': '#000000', 'bg1': '#0E0A18', 'accent': '#6630F8',
'accentLight': '#A175F1', 'accentDeep': '#5A3AD5', 'accentPale': '#E6DCFF',
'signal': '#F05F41', 'warn': '#EC081F', 'ok': '#8FF740',
'glitchA': '#D100D6', 'glitchB': '#58FFEE',
},
'cyan': {
'label': '青蓝', 'fit': '医疗 / 生物 / 健康 / 临床 / 科研',
'bg0': '#05090E', 'bg1': '#071118', 'accent': '#1FA8C9',
'accentLight': '#6FD8EE', 'accentDeep': '#14708A', 'accentPale': '#DCF4FA',
'signal': '#F0873F', 'warn': '#E5323C', 'ok': '#7FE05A',
'glitchA': '#E0409B', 'glitchB': '#B9F26A',
},
'amber': {
'label': '琥珀', 'fit': '能源 / 工业 / 金融 / 历史 / 财经',
'bg0': '#0A0705', 'bg1': '#14100A', 'accent': '#E8912B',
'accentLight': '#F5C46B', 'accentDeep': '#9A5A12', 'accentPale': '#FBEBD2',
'signal': '#3E8FD8', 'warn': '#A31621', 'ok': '#6FCF5A',
'glitchA': '#C2185B', 'glitchB': '#4FC3D9',
},
'mono': {
'label': '素白', 'fit': '严肃议题 / 新闻调查 / 纪实',
'bg0': '#000000', 'bg1': '#111111', 'accent': '#FFFFFF',
'accentLight': '#E8E8E8', 'accentDeep': '#A0A0A1', 'accentPale': '#2A2A2A',
'signal': '#E24B4A', 'warn': '#C81E1E', 'ok': '#6FCF5A',
'glitchA': '#D100D6', 'glitchB': '#58FFEE',
},
}
# 主题词 → 色相联想(anything2explainer 的 12 类对照表精简版)
TOPIC_HUES = [
(('医疗', '医学', '药', '健康', '临床', '生物', '中医', '医院'), 195),
(('能源', '工业', '制造', '机械', '电力', '钢铁'), 32),
(('金融', '财经', '货币', '银行', '股市', '经济', '基金', '历史'), 42),
(('航天', '太空', '宇宙', '天文', '物理'), 262),
(('科技', 'AI', '人工智能', '互联网', '软件', '编程', '大模型', '数据'), 258),
(('环保', '气候', '碳中和', '新能源', '生态'), 135),
(('教育', '学习', '考试', '知识', '课程'), 215),
(('心理', '情绪', '认知', '大脑', '神经'), 300),
(('法律', '合规', '监管', '政策'), 220),
(('农业', '食品', '餐饮', '烹饪'), 75),
(('体育', '运动', '健身'), 15),
(('艺术', '设计', '美学', '音乐'), 330),
]
def topic_to_hue(topic: str):
for keys, hue in TOPIC_HUES:
if any(k.lower() in topic.lower() for k in keys):
return hue, ''.join(keys[:2])
return None, None
def derive(hue: float, name: str = ''):
"""按色相推导全套槽位(含三条硬约束校验与修正)。
语义槽位规则(对齐四档预设的手调规律):
signal = 指标/另一方 → 与 accent 相对温度:暖色主题给冷蓝,冷色主题给暖橙
warn = 警示/错误 → 恒定红系(与 accent 撞红时压暗做亮度分离)
ok = 正确 → 恒定绿系(与 accent 撞绿时偏移到黄绿/青绿)
"""
notes = []
accent = fit_lum(hsl_to_rgb(hue, 0.72, 0.55))
if sat_of(accent) <= 0.25:
notes.append(f'⚠ 饱和度 {sat_of(accent):.2f} ≤ 0.25,柔光判据会退化(mono 类主题属正常)')
warm = hue < 100 or hue > 330 # 红/橙/黄域
sig_hue = 210 if warm else 25 # signal 与 accent 相对温度
signal = hsl_to_rgb(sig_hue, 0.62, 0.58)
if hdist(hue, sig_hue) < 60:
signal = hsl_to_rgb(sig_hue, 0.62, 0.82)
notes.append('signal 与 accent 色相距离不足 → 亮度分离')
warn_hue = 355
if hdist(hue, warn_hue) < 60:
warn = hsl_to_rgb(warn_hue, 0.78, 0.32) # 撞红 → 压暗分离
notes.append('warn 与 accent 撞红 → 深红亮度分离')
else:
warn = hsl_to_rgb(warn_hue, 0.80, 0.47)
ok_hue = 110 if hdist(hue, 110) >= 60 else (145 if hdist(hue, 145) >= 60 else 80)
ok = hsl_to_rgb(ok_hue, 0.60, 0.60)
a = hex2rgb(rgb2hex(accent))
pal = {
'label': name or f'custom({int(hue)}°)', 'fit': f'按主题推导 · 色相 {int(hue)}°',
'bg0': '#000000', 'bg1': rgb2hex(hsl_to_rgb(hue, 0.30, 0.055)),
'accent': rgb2hex(a), 'accentLight': rgb2hex(hsl_to_rgb(hue, 0.62, 0.72)),
'accentDeep': rgb2hex(hsl_to_rgb(hue, 0.68, 0.38)),
'accentPale': rgb2hex(hsl_to_rgb(hue, 0.35, 0.90)),
'signal': rgb2hex(signal), 'warn': rgb2hex(warn),
'ok': rgb2hex(ok), 'glitchA': rgb2hex(hsl_to_rgb((hue + 80) % 360, 0.85, 0.58)),
'glitchB': rgb2hex(hsl_to_rgb((hue + 180) % 360, 0.80, 0.62)),
}
return pal, notes
CSS_TMPL = """/* 自动生成(make_theme.py)—— 全片唯一色源,画面代码禁止写死色值。
* 颜色即语义:accent=重点/我们的 · signal=指标/另一方 · warn=警示 · ok=正确
* 灰系 = 未激活(不参与换肤)。换主题 = 重写本文件(make_theme.py --use)。
* 主题:{label} · 适合:{fit} */
:root {{
--bg-0: {bg0};
--bg-1: {bg1};
--bg-radial: radial-gradient(1000px 700px at 50% 44%, {accent_rgb_a14}, transparent 66%),
linear-gradient(170deg, {bg1} 0%, {bg0} 58%, {bg1} 100%);
--accent: {accent};
--accent-light: {accentLight};
--accent-deep: {accentDeep};
--accent-pale: {accentPale};
--accent-rgb: {accent_rgb};
--accent-soft: rgba({accent_rgb}, .10);
--accent-border: rgba({accent_rgb}, .55);
--accent-glow: rgba({accent_rgb}, .55);
--signal: {signal};
--signal-rgb: {signal_rgb};
--warn: {warn};
--ok: {ok};
--glitch-a: {glitchA};
--glitch-b: {glitchB};
--text: #FFFFFF;
--text-dim: rgba(255,255,255,.55);
--text-faint: rgba(255,255,255,.38);
--line: rgba(255,255,255,.15);
--line-soft: rgba(255,255,255,.08);
--card-bg: rgba(255,255,255,.045);
--grey: #A0A0A1;
/* ── 亮底「编辑页」变量(NYT 风数据帧 / 样板墙 / 亮底章节卡用)──────────
* 用法:亮底帧**必须自己铺 --paper 背景**(绝不能露出暗色幕底),
* 文字一律用 --ink 系、描边用 --line-ink 系,强调色用 --accent-on-paper
* (= accentDeep,保证亮底上的对比度)。暗底帧不要用这一组。 */
--paper: #F7F5EE;
--paper-2: #EAE5D9;
--ink: #17140F;
--ink-dim: rgba(23,20,15,.62);
--ink-faint: rgba(23,20,15,.38);
--line-ink: rgba(23,20,15,.18);
--line-ink-soft: rgba(23,20,15,.09);
--accent-on-paper: {accentDeep};
}}
"""
def write_css(pal, path):
acc = hex2rgb(pal['accent'])
sig = hex2rgb(pal['signal'])
css = CSS_TMPL.format(
label=pal['label'], fit=pal['fit'],
bg0=pal['bg0'], bg1=pal['bg1'],
accent=pal['accent'], accentLight=pal['accentLight'],
accentDeep=pal['accentDeep'], accentPale=pal['accentPale'],
accent_rgb=f"{acc[0]},{acc[1]},{acc[2]}",
accent_rgb_a14=f"rgba({acc[0]},{acc[1]},{acc[2]},.14)",
signal=pal['signal'], signal_rgb=f"{sig[0]},{sig[1]},{sig[2]}",
warn=pal['warn'], ok=pal['ok'],
glitchA=pal['glitchA'], glitchB=pal['glitchB'],
)
open(path, 'w', encoding='utf-8', newline='\n').write(css)
def main() -> int:
ap = argparse.ArgumentParser(description="html-explainer 主题配色")
ap.add_argument("--project", default=".")
ap.add_argument("--preset", default="", help="violet / cyan / amber / mono")
ap.add_argument("--topic", default="", help="按主题词推导(推荐)")
ap.add_argument("--hue", type=float, default=-1)
ap.add_argument("--seed", default="", help="从色号反推整套")
ap.add_argument("--name", default="")
ap.add_argument("--list", action="store_true")
ap.add_argument("--use", action="store_true", help="写 theme.css 并更新 project.json")
args = ap.parse_args()
if args.list or (not args.preset and not args.topic and args.hue < 0 and not args.seed):
print("预设:")
for k, v in PRESETS.items():
print(f" {k:<8} {v['label']} —— {v['fit']} accent {v['accent']}")
print("\n主题联想表(--topic 命中即用):")
for keys, hue in TOPIC_HUES:
print(f" {hue:>4}° {' / '.join(keys[:5])}")
print("\n示例:--topic \"医疗\" | --hue 190 --name 深青 | --seed \"#1FA8C9\"")
return 0
notes = []
if args.preset:
pal = PRESETS.get(args.preset)
if not pal:
print(f"✗ 没有预设 {args.preset}(可选:{', '.join(PRESETS)})")
return 1
elif args.seed:
hue = hue_of(hex2rgb(args.seed))
pal, notes = derive(hue, args.name or f"seed {args.seed}")
elif args.hue >= 0:
pal, notes = derive(args.hue, args.name)
else:
hue, hit = topic_to_hue(args.topic)
if hue is None:
print(f"✗ 主题「{args.topic}」联想不到色相;用 --hue/--seed 直接给,或选预设")
return 1
pal, notes = derive(hue, args.name or args.topic)
print(f"主题:{pal['label']}(accent {pal['accent']},亮度 {lum255(hex2rgb(pal['accent'])):.0f},"
f"饱和度 {sat_of(hex2rgb(pal['accent'])):.2f})")
print(f" signal {pal['signal']} · warn {pal['warn']} · ok {pal['ok']}")
for n in notes:
print(f" {n}")
if args.use:
proj = os.path.abspath(args.project)
css_path = os.path.join(proj, "theme.css")
write_css(pal, css_path)
pj_path = os.path.join(proj, "project.json")
if os.path.exists(pj_path):
pj = json.load(open(pj_path, encoding="utf-8"))
pj["theme"] = pal['label']
json.dump(pj, open(pj_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print(f"✓ 写 {css_path}(project.json.theme = {pal['label']})")
else:
print("(--use 才写盘)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
new_project.py —— 建项目脚手架(复制模板、生成配置与主题、写占位解说词)。
用法:
python new_project.py <工作目录> <slug> [--topic "医疗"] [--preset amber]
[--fps 30] [--width 1920] [--height 1080]
产出结构:
<dir>/
project.json 全片配置(order = 场景顺序,写完解说词后填)
narration.json 解说词([{id, text}],text 用 | 切字幕块)
theme.css 主题配色(--topic/--preset 决定,后续可换)
assets/gsap.min.js
frames/_template.html 场景模板(复制改内容,契约见文件头注释)
frames/_template.beats.js(占位节拍,subs.py 跑完会被真数据覆盖格式参考)
research/ script/ audio/ render/ out/
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
SKILL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("dir")
ap.add_argument("slug")
ap.add_argument("--topic", default="")
ap.add_argument("--preset", default="")
ap.add_argument("--fps", type=int, default=30)
ap.add_argument("--width", type=int, default=1920)
ap.add_argument("--height", type=int, default=1080)
ap.add_argument("--lang", default="zh")
args = ap.parse_args()
proj = os.path.abspath(args.dir)
if os.path.exists(proj) and os.listdir(proj):
print(f"✗ 目录非空:{proj}", file=sys.stderr)
return 1
for d in ("frames", "audio", "render", "out", "research", "script", "assets"):
os.makedirs(os.path.join(proj, d), exist_ok=True)
# project.json
pj = {
"slug": args.slug,
"lang": args.lang,
"fps": args.fps,
"width": args.width,
"height": args.height,
"gap": 0.35,
"order": [], # 写完 narration.json 后把 id 按顺序填进来
"theme": "violet",
"progress": True,
"voice": "zh-CN-YunxiNeural" if args.lang == "zh" else "en-US-GuyNeural",
"rate": "+8%",
}
json.dump(pj, open(os.path.join(proj, "project.json"), "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
# narration.json 占位(示例含 | 切块写法)
narration = [
{"id": "intro", "text": "开场钩子,一到两句。|用竖线切字幕块,每块不超过16个字。"},
{"id": "point1", "text": "第一个论点。|画面节拍用B函数对齐这句。"},
]
json.dump(narration, open(os.path.join(proj, "narration.json"), "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
# 模板与资源
shutil.copy(os.path.join(SKILL_ROOT, "assets", "gsap.min.js"),
os.path.join(proj, "assets", "gsap.min.js"))
tpl = open(os.path.join(SKILL_ROOT, "assets", "frame-template.html"), encoding="utf-8").read()
open(os.path.join(proj, "frames", "_template.html"), "w", encoding="utf-8", newline="\n").write(
tpl.replace("TEMPLATE_ID.beats.js", "_template.beats.js"))
# 封面模板两份:直接建进 frames/,用户改内容即可(默认写在 cover_169/cover_34 下)
# —— 起始尺寸已按目标画幅设好,只改文案与构型
cover_src = open(os.path.join(SKILL_ROOT, "assets", "cover-template.html"), encoding="utf-8").read()
open(os.path.join(proj, "frames", "cover_169.html"), "w", encoding="utf-8", newline="\n").write(
cover_src.replace("1920px; height: 1080px", "1920px; height: 1080px"))
open(os.path.join(proj, "frames", "cover_34.html"), "w", encoding="utf-8", newline="\n").write(
cover_src.replace("1920px; height: 1080px", "1440px; height: 1080px"))
# 占位 beats(预览模板用;正式场景的 beats 由 subs.py 生成)
stub = """/* 占位节拍(new_project.py 生成,仅供 _template.html 预览)。
* 正式节拍由 subs.py 生成:解说词定稿 + tts_build 跑完后自动刷新。 */
window.__BEATS__ = [
{ text: "开场钩子,一到两句。", start: 0.30, end: 2.40 },
{ text: "用竖线切字幕块,每块不超过16个字。", start: 2.40, end: 4.80 },
{ text: "左边卡片", start: 0.40, end: 2.00 },
{ text: "右边卡片", start: 1.80, end: 3.60 },
{ text: "结论句", start: 3.60, end: 5.60 },
{ text: "证据", start: 6.00, end: 8.00 }
];
window.__SEG__ = { id: "_template", duration: 9.0, speech_end: 8.2, tail: 0.8 };
(function () {
var B = window.__BEATS__;
function find(text) {
var t = String(text || '').replace(/[\\s,。、!?;:,.!?;:|]/g, '');
for (var i = 0; i < B.length; i++) {
var bt = B[i].text.replace(/[\\s,。、!?;:,.!?;:|]/g, '');
if (bt === t || bt.indexOf(t) === 0 || t.indexOf(bt) === 0) return B[i];
}
throw new Error('B() 找不到节拍: ' + text);
}
window.B = function (text) { return find(text).start; };
window.Be = function (text) { return find(text).end; };
})();
"""
open(os.path.join(proj, "frames", "_template.beats.js"), "w", encoding="utf-8", newline="\n").write(stub)
# 主题
import subprocess
py = sys.executable
theme_args = [py, os.path.join(SKILL_ROOT, "scripts", "make_theme.py"), "--project", proj, "--use"]
if args.topic:
theme_args += ["--topic", args.topic]
elif args.preset:
theme_args += ["--preset", args.preset]
else:
theme_args += ["--preset", "violet"]
r = subprocess.run(theme_args, capture_output=True, text=True, encoding="utf-8")
print(r.stdout.strip() or r.stderr.strip())
readme = f"""# {args.slug}(html-explainer 项目)
## ★ 先挑画面风格(别从零设计)
`<skill>/references/style-catalog.md` —— **23 个模板风格 / 8 个类别**
(大胆信号卡、奢华极简、NYT 数据图表、瑞士网格、故障艺术、胶片漏光、流体 Hero、
Logo 收尾、东方柔和有机、VFX 文字光标…),每种含画布/字体/时间轴/配色规范。
两类模板改编成本不同:
- **★ rich(12 个)**:单文件 + 纯 CSS @keyframes → 换系统字体栈 + 让出字幕带 + 填内容,即可用
- **gsap(11 个)**:多 composition + CDN GSAP → **不要搬代码**,只取视觉 DNA 用 CSS 重写
改编细则见 `<skill>/references/template-guide.md`。
建议全片轮换 2–4 种风格,避免每场景一个样。
## 流程速查(详情见技能 SKILL.md)
1. research/调研.md —— 按 reference/workflow-guide.md 派研究员,事实带 URL
2. narration.json —— 解说词定稿(`|` 切字幕块,中文每块 ≤16 字)
3. project.json 的 order 填场景顺序(= narration.json 的 id 顺序)
4. 分镜 script/storyboard.md —— **每场景先指定用哪个模板风格**
5. 配音与时间轴:
python <skill>/scripts/tts_build.py --project .
python <skill>/scripts/timeline_build.py --project .
python <skill>/scripts/subs.py --project .
6. 每个场景一个 frames/<id>.html(改编挑中的模板,或从 _template.html 复制;节拍用 B())
7. 渲染:node <skill>/scripts/render_video.mjs . [--preview 30]
8. QC:python <skill>/scripts/qc_check.py --project .
## ★ 封面双方案(成片后必做)
复制 `assets/cover-template.html` 做**两份独立排版**的封面:
frames/cover_169.html 1920x1080 -> 抖音主封面(信息流/播放页)
frames/cover_34.html 1440x1080 -> 兼容主页 3:4 栅格(防切字)
**不是裁切关系**:16:9 裁 3:4 会丢掉 57.8% 的画面宽度,大字钩子必被切。
两份共享配色/主视觉/钩子文案,但各自重排(悖论视觉从右侧并置改成上方竖排、
钩子从两行改成三行)。详规见 `<skill>/references/cover-guide.md`。
渲染:node <skill>/scripts/cover_build.mjs .
主题:{args.topic or args.preset or 'violet'}(make_theme.py --topic ... --use 可换)
"""
open(os.path.join(proj, "README.md"), "w", encoding="utf-8", newline="\n").write(readme)
print(f"✓ 项目建好:{proj}")
print(" 下一步:调研 → 解说词定稿 → 分镜(挑模板风格)→ 填 project.json.order")
print(" 风格库:references/style-catalog.md(23 个模板 / 8 类别)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+179
View File
@@ -0,0 +1,179 @@
#!/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 的目录 → ~/.workbuddy/skills/html-explainer
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;
}
if (process.env.USERPROFILE) cands.push(path.join(process.env.USERPROFILE, '.workbuddy', 'skills', 'html-explainer'));
if (process.env.HOME) cands.push(path.join(process.env.HOME, '.workbuddy', 'skills', '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)));
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
qc_check.py —— 成片体检 + 抽帧速览图。
检查项(全部来自两个血统项目的实战教训):
① 流:必须有 h264 video + aac audio 两条流
② 时长:成片 vs 音轨 vs layout.json 期望帧数 —— 差 >0.5s 判 FAIL
(差 ~0.9s×N 段 = 帧时长用了词边界而非容器时长的典型症状)
③ 音量:成片 mean_volume 与源音轨差 >3dB 判 FAIL(静音轨/占位轨)
④ 抽帧:按字幕切换点 + 每场景中段抽帧,纯背景帧(体积过小)判 FAIL
(1920×1080 纯背景 PNG ≈ 90KB 上下;有内容通常 220KB+)
⑤ 速览图:6 列 contact sheet → out/qc_sheet.jpg(肉眼过一遍字幕带/构图)
用法:
python qc_check.py --project . [--samples 12]
产出:
out/qc_report.md + out/qc_frames/*.jpg + out/qc_sheet.jpg
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
def ffmpeg_exe() -> str:
from shutil import which
p = which("ffmpeg")
if p:
return p
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def probe(ff, path):
"""返回 (duration_sec, streams[], 容器行列表)。"""
r = subprocess.run([ff, "-hide_banner", "-i", path],
capture_output=True, text=True, errors="replace")
lines = r.stderr.splitlines()
dur = 0.0
for line in lines:
if "Duration:" in line:
t = line.split("Duration:")[1].split(",")[0].strip()
h, m, s = t.split(":")
dur = int(h) * 3600 + int(m) * 60 + float(s)
streams = [l.strip() for l in lines if "Stream #" in l]
return dur, streams, lines
def mean_volume(ff, path):
r = subprocess.run([ff, "-hide_banner", "-i", path, "-af", "volumedetect",
"-f", "null", "-"], capture_output=True, text=True, errors="replace")
for line in r.stderr.splitlines():
if "mean_volume:" in line:
return float(line.split("mean_volume:")[1].replace("dB", "").strip())
return None
def extract_frame(ff, video, sec, out_png):
r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-y",
"-i", video, "-ss", f"{sec:.3f}", "-frames:v", "1", out_png],
capture_output=True)
return r.returncode == 0 and os.path.exists(out_png)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--project", default=".")
ap.add_argument("--samples", type=int, default=12)
args = ap.parse_args()
proj = os.path.abspath(args.project)
pj = json.load(open(os.path.join(proj, "project.json"), encoding="utf-8"))
slug = pj.get("slug") or "video"
ff = ffmpeg_exe()
video = os.path.join(proj, "out", f"{slug}.mp4")
audio = os.path.join(proj, "audio", "narration-full.mp3")
layout = os.path.join(proj, "layout.json")
subs = os.path.join(proj, "subs.json")
report, fails, warns = [], [], []
def sec(title):
report.append(f"\n## {title}\n")
if not os.path.exists(video):
print(f"✗ 找不到成片 {video}(先渲染)", file=sys.stderr)
return 1
# ① 流
sec("流")
vdur, streams, _ = probe(ff, video)
has_v = any("Video:" in s and "h264" in s for s in streams)
has_a = any("Audio:" in s and "aac" in s for s in streams)
for s in streams:
report.append(f"- `{s}`")
(report if has_v else fails).append("✓ 视频流 h264" if has_v else "video 流缺失或非 h264")
if not has_a:
warns.append("无 aac 音轨(还没配音?)")
report.append(f"- 成片时长 {vdur:.2f}s")
# ② 时长对齐
sec("时长对齐(两级时钟口径)")
expect = None
if os.path.exists(layout):
lay = json.load(open(layout, encoding="utf-8"))
expect = lay.get("_total", {})
report.append(f"- layout 期望:视频 {expect.get('video_duration_sec')}s / "
f"{expect.get('total_frames')} 帧 · 音轨 {expect.get('audio_duration_sec')}s")
d = abs(vdur - float(expect.get("video_duration_sec", vdur)))
if d > 0.5:
fails.append(f"成片与期望时长差 {d:.2f}s(>0.5s)—— 查帧时长口径是否用了容器时长")
else:
report.append(f"- ✓ 与期望差 {d:.2f}s")
if os.path.exists(audio):
adur, _, _ = probe(ff, audio)
d = abs(vdur - adur)
report.append(f"- 音轨 {adur:.2f}s,成片差 {d:.2f}s")
if d > 0.5:
fails.append(f"音画时长差 {d:.2f}s —— 典型原因:帧时长用了词边界时长(每段少 ~0.86s)")
# ③ 音量
sec("音量")
if os.path.exists(audio):
mv_v, mv_a = mean_volume(ff, video), mean_volume(ff, audio)
report.append(f"- 成片 mean_volume {mv_v} dB · 源 {mv_a} dB")
if mv_v is not None and mv_a is not None and abs(mv_v - mv_a) > 3:
fails.append(f"音量差 {abs(mv_v - mv_a):.1f} dB(静音轨或占位轨?)")
# ④ 抽帧(字幕切换点 + 场景中段)
sec("抽帧体检")
qc_dir = os.path.join(proj, "out", "qc_frames")
os.makedirs(qc_dir, exist_ok=True)
times = []
if os.path.exists(subs):
sd = json.load(open(subs, encoding="utf-8"))
pts = sorted({round(b['global_start_sec'] + 0.25, 2)
for s in sd['segments'] for b in s['blocks']})
# ★ 全片均匀取 N 个(不是前 N 个):块数多于抽样数时按步长挑,
# 否则 QC 只覆盖前 ~1/4 片,后半段场景(本例 world→outro)抽不到。
# 见 lessons.md #29。
if len(pts) > args.samples:
step = len(pts) / args.samples
pts = [pts[int(i * step)] for i in range(args.samples)]
times = pts
if not times:
times = [round(vdur * (i + 1) / (args.samples + 1), 2) for i in range(args.samples)]
sizes = []
for i, t in enumerate(times):
if t >= vdur - 0.1:
continue
p = os.path.join(qc_dir, f"q{i:02d}_{int(t * 1000):07d}ms.jpg")
if extract_frame(ff, video, t, p):
sz = os.path.getsize(p)
sizes.append((t, sz, p))
# 阈值必须随主题底色自适应:暗底主题压缩后体积天然很小,
# 用固定 100KB 会把「黑底 + 稀疏文字」的合规画面全判成空画面(假阳性)。
# 做法:取本片所有抽帧体积的中位数作参考,低于中位数 35% 的帧才是可疑帧。
# 若全部帧都极暗(中位数本身就小),再叠加一个绝对下限兜底。
if sizes:
med = sorted(s for _, s, _ in sizes)[len(sizes) // 2]
floor = max(12_000, int(med * 0.35))
report.append(f"- 抽了 {len(sizes)} 帧 → out/qc_frames/"
f"(本片抽帧体积中位数 {med // 1024}KB,可疑线 {floor // 1024}KB)")
for t, sz, _ in sizes:
flag = " ← 可疑" if sz < floor else ""
report.append(f" - {t:>6.2f}s {sz // 1024:>4}KB{flag}")
if sz < floor:
warns.append(f"{t}s 抽帧 {sz // 1024}KB,低于本片中位数 {med // 1024}KB 的 35%"
f" —— 疑似空画面(主角没渲出来?核对 out/qc_frames/ 该帧)")
else:
report.append("- 未能抽到任何帧")
# ⑤ contact sheet
sec("速览图")
sheet = os.path.join(proj, "out", "qc_sheet.jpg")
try:
from PIL import Image
imgs = [(t, p) for t, _, p in sizes]
if imgs:
cols = 6
rows = (len(imgs) + cols - 1) // cols
tw, th = 480, 270
sheet_im = Image.new("RGB", (cols * tw, rows * th), (16, 16, 16))
for i, (t, p) in enumerate(imgs):
im = Image.open(p).resize((tw, th))
sheet_im.paste(im, ((i % cols) * tw, (i // cols) * th))
sheet_im.save(sheet, quality=85)
report.append(f"- out/qc_sheet.jpg({rows}×{cols},时间点见 qc_frames 文件名)")
except Exception as e:
warns.append(f"速览图失败:{e}")
# 汇总
head = [f"# QC 报告 · {slug}", f"成片:{video}", f"时长 {vdur:.2f}s"]
rep = "\n".join(head + report)
if fails:
rep += "\n\n## FAIL\n" + "\n".join(f"- ✗ {f}" for f in fails)
if warns:
rep += "\n\n## WARN\n" + "\n".join(f"- ⚠ {w}" for w in warns)
if not fails and not warns:
rep += "\n\n## 结论:全部通过 ✓(速览图仍需肉眼过一遍字幕带与构图)"
open(os.path.join(proj, "out", "qc_report.md"), "w", encoding="utf-8", newline="\n").write(rep)
print(rep)
return 1 if fails else 0
if __name__ == "__main__":
raise SystemExit(main())
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env node
/**
* render_video.mjs —— html-explainer 的确定性 HTML→MP4 渲染器。
*
* 【血统】
* - 画面契约(HTML + GSAP 单文件场景、1920×1080、系统字体)来自 html-video
* 的帧形态;但它用「实时录制 + 裁引导期」,天然带一整类坑(__hvUnfreeze
* 起播、Google Fonts 6s 超时、字体 swap 中途换脸、leadInMs 不稳)。
* - 本渲染器改用 anything2explainer / Remotion 同款的**确定性逐帧渲染**:
* 暂停时间轴 → seek 到 t → 截图。没有任何实时录制环节,上述坑整类消除。
*
* 【每帧渲染管线】
* 1. tl.pause(t, false) GSAP 时间轴同步渲染到 t(inline style 立即生效;false = 放行回调)
* 2. getAnimations() seek CSS @keyframes 动画也确定性对齐(currentTime = t*1000)
* 3. __mgSubUpdate(t) 渲染器注入的字幕层按本场景块表硬切
* 4. __mgProgressUpdate() 渲染器注入的全局进度条按 (globalT/total) 填充
* 5. 双 rAF + screenshot 保证合成器已把本帧样式画出来再截图
*
* 【时长口径(继承并修正两级时钟)】
* - 场景时长 = 该段 MP3 的**容器时长**(tts_build.py 已裁首尾静音并回填),
* 段间 gap 由 timeline_build.py 显式插入 —— 音画天然对齐。
* - 总帧数 = round(total × fps),按场景累计舍入,不逐场景独立舍入。
*
* 用法(项目目录 = 含 project.json 的目录):
* node render_video.mjs <projectDir> [--out out/<slug>.mp4]
* [--preview 30] [--fps 30] [--concurrency 3] [--jpeg]
* [--scale 1] [--keep-frames]
*
* 环境解析顺序:
* 浏览器 env BROWSER_PATH → Chrome → Edge → playwright 自带 chromium
* ffmpeg env FFMPEG_PATH → PATH 上的 ffmpeg → python -c imageio_ffmpeg(venv)
* python env PY → PATH python(用来问 imageio-ffmpeg 要静态 ffmpeg)
*/
import { createRequire } from 'node:module';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { execFileSync } from 'node:child_process';
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 = (msg) => process.stderr.write(`[render] ${msg}\n`);
const die = (msg) => { process.stderr.write(`[render] ✗ ${msg}\n`); process.exit(1); };
function parseArgs(argv) {
const a = { _: [] };
for (let i = 0; i < argv.length; i++) {
const t = argv[i];
if (t === '--out') a.out = argv[++i];
else if (t === '--preview') a.preview = parseFloat(argv[++i]);
else if (t === '--fps') a.fps = parseFloat(argv[++i]);
else if (t === '--concurrency') a.concurrency = parseInt(argv[++i], 10);
else if (t === '--jpeg') a.jpeg = true;
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;
}
// ---------- ffmpeg 解析(PATH → imageio-ffmpeg 静态二进制) ----------
function resolveFfmpeg() {
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) return process.env.FFMPEG_PATH;
const isWin = process.platform === 'win32';
const name = isWin ? 'ffmpeg.exe' : 'ffmpeg';
for (const d of (process.env.PATH || '').split(path.delimiter)) {
if (!d) continue;
const p = path.join(d, name);
try { if (fs.existsSync(p)) return p; } catch { /* ignore */ }
}
// 回退:问 python 的 imageio_ffmpeg 要(WorkBuddy 托管 venv 里有静态 ffmpeg)
const pyCandidates = [process.env.PY, 'python3', 'python'].filter(Boolean);
for (const py of pyCandidates) {
try {
const out = execFileSync(py, ['-c', 'import imageio_ffmpeg;print(imageio_ffmpeg.get_ffmpeg_exe())'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
if (out && fs.existsSync(out)) return out;
} catch { /* next */ }
}
return null;
}
// ---------- 浏览器解析(Chrome → Edge → playwright chromium) ----------
function resolveBrowserExec() {
if (process.env.BROWSER_PATH && fs.existsSync(process.env.BROWSER_PATH)) return process.env.BROWSER_PATH;
const candidates = [];
if (process.platform === 'win32') {
for (const p of [
'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',
]) if (p) candidates.push(p);
} else if (process.platform === 'darwin') {
candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
'/Applications/Chromium.app/Contents/MacOS/Chromium');
} else {
candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
'/usr/bin/microsoft-edge');
}
for (const p of candidates) { try { if (fs.existsSync(p)) return p; } catch { /* ignore */ } }
return null; // 走 playwright 自带 chromium(需 npx playwright install chromium)
}
// ---------- 注入页面的字幕层 / 进度条(渲染器全权管理,帧作者零负担) ----------
const OVERLAY_CSS = `
#mg-subs { position: fixed; inset: 0; pointer-events: none; z-index: 900; }
#mg-subs .mg-sub {
position: absolute; left: 50%; bottom: 96px; transform: translateX(-50%);
white-space: nowrap; text-align: center;
font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', 'Source Han Sans SC', sans-serif;
font-weight: 700; font-size: 44px; line-height: 1.2; color: #FFFFFF;
text-shadow:
4px 0 0 #000, -4px 0 0 #000, 0 4px 0 #000, 0 -4px 0 #000,
2.4px 2.4px 0 #000, -2.4px 2.4px 0 #000, 2.4px -2.4px 0 #000, -2.4px -2.4px 0 #000,
1.2px 1.2px 0 #000, -1.2px 1.2px 0 #000, 1.2px -1.2px 0 #000, -1.2px -1.2px 0 #000,
3.2px 1.06px 0 #000, -3.2px 1.06px 0 #000, 3.2px -1.06px 0 #000, -3.2px -1.06px 0 #000,
1.06px 3.2px 0 #000, -1.06px 3.2px 0 #000, 1.06px -3.2px 0 #000, -1.06px -3.2px 0 #000,
2.83px 2.83px 0 #000, -2.83px 2.83px 0 #000, 2.83px -2.83px 0 #000, -2.83px -2.83px 0 #000;
}
#mg-progress { position: fixed; left: 0; right: 0; bottom: 0; height: 12px;
z-index: 901; pointer-events: none; background: rgba(255,255,255,.10); }
#mg-progress .mg-bar { height: 100%; width: 0%;
background: var(--accent, #E4AE29); box-shadow: 0 0 12px var(--accent-glow, rgba(228,174,41,.55)); }
#mg-progress .mg-tick { position: absolute; top: 0; width: 2px; height: 100%;
background: rgba(255,255,255,.38); }
`;
function overlaySetupJs(blocks, fps, withProgress, chapterTicks) {
const blocksJs = JSON.stringify(blocks || []);
const ticksJs = JSON.stringify(chapterTicks || []);
return `(function () {
var css = document.createElement('style'); css.textContent = ${JSON.stringify(OVERLAY_CSS)};
document.head.appendChild(css);
var subEl = document.createElement('div'); subEl.id = 'mg-subs';
var subNode = document.createElement('div'); subNode.className = 'mg-sub';
subNode.style.display = 'none'; subEl.appendChild(subNode);
document.body.appendChild(subEl);
var proEl = null, bar = null;
${withProgress ? `
proEl = document.createElement('div'); proEl.id = 'mg-progress';
bar = document.createElement('div'); bar.className = 'mg-bar'; proEl.appendChild(bar);
${ticksJs}.forEach(function (p) {
var t = document.createElement('div'); t.className = 'mg-tick'; t.style.left = (p * 100) + '%';
proEl.appendChild(t);
});
document.body.appendChild(proEl);` : ''}
var BLOCKS = ${blocksJs}, FPS = ${fps}, cur = -1;
window.__mgSubUpdate = function (t) {
var f = t * FPS, idx = -1;
for (var i = 0; i < BLOCKS.length; i++) {
if (f >= BLOCKS[i].from - 1 && f <= BLOCKS[i].to) { idx = i; break; }
}
if (idx !== cur) {
if (cur >= 0) subNode.style.display = 'none';
if (idx >= 0) {
subNode.textContent = BLOCKS[idx].text;
subNode.style.fontSize = (BLOCKS[idx].size || 44) + 'px';
subNode.style.display = 'block';
}
cur = idx;
}
};
window.__mgProgressUpdate = function (p) {
if (bar) bar.style.width = (Math.max(0, Math.min(1, p)) * 100).toFixed(3) + '%';
};
})();`;
}
// 每帧 seek:GSAP + CSS @keyframes + 字幕 + 进度条 + 双 rAF。
// ★ 必须是**真正的函数**,不能写成字符串形式的箭头函数定义 ——
// playwright 对字符串只做表达式求值:`async (o) => {...}` 会被当成函数字面量
// **返回而不执行**(返回不可序列化 → undefined),导致每帧都是静止首帧。
// 这个坑极隐蔽:不报错、截图仍在写盘,成片却是"只有背景色 + CSS 动画"。
const frameSeek = async (o) => {
const { t, globalT, total } = o;
let tl = window.__tl || null;
if (!tl && window.__timelines) {
for (const k in window.__timelines) { if (window.__timelines[k]) { tl = window.__timelines[k]; break; } }
}
if (!tl) throw new Error('no GSAP timeline found (window.__tl)');
// ★ pause(atTime, suppressEvents):第二参 GSAP 默认 true,会**掐掉本次 seek 触发的回调**
// (onUpdate / onStart / onComplete)。此时用 onUpdate 写 textContent 做的数字滚动
// 在渲染器下永远停在初值——不报错、截图照写、成片数字不动。必须显式传 false。
// 实测见 lessons.md #27。
tl.pause(t, false); // GSAP:seek 即同步渲染 + 放行回调
if (document.getAnimations) { // CSS @keyframes:也确定性对齐
document.getAnimations().forEach((a) => {
try { a.pause(); a.currentTime = Math.round(t * 1000); } catch (e) { /* ignore */ }
});
}
if (window.__mgSubUpdate) window.__mgSubUpdate(t);
if (window.__mgProgressUpdate) window.__mgProgressUpdate(total ? (globalT / total) : 0);
await new Promise((r) => { requestAnimationFrame(() => { requestAnimationFrame(r); }); });
return tl.time();
};
// ---------- 主流程 ----------
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'));
const fps = args.fps || pj.fps || 30;
const W = pj.width || 1920, H = pj.height || 1080;
const order = pj.order || [];
if (!order.length) die('project.json 里没有 order(场景顺序)');
const layoutPath = path.join(projectDir, 'layout.json');
if (!fs.existsSync(layoutPath)) die(`没有 layout.json(先跑 timeline_build.py):${layoutPath}`);
const layout = JSON.parse(fs.readFileSync(layoutPath, 'utf8'));
let subs = { fps, segments: [] };
const subsPath = path.join(projectDir, 'subs.json');
if (fs.existsSync(subsPath)) subs = JSON.parse(fs.readFileSync(subsPath, 'utf8'));
// 缺帧 HTML 的场景直接报错(别静默跳过)
for (const id of order) {
if (!fs.existsSync(path.join(projectDir, 'frames', `${id}.html`)))
die(`缺场景帧:frames/${id}.html`);
}
// 渲染范围:正常全片;--preview N 只渲前 N 秒
const totalSec = order.reduce((s, id) => s + (layout[id]?.duration_sec || 0), 0)
+ (pj.gap || 0) * (order.length - 1);
const preview = args.preview && args.preview > 0 ? Math.min(args.preview, totalSec) : null;
const renderSec = preview != null ? preview : totalSec;
const totalFrames = Math.max(1, Math.round(renderSec * fps));
// 各场景的全局起止帧(累计舍入,保证全局对齐)
const scenes = [];
let cursorSec = 0;
for (const id of order) {
const dur = layout[id]?.duration_sec || 0;
const gf = Math.round(cursorSec * fps);
const gfEnd = Math.min(totalFrames, Math.round((cursorSec + dur + (pj.gap || 0)) * fps));
const n = Math.max(0, gfEnd - gf);
if (n > 0) scenes.push({ id, gf, n, globalStart: cursorSec, dur });
cursorSec += dur + (pj.gap || 0);
}
const framesDir = path.join(projectDir, 'render', 'frames');
fs.mkdirSync(framesDir, { recursive: true });
const outDir = path.join(projectDir, 'out');
fs.mkdirSync(outDir, { recursive: true });
const slug = pj.slug || 'video';
const outPath = args.out || path.join(outDir, preview != null ? 'preview.mp4' : `${slug}.mp4`);
const ffmpeg = resolveFfmpeg();
if (!ffmpeg) die('找不到 ffmpeg:装到 PATH,或设 FFMPEG_PATH,或 python 装好 imageio-ffmpeg');
// ---------- playwright ----------
let chromium;
try { ({ chromium } = require('playwright-core')); }
catch { die(`playwright-core 缺失:在技能目录 node/ 下 npm install playwright-core(见 setup_env.sh)`); }
const browserExec = args.browser || resolveBrowserExec();
const launchOpts = { viewport: { width: W, height: H }, deviceScaleFactor: args.scale || 1 };
if (browserExec) { launchOpts.executablePath = browserExec; log(`浏览器:${browserExec}`); }
else log('浏览器:用 playwright 自带 chromium(首次需 npx playwright install chromium)');
log(`项目:${projectDir}`);
log(`场景 ${scenes.length} 个 · 总时长 ${renderSec.toFixed(2)}s · ${totalFrames} 帧 @${fps}fps`);
log(`画布 ${W}x${H} · ${args.jpeg ? 'JPEG 草稿模式' : 'PNG 精细模式'} · 并发 ${args.concurrency || 3}`);
const browser = await chromium.launch(launchOpts);
// 章节刻度(可选:project.json.chapters = [{title, startSegment}])
const chapterTicks = (pj.chapters || [])
.map(c => { const i = order.indexOf(c.startSegment); return i > 0 ? (Math.round((layout[order[0]] ? 0 : 0) + order.slice(0, i).reduce((s, x) => s + (layout[x]?.duration_sec || 0) + (pj.gap || 0), 0)) / renderSec) : null; })
.filter(x => x != null);
// ---------- 逐场景渲染(可并发多个 page) ----------
const errors = [];
let doneFrames = 0;
const t0 = Date.now();
async function renderScene(sc) {
// ★ viewport 必须逐 page 指定(launch 的 viewport 不继承到 newPage)
const page = await browser.newPage({ viewport: { width: W, height: H } });
const htmlPath = path.join(projectDir, 'frames', `${sc.id}.html`);
try {
// __MG_RENDER__ 必须在任何页面脚本之前置位(帧据此跳过自动起播)
await page.addInitScript(`window.__MG_RENDER__ = true;`);
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'domcontentloaded' });
// 字体等待:系统字体即时就绪;@font-face 有 5s 硬顶(防 CDN 卡死整片)
await page.evaluate(`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 hasTl = await page.evaluate(`Boolean(window.__tl || (window.__timelines && Object.keys(window.__timelines).length))`);
if (!hasTl) throw new Error('帧里没有 GSAP 时间轴(window.__tl)—— 见 frame-contract.md');
// 注入字幕层 + 进度条
const seg = (subs.segments || []).find(s => s.id === sc.id);
const blocks = (seg?.blocks || []).map(b => ({ from: b.from, to: b.to, text: b.text, size: b.size }));
await page.evaluate(overlaySetupJs(blocks, fps, pj.progress !== false, chapterTicks));
// 逐帧 seek + 截图
for (let i = 0; i < sc.n; i++) {
const t = i / fps;
const globalT = sc.globalStart + t;
await page.evaluate(frameSeek, { t, globalT, total: renderSec });
const framePath = path.join(framesDir, `f_${String(sc.gf + i + 1).padStart(6, '0')}.${args.jpeg ? 'jpg' : 'png'}`);
await page.screenshot({ path: framePath, type: args.jpeg ? 'jpeg' : 'png', quality: args.jpeg ? 82 : undefined });
doneFrames++;
if (doneFrames % 300 === 0)
log(`进度 ${doneFrames}/${totalFrames} 帧 · ${(doneFrames / ((Date.now() - t0) / 1000)).toFixed(1)} 帧/秒`);
}
log(`✓ 场景 ${sc.id}:${sc.n} 帧`);
} catch (e) {
errors.push(`场景 ${sc.id}: ${e.message}`);
} finally {
await page.close();
}
}
// 场景级并发(默认 3;帧级不需要——同 page 顺序 seek)
const conc = Math.max(1, Math.min(args.concurrency || 3, scenes.length));
const queue = [...scenes];
const workers = Array.from({ length: conc }, () => (async () => {
while (queue.length && !errors.length) { const sc = queue.shift(); await renderScene(sc); }
})());
await Promise.all(workers);
await browser.close();
if (errors.length) die(`渲染失败:\n ${errors.join('\n ')}`);
log(`截图完成:${doneFrames} 帧,耗时 ${((Date.now() - t0) / 1000).toFixed(1)}s`);
// ---------- ffmpeg 合成 ----------
const audioPath = path.join(projectDir, 'audio', 'narration-full.mp3');
const hasAudio = fs.existsSync(audioPath);
const ext = args.jpeg ? 'jpg' : 'png';
const cmd = [
'-hide_banner', '-loglevel', 'error', '-y',
'-framerate', String(fps), '-i', path.join(framesDir, `f_%06d.${ext}`),
];
if (hasAudio) cmd.push('-i', audioPath);
cmd.push(
'-c:v', 'libx264', '-preset', args.jpeg ? 'veryfast' : 'medium',
'-crf', args.jpeg ? '23' : '18', '-pix_fmt', 'yuv420p',
'-movflags', '+faststart',
);
if (hasAudio) cmd.push('-c:a', 'aac', '-b:a', '160k', '-shortest');
cmd.push(outPath);
log(hasAudio ? `合成:${totalFrames} 帧 + ${audioPath}` : '合成:无音轨(配音还没生成)');
execFileSync(ffmpeg, cmd, { stdio: 'inherit' });
log(`✓ 成片:${outPath}`);
log(` 帧目录:${framesDir}(--keep-frames 未指定且为完整渲时,保留供 QC)`);
if (!args.keepFrames && preview != null) {
// 预览模式顺手清掉草稿帧,避免占盘;完整渲保留(QC/封面要用)
for (const f of fs.readdirSync(framesDir)) fs.unlinkSync(path.join(framesDir, f));
}
}
main().catch(e => die(e.stack || String(e)));
+413
View File
@@ -0,0 +1,413 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
subs.py —— 字幕三出口 + 节拍器 beats.js。
【血统】字幕方案移植自 anything2explainer(tts_build.py + make_srt.py),
经 html-video-workbuddy-driver 实战修订。核心不变式:
① 块用 | 切(中文 ≤16 字 / 英文 ≤48 字符),整句一次送 TTS(词边界最准)
② 每块首字对齐到 WordBoundary 给的秒 → 帧号(绝不按字数插值:
中文同字数时长能差 3 倍,插值一定飘)
③ 两级时钟:段长用容器时长(音画同步),末块收尾用语音真实结束
(speech_end_sec,裁后坐标系)—— 混用会「字幕过了语音还没过」
④ 停留重分配:按真实语速比例 + 每块 ≥0.6s(water-filling)
⑤ 屏幕文本去句读标点(标点只驱动 TTS 停顿,不上画面)
【本技能新增】beats.js 节拍器 —— 把每块的本地秒写进 frames/<id>.beats.js,
场景 HTML 里用 B('文本') 拿到该词的起播秒,画面节拍直接对齐吐字节拍。
解说词改动后重跑 tts_build → 本脚本,beats.js 全部自动刷新,
帧代码一行不用改(这比 anything2explainer 的「帧号硬编码」省一轮全片重对位)。
用法:
python subs.py --project . [--fps 30]
产出:
subs.json + frames/<id>.beats.js + out/<slug>.srt / .vtt
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
FPS_DEFAULT = 30
SUB_SIZE = 44
SUB_MAX_W = 1160
SUB_MIN_SIZE = 34
SUB_BUDGET_ZH = 16
SUB_BUDGET_EN = 48
MIN_HOLD = 0.6
_EM = {'space': 0.227, 'upper': 0.668, 'digit': 0.59, 'lower': 0.566,
'accent': 0.58, 'punct': 0.325}
def text_em(s: str) -> float:
t = 0.0
for ch in s:
c = ord(ch)
if c >= 0x2000:
t += 1.0
elif ch == ' ':
t += _EM['space']
elif 'A' <= ch <= 'Z':
t += _EM['upper']
elif '0' <= ch <= '9':
t += _EM['digit']
elif 'a' <= ch <= 'z':
t += _EM['lower']
elif 0xc0 <= c < 0x250:
t += _EM['accent']
else:
t += _EM['punct']
return t
def fit_size(text, max_w=SUB_MAX_W, size=SUB_SIZE, floor=SUB_MIN_SIZE):
w = text_em(text) * size
if w <= max_w:
return size
return max(floor, int(size * max_w / w))
def will_wrap(text):
return text_em(text) * SUB_MIN_SIZE > SUB_MAX_W
def has_cjk(s: str) -> bool:
return any('\u4e00' <= c <= '\u9fff' for c in s)
_SENT_END = '。!?;'
_PAUSE = ',、:'
def split_chunks(text: str):
if '|' in text:
return [c.strip() for c in text.split('|') if c.strip()]
return auto_split(text)
def auto_split(text: str, budget=None):
zh = has_cjk(text)
budget = budget or (SUB_BUDGET_ZH if zh else SUB_BUDGET_EN)
sents, cur = [], ''
for ch in text:
cur += ch
if ch in _SENT_END:
sents.append(cur.strip()); cur = ''
if cur.strip():
sents.append(cur.strip())
out = []
for s in sents:
if len(s) <= budget:
out.append(s); continue
parts, cur = [], ''
for ch in s:
cur += ch
if ch in _PAUSE:
parts.append(cur.strip()); cur = ''
if cur.strip():
parts.append(cur.strip())
buf = ''
for p in parts:
if not buf:
buf = p
elif len(buf) + len(p) <= budget:
buf += p
else:
out.append(buf); buf = p
if buf:
while len(buf) > budget:
out.append(buf[:budget]); buf = buf[budget:]
if buf:
out.append(buf)
return out or [text]
_PUNCT_RE = re.compile(r'[\s,。、!?:;“”(),.!?:;()\-—…·|]')
def chunk_starts(tts_text, chunks, words, lead_cut):
"""每块首字对齐到词边界秒(词边界为原始坐标系 → 减 lead_cut 平移)。"""
def _t_of(w):
if 't' in w:
return float(w['t'])
return float(w.get('offset_sec', 0.0))
char_t = [None] * len(tts_text)
cur = 0
for w in words:
wt = _PUNCT_RE.sub('', w.get('text', ''))
if not wt:
continue
p = tts_text.find(wt, cur)
if p < 0:
p = tts_text.find(wt[0], cur)
if p < 0:
continue
t = _t_of(w) - lead_cut
for i in range(p, min(len(tts_text), p + len(wt))):
char_t[i] = t
cur = p + len(wt)
starts, pos = [], 0
for c in chunks:
st = None
for i in range(pos, min(len(tts_text), pos + len(c))):
if char_t[i] is not None:
st = char_t[i]; break
starts.append(st)
pos += len(c)
fixed = []
for st in starts:
if st is None:
st = fixed[-1] if fixed else 0.0
if fixed and st < fixed[-1]:
st = fixed[-1]
fixed.append(max(0.0, st))
if fixed:
fixed[0] = 0.0
return fixed
_KEEP_CHARS = set('“”"\'‘’…、~·%()《》〈〉【】[]()/*&+=-_#@$')
_TO_SPACE = set(' \u3000\t')
_DROP_PUNCT = set(',。!?;:,.!?;:、·||')
def clean_sub_text(s: str, lang: str = 'zh') -> str:
out = []
for ch in s:
if ch in _KEEP_CHARS:
out.append(ch); continue
if ch in _TO_SPACE:
if lang != 'zh':
out.append(' ')
continue
if ch in _DROP_PUNCT:
continue
out.append(ch)
t = re.sub(r'\s{2,}', ' ', ''.join(out)).strip()
return t
def durations_proportional(secs, min_hold):
n = len(secs)
if n == 0:
return []
if n * min_hold >= sum(secs):
return [min_hold] * n
d = list(secs)
fixed = [False] * n
for _ in range(n + 2):
free = [i for i in range(n) if not fixed[i]]
if not free:
break
free_total = sum(secs[i] for i in free)
budget = sum(secs) - min_hold * (n - len(free))
changed = False
for i in free:
share = budget * (secs[i] / free_total) if free_total > 0 else budget / len(free)
if share < min_hold:
d[i] = min_hold; fixed[i] = True; changed = True
else:
d[i] = share
if not changed:
break
return d
def frame_of(sec: float, fps: float) -> int:
return int(round(sec * fps)) + 1
def tc(frame: int, fps: float, sep: str) -> str:
ms = int(round((frame - 1) * 1000.0 / fps))
h, rem = divmod(ms, 3600_000)
m, rem = divmod(rem, 60_000)
s, ms = divmod(rem, 1000)
return f'{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}'
# ---- beats.js:给场景作者的对齐节拍器 ----
BEATS_TMPL = """/* 自动生成(subs.py)——解说词/配音改动后重跑即全量刷新,别手改。
* B(text) → 该字幕块起播秒(本地坐标系,0 = 本场景第 0 帧)
* Be(text) → 该块收尾秒
* 规则:text 匹配「块的原文片段」即可(块内前几个字也行),找不到抛错。
* __SEG_INFO__:{id, duration, speech_end, tail} —— duration 对帧长、
* speech_end 是语音真实结束(按它排主体动画,尾部留静止呼吸)。
* 用法:window.__SEG__.speech_end / window.__SEG__.duration */
window.__BEATS__ = __BEATS_DATA__;
window.__SEG__ = __SEG_DATA__;
(function () {
var B = window.__BEATS__;
function find(text) {
var t = String(text || '').replace(/[\\s,。、!?;:,.!?;:|]/g, '');
for (var i = 0; i < B.length; i++) {
var bt = B[i].text.replace(/[\\s,。、!?;:,.!?;:|]/g, '');
if (bt === t || bt.indexOf(t) === 0 || t.indexOf(bt) === 0) return B[i];
}
throw new Error('B() 找不到节拍: ' + text);
}
window.B = function (text) { return find(text).start; };
window.Be = function (text) { return find(text).end; };
})();
"""
def write_beats(path, seg):
data = [{'text': b['spoken'], 'start': b['local_start_sec'],
'end': round(b['local_start_sec'] + (b['global_end_sec'] - b['global_start_sec']), 3)}
for b in seg['blocks']]
seginfo = {'id': seg['id'], 'duration': seg['duration_sec'],
'speech_end': seg['speech_end_sec'], 'tail': seg['tail_silence_sec']}
js = BEATS_TMPL.replace('__BEATS_DATA__', json.dumps(data, ensure_ascii=False, indent=2)) \
.replace('__SEG_DATA__', json.dumps(seginfo, ensure_ascii=False))
open(path, 'w', encoding='utf-8', newline='\n').write(js)
def main() -> int:
ap = argparse.ArgumentParser(description="html-explainer 字幕 + 节拍器")
ap.add_argument("--project", default=".")
ap.add_argument("--fps", type=float, default=0)
args = ap.parse_args()
proj = os.path.abspath(args.project)
pj = json.load(open(os.path.join(proj, "project.json"), encoding="utf-8"))
fps = args.fps or float(pj.get("fps") or FPS_DEFAULT)
narration = json.load(open(os.path.join(proj, "narration.json"), encoding="utf-8"))
if isinstance(narration, dict):
narration = narration.get("items") or []
man = json.load(open(os.path.join(proj, "audio-manifest.json"), encoding="utf-8"))
by_id = {it.get("id") or os.path.splitext(os.path.basename(it["path"]))[0]: it
for it in man.get("items", [])}
layout = json.load(open(os.path.join(proj, "layout.json"), encoding="utf-8"))
out = {'fps': fps, 'size': SUB_SIZE, 'safe_width': SUB_MAX_W,
'budget': {'zh': SUB_BUDGET_ZH, 'en': SUB_BUDGET_EN}, 'segments': []}
warn_over = []
for item in narration:
sid, text = item['id'], item['text']
chunks = split_chunks(text)
m = by_id.get(sid) or {}
words = m.get('boundaries') or []
lead_cut = float(m.get('lead_cut_sec', 0.0))
trimmed = bool(man.get('trimmed'))
tts_text = ''.join(chunks)
if words:
starts_sec = chunk_starts(tts_text, chunks, words, lead_cut)
else:
total = float(m.get('container_duration_sec') or m.get('duration_sec') or 1.0)
tot = max(1, sum(len(c) for c in chunks))
acc, starts_sec = 0, []
for c in chunks:
starts_sec.append(total * acc / tot); acc += len(c)
print(f' ⚠ {sid}: 无词边界 → 按字数插值(字幕会飘)', file=sys.stderr)
# 两级时钟:段长 = 容器;末块收尾 = 语音真实结束(均为裁后坐标系)
seg_container = float(m.get('container_duration_sec') or m.get('duration_sec') or 0.0)
if trimmed and 'speech_end_sec' in m:
speech_end = float(m['speech_end_sec'])
else:
speech_end = float(m.get('duration_sec') or seg_container) - lead_cut
span_end = min(speech_end, seg_container) if seg_container else speech_end
n = len(chunks)
natural = list(starts_sec) + [span_end]
if len(natural) < n + 1:
natural += [span_end] * (n + 1 - len(natural))
raw_spans = [max(1.0 / fps, natural[i + 1] - natural[i]) for i in range(n)]
spans = durations_proportional(raw_spans, MIN_HOLD) if n else []
edges = [starts_sec[0] if starts_sec else 0.0]
for d in spans:
edges.append(edges[-1] + d)
if edges[-1] > span_end and len(edges) > 1:
k = span_end / edges[-1]
edges = [starts_sec[0] + (e - starts_sec[0]) * k for e in edges]
starts_sec = edges[:-1]
bounds = edges
base = float(layout.get(sid, {}).get('start_sec', 0.0))
blocks = []
for i, c in enumerate(chunks):
a, b = bounds[i], bounds[i + 1]
if b <= a:
b = a + 1.0 / fps
f0, f1 = frame_of(a, fps), max(frame_of(a, fps), frame_of(b, fps) - 1)
screen = clean_sub_text(c, 'zh' if has_cjk(c) else 'en') or c
size = fit_size(screen)
blocks.append({
'text': screen, 'spoken': c, 'from': f0, 'to': f1,
'local_start_sec': round(a, 3),
'global_start_sec': round(base + a, 3),
'global_end_sec': round(base + b, 3),
'size': size,
})
if will_wrap(screen) or len(screen) > (SUB_BUDGET_ZH if has_cjk(screen) else SUB_BUDGET_EN):
warn_over.append((sid, screen))
out['segments'].append({
'id': sid, 'text': text, 'chunks': chunks, 'tts_text': tts_text,
'start_sec': round(base, 3), 'duration_sec': round(seg_container, 3),
'speech_end_sec': round(speech_end, 3),
'tail_silence_sec': round(max(0.0, seg_container - speech_end), 3),
'blocks': blocks,
})
sp = os.path.join(proj, 'subs.json')
json.dump(out, open(sp, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
# beats.js per scene
frames_dir = os.path.join(proj, 'frames')
os.makedirs(frames_dir, exist_ok=True)
for s in out['segments']:
write_beats(os.path.join(frames_dir, f"{s['id']}.beats.js"), s)
# SRT / VTT
items = []
for s in out['segments']:
for b in s['blocks']:
gf0 = frame_of(b['global_start_sec'], fps)
gf1 = max(gf0, frame_of(b['global_end_sec'], fps) - 1)
items.append((gf0, gf1, b['text']))
items.sort(key=lambda x: x[0])
for i in range(len(items) - 1):
if items[i][1] >= items[i + 1][0]:
items[i] = (items[i][0], items[i + 1][0] - 1, items[i][2])
srt, vtt = [], ['WEBVTT', f'NOTE {len(items)} 条 · fps {fps}', '']
for i, (f0, f1, text) in enumerate(items, 1):
srt.append(f'{i}\n{tc(f0, fps, ",")} --> {tc(f1 + 1, fps, ",")}\n{text}\n')
vtt.append(f'{tc(f0, fps, ".")} --> {tc(f1 + 1, fps, ".")}\n{text}\n')
slug = pj.get('slug') or 'video'
out_dir = os.path.join(proj, 'out')
os.makedirs(out_dir, exist_ok=True)
srt_p = os.path.join(out_dir, f'{slug}.srt')
open(srt_p, 'w', encoding='utf-8', newline='\n').write('\n'.join(srt))
open(os.path.splitext(srt_p)[0] + '.vtt', 'w', encoding='utf-8', newline='\n').write('\n'.join(vtt))
n_blocks = sum(len(s['blocks']) for s in out['segments'])
print(f'[subs] {len(out["segments"])} 段 → {n_blocks} 块 · fps {fps}')
print(f'[subs] → {sp} + frames/<id>.beats.js + {srt_p}(.vtt)')
for s in out['segments']:
print(f' {s["id"]:<12} {len(s["blocks"])} 块 语音止 {s["speech_end_sec"]:>6.2f}s '
f'/ 容器 {s["duration_sec"]:>6.2f}s(尾静音 {s["tail_silence_sec"]:.2f}s 不挂字幕)')
if warn_over:
print(f'⚠ {len(warn_over)} 块超预算(自动缩字号,>1.3× 折两行压内容区,按缺陷处理):')
for sid, c in warn_over[:8]:
print(f' {sid}: "{c}"')
return 0
if __name__ == '__main__':
raise SystemExit(main())
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
timeline_build.py —— 全局时间轴:layout.json + 拼接音轨。
做两件事:
① 按 project.json 的 order 把各段容器时长 + 段间 gap 排成全局时间轴
→ layout.json(每段 start_sec / duration_sec,渲染器与字幕都读它)
② 把逐段 MP3 用 ffmpeg concat + anullsrc 间隙拼成一条 narration-full.mp3
(总长 = Σ容器时长 + gap×(n-1),与视频帧数同口径 → 音画天然对齐)
用法:
python timeline_build.py --project . [--gap 0.35]
产出:
layout.json + audio/narration-full.mp3
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
def ffmpeg_exe() -> str:
from shutil import which
p = which("ffmpeg")
if p:
return p
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def probe_duration(path: str, ff: str) -> float:
r = subprocess.run([ff, "-hide_banner", "-i", path],
capture_output=True, text=True, errors="replace")
for line in r.stderr.splitlines():
if "Duration:" in line:
t = line.split("Duration:")[1].split(",")[0].strip()
h, m, s = t.split(":")
return int(h) * 3600 + int(m) * 60 + float(s)
return 0.0
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--project", default=".")
ap.add_argument("--gap", type=float, default=-1, help="段间静音秒(默认取 project.json 的 gap)")
args = ap.parse_args()
proj = os.path.abspath(args.project)
pj = json.load(open(os.path.join(proj, "project.json"), encoding="utf-8"))
order = pj.get("order") or []
gap = args.gap if args.gap >= 0 else float(pj.get("gap", 0.35))
man_path = os.path.join(proj, "audio-manifest.json")
if not os.path.exists(man_path):
print("✗ 先跑 tts_build.py(缺 audio-manifest.json)", file=sys.stderr)
return 1
man = json.load(open(man_path, encoding="utf-8"))
by_id = {}
for it in man.get("items", []):
by_id[it.get("id") or os.path.splitext(os.path.basename(it["path"]))[0]] = it
ff = ffmpeg_exe()
layout, cursor, parts = {}, 0.0, []
for nid in order:
it = by_id.get(nid)
if not it:
print(f"✗ manifest 里没有 {nid}", file=sys.stderr)
return 1
dur = round(float(it.get("container_duration_sec") or it["duration_sec"]), 3)
layout[nid] = {"start_sec": round(cursor, 3), "duration_sec": dur}
parts.append(os.path.join(proj, "audio", f"{nid}.mp3"))
cursor += dur + gap
total = cursor - (gap if order else 0.0)
# ---- 拼音轨:concat filter + anullsrc 间隙 ----
inputs = []
for p in parts:
inputs += ["-i", p]
n = len(parts)
filters, labels = [], []
for i in range(n):
filters.append(f"[{i}:a]aresample=44100[a{i}]")
labels.append(f"[a{i}]")
if i < n - 1:
inputs += ["-f", "lavfi", "-t", str(gap), "-i",
"anullsrc=channel_layout=mono:sample_rate=44100"]
filters.append(f"[{n + i}:a]aresample=44100[g{i}]")
labels.append(f"[g{i}]")
fc = ";".join(filters) + ";" + "".join(labels) + f"concat=n={len(labels)}:v=0:a=1[out]"
out = os.path.join(proj, "audio", "narration-full.mp3")
os.makedirs(os.path.dirname(out), exist_ok=True)
cmd = [ff, "-hide_banner", "-loglevel", "error", "-y",
*inputs, "-filter_complex", fc, "-map", "[out]",
"-c:a", "libmp3lame", "-b:a", "128k", out]
r = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
if r.returncode != 0:
print("✗ ffmpeg 拼接失败:\n" + (r.stderr or "")[-800:], file=sys.stderr)
return 1
audio_total = probe_duration(out, ff)
layout["_total"] = {"audio_duration_sec": round(audio_total, 3),
"video_duration_sec": round(total, 3),
"gap_sec": gap, "fps": pj.get("fps", 30),
"total_frames": int(round(total * pj.get("fps", 30)))}
lp = os.path.join(proj, "layout.json")
json.dump(layout, open(lp, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print(f"[timeline] {len(order)} 段 · gap {gap}s · 视频 {total:.2f}s / "
f"{int(round(total * pj.get('fps', 30)))} 帧 · 音轨 {audio_total:.2f}s → {lp}")
drift = abs(audio_total - total)
if drift > 0.5:
print(f" ⚠ 音轨与视频时长差 {drift:.2f}s(>0.5s,检查 gap 或音频)", file=sys.stderr)
for nid in order:
print(f" {nid:<12} start {layout[nid]['start_sec']:>7.2f}s dur {layout[nid]['duration_sec']:>6.3f}s")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
tts_build.py —— html-explainer 的配音与时间轴数据源(edge-tts)。
【血统与口径】工程化外壳移植自 anything2explainer 的 tts_build.py(经
html-video-workbuddy-driver 的 tts_edges.py 二次实战),核心五件事:
① RATE 默认 +8%(长片解说密度;短视频高密度场景可加到 +20%~+37%,
标定法:同一句话跨 RATE 对比,dur×(1+rate) 应为常数 → 线性可外推)
② 每次合成 asyncio.wait_for 硬超时 45s —— edge-tts 的 websocket 会
偶发**永久挂起**(不抛错不返回零日志),必须把挂死变成可重试失败
③ 指数退避重试 4 次(微软端点连续请求会限流抛 NoAudioReceived)
④ 句间 0.8s 间隔 + 磁盘缓存(sig 含 boundary 与 trim —— 参数变了旧缓存自动失效)
⑤ 默认裁首尾静音(首回退 0.03s 保爆破音、尾保留 0.12s 保收音),
裁完段间停顿才由 GAP 显式控制
两个 edge-tts 7.x 的坑(已处理,别删):
1) boundary 默认 'SentenceBoundary' → 整句一个事件,字幕节拍退化成
按字数插值。必须显式传 boundary='WordBoundary'。
2) 相邻数字熔成大数(「沪深300」「4479.55点」→「三百万四千…」),
数字与数字之间补中文逗号。
时长两级口径(manifest 同时写两个,下游各取所需):
container_duration_sec 裁完后 MP3 容器时长 → **帧时长用这个**(音画同步)
speech_end_sec 词边界末尾(裁后坐标系)→ **末块字幕收尾用这个**
duration_sec 词边界末尾(原始坐标系)→ 仅排查用
用法(项目目录内):
python tts_build.py --project . [--rate +8%] [--voice zh-CN-YunxiNeural] [--no-trim]
产出:
audio/<id>.mp3 + audio-manifest.json
"""
from __future__ import annotations
import argparse
import asyncio
import hashlib
import inspect
import json
import os
import re
import subprocess
import sys
import edge_tts
DEFAULT_VOICE_ZH = "zh-CN-YunxiNeural" # 云希,男声
DEFAULT_VOICE_EN = "en-US-GuyNeural"
DEFAULT_RATE = "+8%"
DEFAULT_VOLUME = "+0%"
DEFAULT_PITCH = "+0Hz"
DEFAULT_TIMEOUT = 45.0
DEFAULT_RETRIES = 4
RETRY_SLEEP = 0.8
TRIM_HEAD_BACKOFF = 0.03
TRIM_TAIL_KEEP = 0.12
TRIM_THRESHOLD = 0.004
_NUMERIC_RUN = re.compile(r"[0-9]+(?:\.[0-9]+)?")
def sanitize_for_tts(text: str) -> str:
"""相邻数字之间补中文逗号,防熔读(4479.55 这种完整数字不会被拆)。"""
tokens, last = [], 0
for m in _NUMERIC_RUN.finditer(text):
tokens.append(("text", text[last:m.start()]))
tokens.append(("num", m.group(0)))
last = m.end()
tokens.append(("text", text[last:]))
out: list[str] = []
prev_kind = None
for kind, val in tokens:
if kind == "num" and prev_kind == "num":
if out and not out[-1].endswith((",", ",", "。")):
out.append(",")
out.append(val)
prev_kind = kind
return "".join(out)
def _boundary_kwarg() -> str:
try:
p = inspect.signature(edge_tts.Communicate.__init__).parameters
return "WordBoundary" if "boundary" in p else "n/a"
except Exception:
return "n/a"
def ffmpeg_exe() -> str:
from shutil import which
p = which("ffmpeg")
if p:
return p
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def container_duration(path: str) -> float:
r = subprocess.run([ffmpeg_exe(), "-hide_banner", "-i", path],
capture_output=True, text=True, errors="replace")
for line in r.stderr.splitlines():
if "Duration:" in line:
t = line.split("Duration:")[1].split(",")[0].strip()
h, m, s = t.split(":")
return round(int(h) * 3600 + int(m) * 60 + float(s), 3)
return 0.0
def decode_pcm(path: str, sr: int = 48000):
import numpy as np
r = subprocess.run([ffmpeg_exe(), "-v", "error", "-i", path, "-f", "f32le",
"-ac", "1", "-ar", str(sr), "-"], capture_output=True)
return np.frombuffer(r.stdout, dtype=np.float32)
def speech_window(path: str, sr: int = 48000):
import numpy as np
x = decode_pcm(path, sr)
if x.size == 0:
return 0.0, 0.0
idx = np.where(np.abs(x) > TRIM_THRESHOLD)[0]
if idx.size == 0:
return 0.0, 0.0
return round(float(idx[0]) / sr, 3), round(float(idx[-1]) / sr, 3)
def trim_edges(path: str, sr: int = 48000) -> float:
"""原地裁首尾静音,返回裁掉的首部长度(秒)。"""
import numpy as np
x = decode_pcm(path, sr)
if x.size == 0:
return 0.0
idx = np.where(np.abs(x) > TRIM_THRESHOLD)[0]
if idx.size == 0:
return 0.0
a = max(0, int(idx[0]) - int(TRIM_HEAD_BACKOFF * sr))
b = min(len(x), int(idx[-1]) + int(TRIM_TAIL_KEEP * sr))
if b <= a:
return 0.0
y = x[a:b]
tmp = path + ".trim.mp3"
cmd = [ffmpeg_exe(), "-hide_banner", "-loglevel", "error", "-y",
"-f", "f32le", "-ac", "1", "-ar", str(sr), "-i", "-",
"-c:a", "libmp3lame", "-b:a", "128k", tmp]
r = subprocess.run(cmd, input=y.astype("<f4").tobytes(), capture_output=True)
if r.returncode != 0 or not os.path.exists(tmp):
print(f" ⚠ trim 失败,保留原文件:{(r.stderr or b'')[-200:]}", file=sys.stderr)
return 0.0
os.replace(tmp, path)
return round(a / sr, 3)
def cache_paths(text, voice, rate, volume, pitch, cache_dir, trim):
sig = f"{voice}|{rate}|{volume}|{pitch}|{_boundary_kwarg()}|{int(trim)}|{text}"
h = hashlib.sha1(sig.encode("utf-8")).hexdigest()[:16]
d = os.path.join(cache_dir, "trim") if trim else cache_dir
os.makedirs(d, exist_ok=True)
return os.path.join(d, h + ".mp3"), os.path.join(d, h + ".json")
async def synth(text, out_path, voice, rate, volume=DEFAULT_VOLUME, pitch=DEFAULT_PITCH,
cache_dir="", timeout=DEFAULT_TIMEOUT, retries=DEFAULT_RETRIES, trim=True) -> dict:
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
spoken = sanitize_for_tts(text)
cache_mp3 = cache_js = ""
if cache_dir:
cache_mp3, cache_js = cache_paths(text, voice, rate, volume, pitch, cache_dir, trim)
if os.path.exists(cache_mp3) and os.path.exists(cache_js):
bounds = json.load(open(cache_js, encoding="utf-8"))
with open(cache_mp3, "rb") as f:
open(out_path, "wb").write(f.read())
dur = bounds[-1]["offset_sec"] + bounds[-1]["duration_sec"] if bounds else 0.0
return _result(text, spoken, out_path, voice, rate, bounds, dur, 0.0, trim)
async def _once():
kw = {}
b = _boundary_kwarg()
if b != "n/a":
kw["boundary"] = b
comm = edge_tts.Communicate(spoken, voice, rate=rate, volume=volume, pitch=pitch, **kw)
audio = bytearray()
bounds: list[dict] = []
async for chunk in comm.stream():
if chunk["type"] == "audio":
audio.extend(chunk["data"])
elif chunk["type"] == "WordBoundary":
bounds.append({
"text": chunk["text"],
"offset_sec": round(chunk["offset"] / 10_000_000, 4),
"duration_sec": round(chunk["duration"] / 10_000_000, 4),
})
if not audio:
raise RuntimeError("empty audio stream")
return bytes(audio), bounds
last: Exception | None = None
for attempt in range(retries):
try:
audio, bounds = await asyncio.wait_for(_once(), timeout=timeout)
with open(out_path, "wb") as f:
f.write(audio)
await asyncio.sleep(RETRY_SLEEP)
head_cut = trim_edges(out_path) if trim else 0.0
if cache_dir:
with open(cache_mp3, "wb") as f:
f.write(open(out_path, "rb").read())
json.dump(bounds, open(cache_js, "w", encoding="utf-8"), ensure_ascii=False)
dur = bounds[-1]["offset_sec"] + bounds[-1]["duration_sec"] if bounds else 0.0
return _result(text, spoken, out_path, voice, rate, bounds, dur, head_cut, trim)
except Exception as e: # noqa: BLE001
last = e
print(f" [retry {attempt + 1}/{retries}] {type(e).__name__}: {e}", file=sys.stderr, flush=True)
await asyncio.sleep(1.5 * (attempt + 1))
raise SystemExit(
f"edge-tts 连续 {retries} 次失败:{type(last).__name__}: {last}\n"
f"已合成段落走缓存不会重复耗时,稍后重跑即可。")
def _result(text, spoken, out_path, voice, rate, bounds, dur, head_cut, trimmed) -> dict:
cd = container_duration(out_path)
ds, de = speech_window(out_path) if trimmed else (0.0, cd)
return {
"path": os.path.abspath(out_path),
"bytes": os.path.getsize(out_path) if os.path.exists(out_path) else 0,
"voice": voice, "rate": rate,
"spoken_text": spoken, "original_text": text,
# ★ 词边界是「原始坐标系」;裁剪后请用 lead_cut_sec 平移
"duration_sec": round(dur, 3),
"lead_cut_sec": round(head_cut, 3),
# ★ 帧时长口径:裁后容器时长
"container_duration_sec": cd,
# ★ 末块字幕收尾口径:裁后坐标系里的语音真实结束
# 注意 speech_window(out_path) 量的是**已经裁过**的文件,de 本身就在裁后坐标系里,
# 绝不能再减 head_cut(那会重复扣一次,症状是「重跑(走缓存)与首跑(现合成)
# 报出的 speech_end 差一个首裁量」,字幕末块提前消失)。见 lessons.md。
"speech_start_sec": ds if trimmed else 0.0,
"speech_end_sec": de if trimmed else round(dur, 3),
"tail_pad_sec": round(max(0.0, cd - (de if trimmed else dur)), 3),
"boundary_count": len(bounds),
"boundaries": bounds,
}
async def run(args) -> int:
proj = os.path.abspath(args.project)
pj = json.load(open(os.path.join(proj, "project.json"), encoding="utf-8"))
narration = json.load(open(os.path.join(proj, "narration.json"), encoding="utf-8"))
if isinstance(narration, dict):
narration = narration.get("items") or []
voice = args.voice or pj.get("voice") or DEFAULT_VOICE_ZH
rate = args.rate or pj.get("rate") or DEFAULT_RATE
out_dir = os.path.join(proj, "audio")
cache = os.path.join(out_dir, "cache")
results = []
for item in narration:
key, text = item["id"], item["text"]
r = await synth(text, os.path.join(out_dir, f"{key}.mp3"),
voice=item.get("voice", voice), rate=item.get("rate", rate),
cache_dir="" if args.no_cache else cache,
trim=not args.no_trim)
r["id"] = key
results.append(r)
print(f" [{key}] {r['bytes']:>8}B 容器 {r['container_duration_sec']:>7.3f}s "
f"语音止 {r['speech_end_sec']:>6.3f}s 首裁 {r['lead_cut_sec']:.3f}s "
f"bounds={r['boundary_count']}", file=sys.stderr)
manifest = {
"voice": voice, "rate": rate,
"trimmed": not args.no_trim,
"engine": f"edge-tts {edge_tts.__version__}",
"boundary_mode": _boundary_kwarg(),
"items": results,
}
mp = os.path.join(proj, "audio-manifest.json")
json.dump(manifest, open(mp, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print(f"[tts] {len(results)} 段 → audio/ + {mp}")
total = sum(r["container_duration_sec"] for r in results)
print(f"[tts] 容器合计 {total:.2f}s(未含段间 gap;timeline_build.py 会加)")
return 0
def main() -> int:
p = argparse.ArgumentParser(description="html-explainer 配音合成(edge-tts)")
p.add_argument("--project", default=".")
p.add_argument("--voice", default="")
p.add_argument("--rate", default="")
p.add_argument("--no-trim", action="store_true", help="不裁首尾静音(不建议)")
p.add_argument("--no-cache", action="store_true")
args = p.parse_args()
return asyncio.run(run(args))
if __name__ == "__main__":
raise SystemExit(main())