diff --git a/.github/workflows/html2video-for-mcode-smoke.yml b/.github/workflows/html2video-for-mcode-smoke.yml
new file mode 100644
index 00000000..24dbc216
--- /dev/null
+++ b/.github/workflows/html2video-for-mcode-smoke.yml
@@ -0,0 +1,97 @@
+# html2video-for-mcode · scoped smoke CI
+# 官方主 CI(ci.yml)只跑 validate + node --test(无 ffmpeg/playwright, 渲染冒烟会 skip)。
+# 本 workflow 按 CONTRIBUTING/ci.yml 注释给出的"单插件 scoped workflow"模式,
+# 装齐依赖后真实执行全部测试 —— 这是 PR #41 评审要求的可执行测试证据。
+# 触发条件: 仅本插件目录(.github/workflows/ 本文件除外)或本文件自身变更。
+#
+# 第三轮 review 修正(2026-09-18, 1.7.1 才真正推上来 —— 1.7.0 时这份修正只改了本地没推送,
+# PR 分支上一直是旧枚举版, 漏跑 cover-transition / review-round2 / review-round3 / subtitles-invalidate):
+# ① 测试文件不再逐个枚举, 改用 shell glob `tests/*.test.mjs` —— 枚举写法每加一个测试文件就要
+# 手工记得改这里, 历史上已经漏过 css-kit / tokens-fx / chart-kit / table-kit 与上述四个文件。
+# ② 补 permissions: contents: read(与本仓库其余 scoped workflow 一致, 只读仓库)。
+# ③ actions 按仓库主 ci.yml 的同一批版本做**完整 SHA pin**(附版本注释), 不用浮动 tag;
+# SHA 由 gh api repos///commits/ 解析得到, 40 位整(主 ci.yml 的 checkout
+# 那行多了一位字符, 那是宿主仓库自己的笔误, 不去动它 —— 见 PR comment)。
+#
+# 第四轮 CHANGES_REQUESTED 修正(2026-09-21): 评审要求 exact-head 证据覆盖 **Windows** ——
+# 新增 windows-latest 作业(同一条命令, 依赖换成 choco ffmpeg + chromium)。两个作业的命令与
+# 依赖组合已先在技能镜像仓的 CI 上双平台跑绿(248 pass / 0 fail / 0 skip, 两个平台各一次),
+# Node 版本也从 22 提到 24 与那份证过的配置对齐(此 workflow 此前因审批门从未真正执行过)。
+# ffmpeg 一律走系统包(PATH 发现): 测试会把脚本放进临时项目目录当 cwd 跑, node_modules 发现
+# 依赖 cwd/上两层锚点, 在插件这种深层布局里够不到仓库根的 node_modules。
+name: html2video-for-mcode smoke
+
+on:
+ pull_request:
+ paths:
+ - 'plugins/Wzdhehe/html2video-for-mcode/**'
+ - '.github/workflows/html2video-for-mcode-smoke.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'plugins/Wzdhehe/html2video-for-mcode/**'
+ - '.github/workflows/html2video-for-mcode-smoke.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ smoke-linux:
+ name: smoke (ubuntu-latest · ffmpeg + chromium)
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 24
+
+ - name: Install ffmpeg
+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
+
+ - name: Install playwright (no package.json / lockfile changes)
+ run: |
+ npm install --no-save --no-package-lock playwright
+ npx playwright install --with-deps chromium
+
+ # 一条命令跑完 tests/ 下的**全部** *.test.mjs(安全 / 策略 / 模板 / 受管块 / 渲染冒烟)。
+ # glob 交给 shell 展开: 以后新增测试文件自动进 CI, 不需要改这里(旧枚举写法漏过 8 个文件)。
+ # 单测超时 300s: 渲染冒烟里的 capture / build-video 在 CI 上要几十秒。
+ - name: All tests (security · policy · templates · css blocks · render smoke)
+ run: |
+ node --test --test-timeout=300000 \
+ plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/*.test.mjs
+
+ smoke-windows:
+ name: smoke (windows-latest · ffmpeg + chromium)
+ runs-on: windows-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 24
+
+ # choco 的 ffmpeg 包含 ffmpeg.exe 与 ffprobe.exe, 落在 PATH 上
+ - name: Install ffmpeg
+ shell: bash
+ run: choco install ffmpeg -y --no-progress
+
+ - name: Install playwright (no package.json / lockfile changes)
+ shell: bash
+ run: |
+ npm install --no-save --no-package-lock playwright
+ npx playwright install chromium
+
+ - name: All tests (security · policy · templates · css blocks · render smoke)
+ shell: bash
+ run: |
+ node --test --test-timeout=300000 \
+ plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/*.test.mjs
diff --git a/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json
new file mode 100644
index 00000000..282237ec
--- /dev/null
+++ b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json
@@ -0,0 +1,8 @@
+{
+ "name": "html2video-for-mcode",
+ "version": "1.9.10",
+ "description": "Turn a topic, outline, or script into a narrated MP4: HTML slides with staged entrance animations, TTS voiceover, ffmpeg assembly, and ASR verification.",
+ "skills": [
+ "./skills/html2video-for-mcode/SKILL.md"
+ ]
+}
diff --git a/plugins/Wzdhehe/html2video-for-mcode/.gitattributes b/plugins/Wzdhehe/html2video-for-mcode/.gitattributes
new file mode 100644
index 00000000..c304db3a
--- /dev/null
+++ b/plugins/Wzdhehe/html2video-for-mcode/.gitattributes
@@ -0,0 +1,10 @@
+# Force LF for this plugin's text files. Added in 1.9.3: nine files carried CRLF
+# (authored on Windows), which made `git diff --check` report trailing-whitespace
+# errors on the PR under default whitespace rules. Same convention as
+# plugins/antianqi/mcode-island/.gitattributes.
+# Override at clone time: `git config core.autocrlf input`.
+* text=auto eol=lf
+*.md text eol=lf
+*.mjs text eol=lf
+*.json text eol=lf
+LICENSE text eol=lf
diff --git a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md
new file mode 100644
index 00000000..5298d5a3
--- /dev/null
+++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md
@@ -0,0 +1,475 @@
+# Changelog
+
+## 1.9.10 — 2026-09-23
+
+**Round-21 review: subtitle line-width geometry corrected, and the 18-char rule's drift closed** (two-axis review of 1.9.9 found one hard inconsistency and one wrong baseline — both reproduced before fixing)
+
+- **Rule drift (hard):** 1.9.9 rewrote the subtitle wrap rule in `SKILL.md` and `references/authoring.md`, but three surfaces kept the old ">18 chars" rule — `references/render.md` ("A single sentence >18 chars … gets a warning from plan-timings"), `references/tts-and-timing.md` ("the single-line subtitle limit is 18 chars for Chinese / 42 characters for English") and `evals/evals.json` ("单句建议 ≤18 字"). All three now carry the width-aware rule (and `render.md` is a file 1.9.9 itself had edited — so this was a missed spot, not an untouched file).
+- **The 1080 baseline published in 1.9.9 was wrong** — correction to that entry: its "≈18 CJK chars per line at 1080, ≈32 at 1920" figures were a restatement of the superseded vertical baseline. The pill's font **and** padding both scale with `--sub-scale = clamp(W/1920, 0.75, 1.25)`, so per-line capacity is **not** linear in width: true values are ≈**24** CJK chars at 1080, ≈**33** at 1920, ≈**35** at 2560. The `18 × W/1080` estimate over-warned at 1080 by ~27% and — the dangerous direction — missed real three-line wraps at 2560 (estimated 42 vs true 35.6). All five doc surfaces now state the corrected numbers and separate the **craft guideline** (≈18 CJK / ≈42 English for one-liners) from the **pill capacity** the gate uses.
+- **Single source:** the pill geometry (box width 0.729167, side padding 34px, font 40px, the scale clamp) now lives once in `tools.SUB_GEOMETRY` / `subScale` / `subLineCap`; `plan-timings` derives the line cap from it and `capture` builds the `.kit-sub` CSS from the same constants — the two copies had already drifted (the review's Duplicated Code finding).
+- **Entry guard (same review):** the same-basename fallback now **announces itself on stderr** when it fires — the trade is "a loud, self-annotated extra run" against "a silent no-op" (the silent kind cost a 10-minute mystery in the field); its stated justification no longer claims cases `realpath` already covers. Also from the review's small items: the unused `fileURLToPath` import in `preview-page.mjs` is gone, the `real` helper is renamed `realpathOf`, and the guard's `!entry` branch is now actually covered (explicit `null` — passing `undefined` hits the default parameter and never reached it).
+- **Tests +1 (257 in fourteen files):** the geometry numbers are pinned (`subLineCap` = 24/33/35 CJK, 55 Latin; reverting to the linear formula turns it red), and the plan-timings case now exercises 2/3/4-line samples so the "three or more" predicate is tested at its exact boundary (the review noted the 1.9.9 title claimed a 3-line case it didn't run).
+
+## 1.9.9 — 2026-09-22
+
+**Cloud-sandbox field batch: entry guard could silently no-op; subtitle wrap warning mis-fired** (report from a mavis-style web sandbox session, sorted by the reporter into skill issues vs environment quirks)
+
+- **🔴 Silent no-op entry guard (the core bug).** `preview-page.mjs`'s "run `main` only when executed directly" check compared `path.resolve(argv[1])` with `fileURLToPath(import.meta.url)` — for an entry reached through a **symlinked path** the module URL is the real path while `argv[1]` is the link path, so the comparison is always false: the script exits 0 with **no output and no work done** (the reporter burned ~10 minutes proving `main` hadn't run). The check is now `tools.isMainModule` (single source): realpath both sides, plus a same-basename fallback so the "silent skip" shape is impossible in practice. Clarified in the same comment: `node -e "import(…)"` does not run `main` by design (import semantics) — run CLIs as `node `;
+ const m = /]*>/i.exec(html);
+ if (!m) return script + html;
+ return html.slice(0, m.index + m[0].length) + '\n' + script + html.slice(m.index + m[0].length);
+}
+
+// ─────────────────────────── 放映页 ───────────────────────────
+
+export function buildPlayPage({
+ topic = '', lang = 'zh', slides = [], cssNote = '', generatedAt = '',
+ narration = true, // 是否加载口播文案 UI(没有 clauses 或 --no-script 时为 false)
+ timing = false, // 是否有对时数据(只影响标题上的"(未对时)"标注)
+ fallbackNote = '', // "还没对时/等间隔预览"的如实说明(与口播 UI 无关, 画面上也要说清)
+ canvas = { w: 1920, h: 1080 }, // 画布尺寸(竖版 1080×1920): 舞台缩放与缩略图比例都按它算
+} = {}) {
+ // UI 双语(实测反馈: 英文项目的放映页整套中文按钮)。lang 来自 script.json(en* → 英文, 其余中文)
+ const T = String(lang).toLowerCase().startsWith('en') ? {
+ title: 'Play', fxOn: 'Motion on', fxOff: 'Motion off', narrOn: 'Narration on', narrOff: 'Narration off',
+ overview: 'Overview', panelH: 'Narration for this slide', untimed: '(untimed)',
+ fxTitle: 'Motion on / off (key X)', narrTitle: 'Narration on / off (key P)',
+ prevTitle: 'Previous level / slide', nextTitle: 'Next level / slide',
+ hintStep: 'reveal level / slide', hintFx: 'motion on / off', hintNarr: 'narration on / off',
+ hintOv: 'overview', hintFs: 'fullscreen', level: 'step',
+ cut: 'Cut', xfade: 'Dissolve', transTitle: 'Slide transition: cut / dissolve (compare, then set script.json transition)', hintTrans: 'slide transition',
+ gen: 'Snapshots generated', genNote: 'same source as the final video (no audio, no subtitles)',
+ noThumbA: 'No thumbnail (preview/', ovH: 'Overview · click any slide to jump (thumbnails from preview/*.png)',
+ } : {
+ title: '放映页', fxOn: '动效开', fxOff: '动效关', narrOn: '口播开', narrOff: '口播关',
+ overview: '总览', panelH: '本张口播文案', untimed: '(未对时)',
+ fxTitle: '动效开 / 动效关(快捷键 X)', narrTitle: '口播开 / 口播关(快捷键 P)',
+ prevTitle: '上一级 / 上一张', nextTitle: '下一级 / 下一张',
+ hintStep: '逐级入场 / 翻页', hintFx: '动效开 / 动效关', hintNarr: '口播开 / 口播关',
+ hintOv: '总览', hintFs: '全屏', level: '级',
+ cut: '硬切', xfade: '溶解', transTitle: '切页方式:硬切 / 溶解(现场对比后写进 script.json 的 transition)', hintTrans: '切页方式',
+ gen: '快照生成于', genNote: '画面与成片同源(无声、无字幕)',
+ noThumbA: '无缩略图(preview/', ovH: '总览 · 点任意一张跳转(缩略图来自 preview/*.png)',
+ };
+ const model = slides.map(s => ({
+ id: s.id, name: s.name, title: s.title ?? '',
+ src: s.copy, nofx: s.copyNofx,
+ steps: (s.steps && s.steps.length) ? s.steps : [1], // 动效开时逐级揭示的 stage 序列
+ clauses: (s.clauses ?? []).map(c => ({ stage: c.stage ?? null, text: c.text ?? '', text2: c.text2 ?? '' })),
+ }));
+ const json = JSON.stringify(model).replace(/⚠ ${esc(cssNote)}
` : '';
+ const notice = fallbackNote ? `${esc(fallbackNote)}
` : '';
+
+ return `
+
+
+
+
+
+${T.title} · ${esc(topic) || 'html2video'} · html2video-for-mcode
+
+
+
+
+
+ ${esc(topic) || T.title}
+ 1 / ${slides.length}
+
+
+ ${notice}
+
+
+
+ ${narration ? `` : ''}
+
+
+
+
+
+
+`;
+}
+
+// ─────────────────────────── 主流程 ───────────────────────────
+
+function main() {
+ const argv = process.argv.slice(2);
+ const dir = positionalDir(argv);
+ const OPEN = argv.includes('--open');
+ const NO_SCRIPT = argv.includes('--no-script'); // 不加载口播文案 UI(只想看 HTML 画面时)
+ process.env.KIT_PROJECT_DIR = dir;
+
+ const scriptPath = path.join(dir, 'script.json');
+ if (!fs.existsSync(scriptPath)) { console.error(`✗ 找不到 ${scriptPath}`); process.exit(1); }
+ const script = JSON.parse(fs.readFileSync(scriptPath, 'utf8'));
+ validateScriptPaths(script, dir); // id/html 派生路径收监(与 capture 同一道门)
+
+ const timingsPath = path.join(dir, 'build', 'timings.json');
+ const hasTimings = fs.existsSync(timingsPath);
+ const timings = hasTimings ? JSON.parse(fs.readFileSync(timingsPath, 'utf8')) : null;
+
+ const slidesDir = path.join(dir, 'slides');
+ const outDir = safeOut(dir, 'preview', 'play');
+ fs.mkdirSync(outDir, { recursive: true });
+
+ // 项目 tokens.css 落后于技能当前版本(缺 no-fx / 图表 / 表格任一段)时给副本兜底注入当前版,
+ // 并在页面与终端如实说明 —— 否则"关动效对照"会把画面锁在入场前的透明态, 旧图表规则也会
+ // 把新版画法(如 .chart-ticks)渲染坏。注入的
+