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:
commit
66b61002f2
42 files changed
+8004
No files matched your search
@@ -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())
|
||||
@@ -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)));
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)));
|
||||
@@ -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())
|
||||
@@ -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
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user