Files
html-explainer/scripts/subs.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

414 lines
15 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 -*-
"""
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())