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