Files
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

148 lines
6.5 KiB
Python
Raw Permalink 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 -*-
"""
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())