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
180 lines
8.2 KiB
Python
180 lines
8.2 KiB
Python
#!/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())
|