Files
html-explainer/scripts/import_styles.py
T
MohZhuang 66b61002f2 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
2026-09-21 12:46:23 +08:00

398 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())