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..1e3e089f --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "html2video-for-mcode", + "version": "1.9.19", + "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..b00cb86d --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md @@ -0,0 +1,583 @@ +# Changelog + +## 1.9.19 — 2026-10-10 + +**One regression, found by reviewing our own 1.9.18 fix — and a test that structurally could not have caught it.** + +- **`--open` stopped opening the browser on Windows.** The 1.9.18 fix for M1 added `windowsVerbatimArguments` so the target would be quoted explicitly instead of relying on Node's quote-only-when-it-contains-spaces rule. But verbatim mode also stops Node from quoting the **empty-string argument**, so `start`'s empty *title* vanished: `start "" ""` became `start ""`. START treats the first quoted token as the **window title**, so with no command left to run nothing opens — while the script still prints "已在浏览器打开(若没弹出…)". A security fix that broke the feature it was securing. + - The rule was measured, not assumed. With `mkdir` as a marker: `cmd /c start "T" cmd /c ` runs `` (T became the title), while `cmd /c start T cmd /c ` does not (T became a command that does not exist). One quoted token and no command after it therefore opens nothing. + - Fix: the empty title is now a **literal** `'""'`, and the argv construction moved into an exported `openCmdArgv(target, platform)` so it can be asserted directly rather than inferred from a spawn. +- **Why our own M1 test missed it, and what changed.** That test substituted `echo` for `start`. `echo` has no title semantics, so "the quoted path is consumed as a title" was outside anything the test could observe — the proxy proved quoting, not START's parser. The replacement asserts on `openCmdArgv`'s argv, **red-proves** that the pre-fix shape fails the same assertion, and then runs **`start` itself**: the quoted form must create the marker, the unquoted form must not. +- **Housekeeping:** the new behavioural test runs `start /b`. Without `/b` every suite run flashes a console window on the machine running it (measured: 3639 ms and one new conhost with `/b` absent; 134 ms and none with it). + +**Tests +3 → 317 in sixteen files.** Suite re-run before pushing: **317 tests, 313 pass, 0 fail, 4 named capability skips** — all four the file-symlink canaries, skipped with the reason "当前环境建不了文件符号链接(Windows 需开发者模式; ubuntu CI 真跑)" because this Windows machine has Developer Mode off. The directory-junction equivalents of those guards (`safeRel` escape, `L1` image gate) run and pass here via the `mklink /J` fallback, and the render-smoke suite ran for real on this machine rather than skipping. + +## 1.9.18 — 2026-10-10 + +**Independent security audit of the "parse and generate" dimension — the one the hosted review rounds never covered.** The auditor was barred from reading the earlier review records so as not to anchor; it reported 2 HIGH / 4 MEDIUM / 6 LOW. Every finding was reproduced on our own tree before acting, three of its claims did not survive that, and what it confirmed is fixed here. + +**HIGH — fixed** + +- **H1 · attribute injection in the play page (`preview-page.mjs`).** `setAttr` re-serialised a value it had just read out of a **single-quoted** attribute into double quotes **without escaping**, so an author's `` came back from `addNoFx` with a live event handler — and `X` (the animation toggle the docs push) is the trigger. Reproduced by calling the real function. Values are now HTML-escaped on the way out (`&` `"` `<` `>`); the same applies to `injectHtmlVars`'s `style` path. The regression test **parses attribute names** instead of searching for `on*=` text — after the fix the text legitimately lives inside the attribute value, so a text search would report a false red — and it carries a detector self-check (the unescaped shape must be recognised) so the pair cannot be green for free. +- **H2 · SRT cue injection (`build-video.mjs`).** `clause` text was spliced into `out/subs.srt` verbatim, so a clause containing `\n\n99\n00:59:00,000 --> 01:00:00,000\n…` produced **more timecodes than cues** — forged cues in a file that gets uploaded to a platform. SRT generation moved to a single source, `tools.buildSrt`, which sanitises each line: bidi and C0/C1 control characters stripped, all whitespace runs folded to one space (so a cue is always one line), a residual inline `-->` turned into `→` because lenient parsers — ffmpeg's srt demuxer scans lines for timecodes — would otherwise still mis-read it. The invariant asserted is *line-anchored*: line-anchored timecode lines === cue count. + +**MEDIUM — fixed** + +- **M1 · `--open` could run a second command.** `spawn('cmd', ['/c','start','',target])` relied on Node quoting the target, and Node only quotes when the argument contains spaces or quotes — a Windows path may contain `&` without a space. Reproduced with a controlled origin: the second command really executed. The target is now quoted explicitly with `windowsVerbatimArguments`. The test runs both shapes and requires the unquoted one to still be exploitable, so it cannot pass for the wrong reason. +- **M2 · `fx-*` classes were trusted by prefix.** `class="fx-notreal"` satisfied the data-stage rule while nothing animates, and a locally defined `.fx-mine` with a keyframe lacking `opacity` was never inspected (only `tokens.css` classes were). Both now check the slide's own ` + + +
+ A · ${topicHtml || '主题名'} +
+

SECTION · 小节名

+

一句话断言,关键词点亮。

+

一两句展开: 补充语境或给出解释, 别只有标题。

+
+ +
+
9亿
+
周活跃用户
+
+
+
+ 02 / 08 +
+ + +`; + +const scriptJson = { + _readme: 'html2video-for-mcode 脚本契约。clauses 每个元素=一句口播; stage=这句开口时该入场的视觉层(1/2/3); 可选 text2=双语字幕第二行。tail=收尾留白秒数(默认0.8)。改口播必须过 Gate 1, 之后从 Phase 2 重跑。可选顶层 bgm: "assets/bgm.mp3" 或 {file,volume:0.12,fadeIn:1.5,fadeOut:2.5}。width/height 决定画布: 1920×1080 横屏 / 1080×1920 竖屏。lang 决定口播语种与音色: zh(默认)/en/yue/其他 BCP-47 — 语种必须与音色匹配, 且影响语速基准与字幕行宽校验。speed=试听时与用户定的语速(Gate 4 可复核); transition=切页方式: cut 硬切(默认, 段间不经过黑场) 或 {type:"xfade",duration:0.4} 交叉溶解。', + topic, + lang: "zh", + voice: 'Chinese (Mandarin)_Gentleman', + speed: { default: 1.1, first: 1.05, last: 1.05 }, + transition: { type: 'cut' }, + fps: 30, width: 1920, height: 1080, + slides: [ + { id: '01', layout: 'title-hero', html: '01-title.html', audio: '01.mp3', title: '', clauses: [{ stage: 1, text: '' }] }, + { id: '02', layout: 'statement', html: '02-statement.html', audio: '02.mp3', title: '', clauses: [{ stage: 1, text: '' }, { stage: 2, text: '' }] }, + { id: '03', layout: 'bullets', html: '03-bullets.html', audio: '03.mp3', title: '', clauses: [{ stage: 1, text: '' }, { stage: 2, text: '' }] }, + { id: '04', layout: 'compare', html: '04-compare.html', audio: '04.mp3', title: '', clauses: [{ stage: 1, text: '' }, { stage: 2, text: '' }] }, + { id: '05', layout: 'data-viz', html: '05-data.html', audio: '05.mp3', title: '', clauses: [{ stage: 1, text: '' }, { stage: 2, text: '' }] }, + { id: '06', layout: 'code', html: '06-code.html', audio: '06.mp3', title: '', clauses: [{ stage: 1, text: '' }, { stage: 2, text: '' }] }, + { id: '07', layout: 'quote', html: '07-quote.html', audio: '07.mp3', title: '', clauses: [{ stage: 1, text: '' }, { stage: 2, text: '' }] }, + { id: '08', layout: 'closing', html: '08-closing.html', audio: '08.mp3', title: '', clauses: [{ stage: 1, text: '' }] }, + ], +}; + +// 覆盖保护(必须在建目录之前检查, 否则会把自建的空子目录当成"非空"): 目标目录已存在且 +// 非空 → 拒绝。init 会重置 script.json / notes.md 等 5 个文件, 误跑到已开工的项目上会毁掉 +// 全部进度, 所以必须显式 --force。 +const FORCE = argv.includes('--force'); +const GENERATED = ['script.json', 'slides/tokens.css', 'slides/_template.html', 'assets/MANIFEST.md', 'research/notes.md']; + +// --upgrade-css / --check-css: 只管 tokens.css 里三个工具箱受管块的新旧, 不生成/不重置任何文件。 +// 老项目缺 no-fx 规则时 会静默失效(只关动画不把 opacity 抬回来, 页面反而空白); +// 缺图表/表格工具箱时新配方会静默半死(条形不生长 / 环形不扫出 / 数字不滚动)。 +// 判定按内容 rev(见 css-kit.mjs), 不再是"文件里出现过标记字符串就算有" —— 那种判定在项目补过 +// 一次后就永远报"无需升级", 源头后续改动再也传不下去(2026-09-18 排查定案的根因)。 +if (argv.includes('--upgrade-css') || argv.includes('--check-css')) { + // 落盘收监(与 capture/build-video 同一道): slides/ 这一段**本身**可能是指向项目外的符号链接, + // 那样 tokens.css(以及 .bak)就会写到项目外, 而命令照报成功(2026-09-18 复查 B4) + const cssPath = safeOut(dir, 'slides', 'tokens.css'); + if (!fs.existsSync(cssPath)) { console.error(`✗ 找不到 ${cssPath}`); process.exit(1); } + const css = fs.readFileSync(cssPath, 'utf8'); + if (css.length > MAX_SCAN_BYTES) { console.error(`✗ tokens.css 有 ${Math.round(css.length / 1e4) / 100}MB, 超过 ${MAX_SCAN_BYTES / 1e6}MB 上限拒绝扫描(正常项目 ≈15KB; 构造的超大输入会让受管块定位二次方变慢)`); process.exit(1); } + + if (argv.includes('--check-css')) { + const st = kitStatuses(css, { expected: { text: generateTokensCss(), rev: TOKENS_REV } }); + const bad = st.filter(s => s.status !== 'ok'); + if (!bad.length) { + console.log(`✓ tokens.css 已是最新(tokens rev ${TOKENS_REV.slice(0, 8)} / kit rev ${KIT_REV.slice(0, 8)})`); + // ok 但带说明的项也打出来: "老格式(裸文本)"、"区外有你自己的覆写"这类信息不影响渲染, + // 但决定了下次升级会不会动你的文件; 藏起来只会让人以为工具没看这块(2026-09-18 复查)。 + for (const s of st.filter(x => x.detail)) console.log(` 提示 ${s.label}: ${s.detail}`); + process.exit(0); + } + for (const s of bad) console.error(`✗ ${s.label}: [${s.status}] ${s.detail}`); + console.error(' → node scripts/init-project.mjs <项目目录> --upgrade-css(原地替换受管区, 不动区外的规则)'); + process.exit(1); + } + + const { css: next, actions, normalizedLineEndings } = applyKitUpgrade(css, { tokensText: generateTokensCss(), tokensRev: TOKENS_REV }); + if (!actions.length) { console.log(`无需升级: ${cssPath} 已是最新(tokens rev ${TOKENS_REV.slice(0, 8)})`); process.exit(0); } + const bakPath = safeOut(dir, 'slides', 'tokens.css.bak'); // .bak 也要收监: 它是同一个目录段下的叶子文件 + if (fs.existsSync(bakPath)) console.warn('⚠ 已存在 tokens.css.bak, 将被本次升级前的备份覆盖(旧备份会丢, 需要留就先改名)'); + fs.writeFileSync(bakPath, css); // 覆盖前备份: 升级只应动受管块, 万一不对可整文件回退 + fs.writeFileSync(cssPath, next); + for (const a of actions) console.log(`✓ ${a.label}: ${a.reason}`); + if (normalizedLineEndings) console.log(' 行尾已统一为 LF(与 rev 哈希同一标准; CSS 语义不变)'); + console.log(` 备份 → ${cssPath}.bak;受管块之外的内容(含你的覆写)未动`); + // 升级只改 tokens.css —— 已渲染产物里还是旧 CSS 的画面, 必须点名要重跑什么(流程审计 P3) + console.log(' ⚠ 已生成的 preview/*.png 与 build/frames 仍是旧 CSS 画面: 受影响张重跑 capture(--mode still + --mode motion), 放映页重跑 preview-page.mjs, 再 build-video'); + process.exit(0); +} + +if (fs.existsSync(dir)) { + const existing = fs.readdirSync(dir).filter(e => !GENERATED.includes(e)); + const wouldOverwrite = GENERATED.filter(f => fs.existsSync(path.join(dir, f))); + if ((existing.length || wouldOverwrite.length) && !FORCE) { + console.error(`✗ 目标目录已存在且非空: ${dir}\n init 会重置这些文件(其余不动): ${GENERATED.join(' · ')}`); + if (wouldOverwrite.length) console.error(` 其中已存在、将被覆盖的: ${wouldOverwrite.join(' · ')}`); + console.error(' 确认要重新初始化请加 --force'); + process.exit(1); + } + if (FORCE && wouldOverwrite.length) console.warn(`⚠ --force: 将覆盖 ${wouldOverwrite.length} 个生成文件(其余内容不动): ${wouldOverwrite.join(' · ')}`); +} + +// 先把项目根建出来(它就是本次要创建的东西), 之后的每个派生写点才有一个"真实存在"的根可比对: +// safeOut 比对的是"最深已存在祖先的 realpath 是否仍在根内", 根不存在时会拿父目录去比 → 全部误判越界。 +// 有了根之后, 骨架写点与 --upgrade-css 走同一道收监: --force 重建一个 slides 是指向项目外的 +// junction 的目录时, 不会再把生成物写到项目外(2026-09-18 复查: 这条此前只有升级路径收监了)。 +fs.mkdirSync(dir, { recursive: true }); +for (const d of ['research', 'assets', 'slides', 'audio', 'preview', 'out', 'build', 'asr']) { + fs.mkdirSync(safeOut(dir, d), { recursive: true }); +} +fs.writeFileSync(safeOut(dir, 'slides', 'tokens.css'), + '/* 生成物: 下面的 tokens 受管区由 init-project.mjs 生成, 升级走 --upgrade-css 原地替换;\n' + + ' 你自己的规则写到受管区之外(文件末尾), 升级不会碰 */\n' + + wrapTokens(generateTokensCss(), TOKENS_REV) + '\n'); +fs.writeFileSync(safeOut(dir, 'slides', '_template.html'), TEMPLATE_HTML); +fs.writeFileSync(safeOut(dir, 'script.json'), JSON.stringify(scriptJson, null, 2) + '\n'); +fs.writeFileSync(safeOut(dir, 'assets', 'MANIFEST.md'), + `# 素材清单\n\n| 文件 | 内容 | 来源 | 许可 |\n|---|---|---|---|\n\n\n`); +fs.writeFileSync(safeOut(dir, 'research', 'notes.md'), + `# 调研笔记${topic ? ` · ${topic}` : ''}\n\n## 核心事实\n\n| 事实 | 数值/表述 | 来源(URL/文档) | 口径日期 | 等级(一手/二手/弱) | 第二来源 |\n|---|---|---|---|---|---|\n\n## 不确定项(不进脚本)\n\n## 不该进脚本的内容(传闻/争议/无法核实)\n\n`); + +console.log(`已生成项目骨架: ${dir}`); +console.log(` +下一步: +1. 填 research/notes.md(事实性题材必须先搜集, 过 Gate 0) +2. 逐张填 script.json 的 clauses(内容量规则见 references/authoring.md, 过 Gate 1) +3. 做 TTS 到 audio/.mp3, 然后: + node scripts/plan-timings.mjs "${dir.replace(/\\/g, '/')}" +4. 每张 HTML 参照 slides/_template.html 写到 slides/(主题见 tokens.css 顶部注释), 然后: + node scripts/check-theme.mjs "${dir.replace(/\\/g, '/')}" # 主题对比度校验 + node scripts/capture.mjs "${dir.replace(/\\/g, '/')}" --mode still # Gate 4 终态预览 + node scripts/capture.mjs "${dir.replace(/\\/g, '/')}" --mode motion # 动画帧 + node scripts/build-video.mjs "${dir.replace(/\\/g, '/')}" --asr`); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/limits.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/limits.mjs new file mode 100644 index 00000000..e4275788 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/limits.mjs @@ -0,0 +1,11 @@ +// html2video-for-mcode · 扫描上限(单一来源) +// +// 为什么单独一个文件: 这个上限对 **tokens.css 与 slide HTML 都生效**(check-slides / preview-page / +// capture 两侧都过同一道门), 但它原先住在 css-kit.mjs 里 —— 那是"受管块扫描"引入的, 于是 HTML 侧的 +// 调用点看起来像在依赖 CSS 模块。搬到 tools.mjs 会成环(tools 已经 import css-kit 取 kitStatuses), +// 所以放在这个谁都能 import 的叶子模块里。 +// +// 数值的理由: 受管块定位的正则对"无闭合定界符 × N"的构造输入是二次方复杂度 —— 2026-09-18 审计实测 +// 4MB 恶意 tokens.css 会让 --check-css 跑 39s。正常 tokens.css ≈15KB、单张 slide ≈10KB, +// 2MB 只可能是构造出来的, 所以入口直接拒绝扫描并说明原因。 +export const MAX_SCAN_BYTES = 2_000_000; diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/nofx-css.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/nofx-css.mjs new file mode 100644 index 00000000..b6808a47 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/nofx-css.mjs @@ -0,0 +1,19 @@ +// html2video-for-mcode · no-fx 规则的唯一来源。 +// init-project 把它写进 tokens.css; preview-page 在"老项目 tokens.css 里没有这段"时兜底注入, +// 否则放映页的"关动效对照"会把画面锁在入场前的透明态(看起来像整片空白)。 +// 两处共用一份文本, 避免 CSS 漂移。 + +export const NOFX_CSS = `/* 一键关全部动效: 或 .stage.no-fx。 + 注意必须同时把 [data-stage] 的基础态 opacity:0 拉回来 —— 入场效果靠 animation 的 both + 填充从 0 拉到 1, 只关动画不管基础态 = 元素全部隐形(2026-09-18 实测踩过)。 + 关掉后 motion 捕获自动退化为静态帧, 成片照常出; 字幕(.kit-sub)不受影响。 */ +.no-fx [class*="fx-"], .no-fx .fx-stagger > *, .no-fx .fx-shimmer::after { animation: none !important; } +.no-fx [data-stage], .no-fx .fx-stagger > * { + opacity: 1 !important; transform: none !important; filter: none !important; clip-path: none !important; +} +.no-fx .fx-draw { stroke-dasharray: none !important; stroke-dashoffset: 0 !important; }`; + +// 判定标准落在"承重"的那条规则上: 只关动画、没把 opacity 抬回来的旧版一律算没有。 +export function hasNofxRules(css) { + return /\.no-fx\s+\[data-stage\][^{]*\{[^}]*opacity\s*:\s*1\s*!important/.test(String(css ?? '')); +} diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/plan-timings.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/plan-timings.mjs new file mode 100644 index 00000000..7de1b88d --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/plan-timings.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// html2video-for-mcode · 实测对时: script.json + audio/*.mp3 → build/timings.json +// 这是全流水线时长的唯一事实来源。用法: node plan-timings.mjs <项目目录> [--pacing=<属性值>] +// 语种由 script.json 的 lang 决定(zh 默认 / en / yue / 其他 BCP-47), 影响语速基准与字幕行宽阈值。 +// timings.json 每个 slide 含 clauses[]: 每句口播的估算开口时刻/时长, 供字幕、ASR 按句切分、对时校准共用。 +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { positionalDir, probeDuration, requireTool, safeId, safeOut, safeRel, subLineCap, validateScriptPaths } from './tools.mjs'; + +// 语种相关的计量基准。中文按"字", 英文按"字符"(含词间节奏, 与音节时长大致成正比)。 +// pacing = 每单位每秒的常见语速; emPer = 单字宽按 em 折算(行宽估算用 —— 行宽公式在 +// tools.subLineCap, 字幕胶囊几何的唯一来源也在 tools.SUB_GEOMETRY); pace 区间用于语速异常预警。 +// yearGate = 年份逐位读的闸门只对中/粤语面开(第 24 轮: 用 emPer===1 充当语族判别是排版 +// 属性代理语义, 未来 emPer:1 的语种会误触); 未知语种 fallback 不开闸。 +const ZH_BASE = { unit: '字', pacing: 4.8, paceMin: 3, paceMax: 6.5, emPer: 1, word: null, yearGate: true }; +const LANG_CFG = { + zh: ZH_BASE, + yue: ZH_BASE, + en: { unit: '字符', pacing: 14, paceMin: 9, paceMax: 18, emPer: 0.44, word: 'words', yearGate: false }, +}; +const langCfg = code => LANG_CFG[code] ?? { unit: '字符', pacing: 14, paceMin: 8, paceMax: 20, emPer: 0.44, word: null, yearGate: false }; + +const argv = process.argv.slice(2); +const dir = positionalDir(argv); +const LEAD = 0.2; // 视觉比语音提前出现秒数(广播惯例, 观感同步) + +process.env.KIT_PROJECT_DIR = dir; +const FFPROBE = requireTool('ffprobe', 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); // script.json 是 agent 可编辑文件: id/audio 派生路径先收监 +const fps = script.fps ?? 30; +const LANG = script.lang ?? 'zh'; +const CFG = langCfg(LANG); +const pacingArg = argv.find(a => a.startsWith('--pacing=')); +const pacing = pacingArg ? parseFloat(pacingArg.slice(9)) : CFG.pacing; +// 试听时与用户定的语速(1.6.0): script.speed 此前是纯声明、没有任何脚本读它 → "语速偏慢"只能靠人耳发现。 +// 现在拿它当期望值: 实测语速(字数÷实测音频时长)偏离 期望基准×speed 超过 20% 就告警, 越界也告警。 +const SPEED = (() => { + const v = script.speed; + const n = Number(typeof v === 'object' && v !== null ? v.default : v); + return Number.isFinite(n) && n > 0 ? n : 1.0; +})(); + +const probeDur = file => probeDuration(FFPROBE, file); // 探测收进 tools.mjs 单一实现(第十三轮 review 去重) +const charCount = s => String(s ?? '').replace(/\s+/g, '').length; +const r3 = x => Math.round(x * 1000) / 1000; + +const rows = []; +const warns = []; +for (const s of script.slides) { + const audioPath = safeRel(path.join(dir, 'audio'), s.audio ?? `${safeId(s.id)}.mp3`, { where: `slides[${s.id}].audio` }); + if (!fs.existsSync(audioPath)) { console.error(`✗ 缺音频 ${audioPath} — 先完成 Phase 2 TTS`); process.exit(1); } + const tts = probeDur(audioPath); + if (tts == null) { console.error(`✗ ffprobe 读不出时长: ${audioPath}`); process.exit(1); } + + // 时间权重按**发音形态**(say ?? text)算 —— 音频念的是 say(第 24 轮: "82.3%"显示 5 字、 + // 发音"百分之八十二点三"8 字, 按显示形态分摊会把长 say 句的开口系统性估早, 字幕窗与 + // ASR 切分跟着偏)。字幕行宽(下方 wrap 检查)仍按显示形态 text —— 胶囊几何是屏幕上的事。 + const spokenLen = c => charCount(c.say ?? c.text); + const total = s.clauses.reduce((n, c) => n + spokenLen(c), 0); + if (total === 0) warns.push(`${s.id}: clauses 为空, 将整张静止`); + + // 第 k 句开口时刻 ≈ 实测时长 × (前 k-1 句字数占比); stage 取该层最早一句, 再提前 LEAD + const clauses = []; + const stageTime = {}; + let cum = 0; + for (const c of s.clauses) { + const start = total > 0 ? (tts * cum) / total : 0; + // say = 发音形态(只喂 TTS/ASR), text = 显示形态(字幕/画面) —— 原样传递给下游; chars = 发音字数(计时簿记) + clauses.push({ stage: c.stage ?? null, start: r3(start), chars: spokenLen(c), text: c.text, ...(c.text2 ? { text2: c.text2 } : {}), ...(c.say ? { say: c.say } : {}) }); + const t = Math.max(0, start - LEAD); + if (c.stage != null) stageTime[c.stage] = c.stage in stageTime ? Math.min(stageTime[c.stage], t) : t; + cum += spokenLen(c); + } + clauses.forEach((c, i) => { c.dur = r3((clauses[i + 1]?.start ?? tts) - c.start); }); + Object.assign(stageTime, s.stageTimes ?? {}); // 显式 stageTimes 覆盖优先 + + const tail = s.tail ?? 0.8; + const duration = Math.ceil((tts + tail) * fps) / fps; // 对齐帧网格 + const wps = tts > 0 ? total / tts : 0; + + if (wps > 0 && (wps < CFG.paceMin || wps > CFG.paceMax)) warns.push(`${s.id}: 语速 ${wps.toFixed(1)} ${CFG.unit}/s (${LANG} 常见 ${CFG.paceMin}–${CFG.paceMax}) — 检查 speed 或字数, 或用 --pacing 重估`); + // 与 script.speed 对账(1.6.0): 期望 = 基准 × speed; 实测偏离 >20% 说明该段 TTS 没用这个 speed + const expect = CFG.pacing * SPEED; + if (wps > 0 && Math.abs(wps - expect) > expect * 0.2) { + warns.push(`${s.id}: 实测语速 ${wps.toFixed(1)} ${CFG.unit}/s 与 script.speed=${SPEED} 的期望 ${expect.toFixed(1)} 差 ${(Math.abs(wps - expect) / expect * 100).toFixed(0)}% — 该段 TTS 可能没用这个 speed(试听定的是 ${SPEED}), 或字数估算错了; 复核后重做该段 TTS 或改 script.speed`); + } + if (duration > 15) warns.push(`${s.id}: ${duration.toFixed(1)}s 超过 15s — 建议拆成两张`); + const stages = Object.keys(stageTime).map(Number); + const last = stages.length ? Math.max(...stages) : 0; + if (last > 0 && duration - (stageTime[last] ?? 0) < 1.2) warns.push(`${s.id}: 最后一个 stage 在 ${stageTime[last].toFixed(1)}s, 距收尾不足 1.2s — 观众看不清, 建议 tail 加大或精简口播`); + // 字幕行宽来自 tools.subLineCap(胶囊几何唯一来源; 缩放带钳位, 按宽度线性外推是错的 —— + // 2026-09-23 复核: 中文 1080≈24 字/行、1920≈33、2560≈35)。折 2 行可读, 折 3 行起才警告。 + const subCap = subLineCap(script.width ?? 1920, CFG.emPer); + for (const c of clauses) { + const n = charCount(c.text); + const lines = Math.ceil(n / subCap); + if (lines >= 3) warns.push(`${s.id} 第 ${clauses.indexOf(c) + 1} 句 ${n} ${CFG.unit}(行宽 ≈${subCap} ${CFG.unit}/行) 会折 ${lines} 行 — 3 行起可读性差, 建议拆句(≤2 行可接受)`); + if (c.text2 && c.text2.length > 60) warns.push(`${s.id} 第 ${clauses.indexOf(c) + 1} 句双语第二行 ${c.text2.length} 字符 > 60 — 建议精简译文`); + // 年份读法闸门(2026-09-28 用户实测: TTS 把 2026年 念成"两千零二十六年"): 显示与发音 + // 分离 —— text 保持 2026年 上字幕, 加 say:'二零二六年' 控制发音。判别只钉无歧义的 + // "四位数字+年"(任意年份都逐位读, 不止 19xx/20xx —— 第 24 轮: 旧正则漏 1897年 这类; + // 整读/逐位读是意图问题, 2026点 就该整读 —— 其余数字写法走文档纪律)。 + if (CFG.yearGate) { + const spoken = c.say ?? c.text; + const ym = /(? '零一二三四五六七八九'[+d]).join(''); + warns.push(`${s.id} 第 ${clauses.indexOf(c) + 1} 句 "${ym[0]}" 会被 TTS 按整数读(2026年→"两千零二十六年") — 年份按播报惯例逐位读: text 原样上字幕, 加 say:"${digitsRead}年…"`); + } + } + } + + rows.push({ + id: s.id, tts: r3(tts), duration: r3(duration), + chars: total, wps: +wps.toFixed(2), stages: stageTime, clauses, + script: s.clauses.map(c => c.text).join(''), + }); +} + +const totalDur = rows.reduce((n, r) => n + r.duration, 0); +fs.mkdirSync(safeOut(dir, 'build'), { recursive: true }); +fs.writeFileSync(safeOut(dir, 'build', 'timings.json'), + JSON.stringify({ fps, lang: LANG, pacing, lead: LEAD, total: r3(totalDur), slides: rows }, null, 2) + '\n'); + +const unitLabel = `量(${CFG.unit})`; +console.table(rows.map(({ id, tts, duration, chars, wps, stages }) => + ({ id, 'TTS(s)': tts, '成片(s)': duration, [unitLabel]: chars, [`${CFG.unit}/s`]: wps, 'stage时刻': JSON.stringify(stages) }))); +console.log(`总时长: ${totalDur.toFixed(1)}s · 语言 ${LANG}(${CFG.unit}基准 ${pacing}/${CFG.unit}·s⁻¹ · script.speed=${SPEED}) → build/timings.json`); +if (SPEED < 0.8 || SPEED > 1.4) warns.unshift(`script.speed = ${SPEED} 超出常规区间 0.8–1.4 — 确认是不是写错(或 TTS 调用与它不一致)`); +if (warns.length) { console.warn('\n⚠ 警告:'); for (const w of warns) console.warn(' - ' + w); } +console.log('\n下一步可选: node scripts/check-timing.mjs <项目目录> 用静音检测实测每句开口时刻, 对比/校准估算。'); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/prep-image.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/prep-image.mjs new file mode 100644 index 00000000..467f60d6 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/prep-image.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// html2video-for-mcode · 配图准备工具(选图 SOP 的执行辅助) — 只用 ffprobe/ffmpeg, 不依赖 Python +// 用法: +// node prep-image.mjs --check <图1> [图2 ...] 看尺寸/比例/裁切风险, 给人做取舍 +// node prep-image.mjs --crop [--ratio 16:9] [--anchor bottom|top|center] +// 裁切硬限制(与 image-sources.md 的 SOP 一致): 主体必须完整可见、裁掉面积 ≤20%、输出严格目标比例。 +// 优先靠换图/换版式解决, 本工具只是最后的兜底手段。 +// 边界契约(1.7.5 复查④): --crop 的 in/out 都是**命令行显式给出**的路径(与你直接跑 ffmpeg 同一 +// 信任级), 不在"项目 + 派生路径"的收监范围内; 覆盖已存在 out 仍需 --force。此为 README 声明过的唯一例外。 +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { flagValue, positionals, probeSize, requireTool } from './tools.mjs'; + +const argv = process.argv.slice(2); +// --check / --crop 是布尔开关, 它们后面那串文件名本来就是位置参数(所以不在 tools.VALUE_FLAGS 里) +const positional = positionals(argv); + +// 按当前工作目录找项目内的 ffmpeg-static / ffprobe-static(二审 P2: README 明说支持装在视频项目里) +const FFMPEG = requireTool('ffmpeg', process.cwd()); +const FFPROBE = requireTool('ffprobe', process.cwd()); +const win = { encoding: 'utf8', windowsHide: true }; + +function probe(file) { + const s = probeSize(FFPROBE, file); // 探测收进 tools.mjs 单一实现(第十三轮 review 去重) + return s ? { w: s[0], h: s[1] } : null; +} +const gcd = (a, b) => (b ? gcd(b, a % b) : a); +const ratioLabel = (w, h) => { const g = gcd(w, h); return `${w / g}:${h / g} (${(w / h).toFixed(2)})`; }; + +if (argv.includes('--check') || (!argv.includes('--crop') && positional.length)) { + const files = positional; + if (!files.length) { console.error('用法: node prep-image.mjs --check <图1> [图2 ...]'); process.exit(1); } + let risk = 0; + for (const f of files) { + if (!fs.existsSync(f)) { console.error(`✗ 不存在: ${f}`); process.exitCode = 1; continue; } + const s = probe(f); + if (!s) { console.error(`✗ 读不出尺寸: ${f}`); process.exitCode = 1; continue; } + const r = s.w / s.h; + const ratio = 16 / 9; + const notes = []; + if (Math.abs(r - ratio) < 0.06) notes.push('比例≈16:9, 可直接套 .img-frame 默认框'); + else if (r > ratio) { + // 比 16:9 更宽 → 裁宽度 + notes.push(`偏宽: 保持高度裁宽度 ${(100 * (1 - (s.h * ratio) / s.w)).toFixed(0)}% 可到 16:9`); + } else { + // 比 16:9 更高(窄) → 裁高度 + notes.push(`偏高(窄): 保持宽度裁高度 ${(100 * (1 - (s.w / ratio) / s.h)).toFixed(0)}% 可到 16:9`); + } + if (s.w < 1200) notes.push('分辨率 <1200px, 上屏会软 — 建议换更大的图'); + if (Math.abs(r - ratio) >= 0.06) { notes.push('⚠ 主体若在边缘, 先换图(搜「全景/全貌/正面/远景」), 不要硬裁'); risk++; } + console.log(`${path.basename(f)}\n 尺寸 ${s.w}×${s.h} · 比例 ${ratioLabel(s.w, s.h)}\n ${notes.join('\n ')}`); + console.log(' 下一步: 肉眼确认主体位置 → 主体在边缘就换图; 居中可小裁, 或改用 .img-frame.contain 留白\n'); + } + if (risk) console.log(`提示: ${risk} 张存在裁切风险 —— 按 references/image-sources.md 的 SOP 优先重搜素材。`); + process.exit(process.exitCode ?? 0); +} + +if (argv.includes('--crop')) { + const [src, dst] = positional; + if (!src || !dst) { console.error('用法: node prep-image.mjs --crop [--ratio 16:9] [--anchor bottom|top|center] [--force]'); process.exit(1); } + const FORCE = argv.includes('--force'); + if (fs.existsSync(dst) && !FORCE) { console.error(`✗ 输出已存在, 不覆盖: ${dst}(要覆盖加 --force)`); process.exit(1); } + const [rw, rh] = (flagValue(argv, '--ratio', '16:9')).split(':').map(Number); + const anchor = flagValue(argv, '--anchor', 'center'); + if (!rw || !rh) { console.error('✗ --ratio 形如 16:9'); process.exit(1); } + const s = probe(src); + if (!s) { console.error(`✗ 读不出尺寸: ${src}`); process.exit(1); } + const target = rw / rh, cur = s.w / s.h; + + // 目标裁切框: 比目标更宽 → 裁宽; 比目标更高(窄) → 裁高。绝不放大补边。 + let cw, ch, x, y; + if (cur > target) { ch = s.h; cw = Math.round(s.h * target); } + else { cw = s.w; ch = Math.round(s.w / target); } + if (cw > s.w || ch > s.h) { console.error('✗ 该比例需要放大补边, 裁不出来 —— 改用 .img-frame.contain 留白'); process.exit(1); } + const cutFrac = 1 - (cw * ch) / (s.w * s.h); + if (cutFrac > 0.2) { + console.error(`✗ 需裁掉 ${(cutFrac * 100).toFixed(0)}% 面积(硬限制 ≤20%)——按 SOP 先换图或用 .img-frame.contain 留白, 不要硬裁`); + process.exit(1); + } + x = Math.round((s.w - cw) / 2); + y = anchor === 'top' ? 0 : anchor === 'bottom' ? s.h - ch : Math.round((s.h - ch) / 2); + const r = spawnSync(FFMPEG, ['-y', '-v', 'error', '-i', src, '-vf', `crop=${cw}:${ch}:${x}:${y}`, dst], win); + if (r.status !== 0) { console.error('✗ 裁切失败:\n' + (r.stderr || '')); process.exit(1); } + const out = probe(dst); + console.log(`✓ ${path.basename(dst)} ${s.w}×${s.h} → ${out.w}×${out.h} (${ratioLabel(out.w, out.h)}), 裁掉 ${(cutFrac * 100).toFixed(1)}% 面积, 锚点 ${anchor}`); + console.log(' 裁完必须肉眼核对主体完整可见 —— 宁可改用 .img-frame + --img-pos, 也不要裁到主体。'); + process.exit(0); +} + +console.error('用法:\n node prep-image.mjs --check <图...>\n node prep-image.mjs --crop [--ratio 16:9] [--anchor bottom|top|center]'); +process.exit(1); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/preview-page.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/preview-page.mjs new file mode 100644 index 00000000..4928c96b --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/preview-page.mjs @@ -0,0 +1,728 @@ +#!/usr/bin/env node +// html2video-for-mcode · 放映页: preview/play/index.html + 逐张"真实时序"快照。 +// 用法: node preview-page.mjs <项目目录> [--open] [--no-script] +// +// 定位: **用浏览器把 HTML 画面放一遍**。只做四件事 —— 翻页(动效开 = 逐级入场)、动效开/关对照、口播开/关、总览; +// 口播文案是锦上添花(可关), 不做计时器/进度条/播放器那套 UI(要看时间就直接看成片)。 +// 换帧用双缓冲 iframe: 新帧在隐藏帧里加载完才对调显示, 不闪白; UI 文案随 script.lang 中英双语。 +// +// 为什么不是"直接双击 slides/*.html": 动画延迟(--t1/--t2/--t3)与画布尺寸是渲染管线按 +// timings.json 注入的(tokens.css 里只有占位默认值 --t2:800ms), 直接打开看到的是"所有动画 +// 挤在 2 秒内"的假象。本脚本把注入值原样写进副本的 , 于是浏览器里的播放时序 +// = 成片时序, 且副本带 指回 slides/, 主题与素材照常解析。 +// 另出一份加了 no-fx 的副本, 页面按 X 即可对照"关掉动效后画面是否还完整" —— 这是 +// "元素永久不可见 / 关动效反而更空"那类静默故障的 5 秒自检, 不必等 3–6 分钟的 motion 编码。 +// +// 口播 UI 按数据决定加不加载: 没有 clauses → 完全不出; 有 clauses没对时 → 只列文案; +// --no-script 强制不出。布局随窗口自适应(窄窗口/手机口播面板收成底部抽屉, 触屏可左右滑动翻页)。 +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { expectedTokens, isMainModule, positionalDir, safeId, safeOut, safeRel, validateScriptPaths } from './tools.mjs'; +import { NOFX_CSS } from './nofx-css.mjs'; +import { KITS, TOKENS_ID, findAllBlocks, kitStatuses, MAX_SCAN_BYTES } from './css-kit.mjs'; +import { generateTokensCss } from './tokens-template.mjs'; + +const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + +// ─────────────────────────── 纯函数(供 tests/ 单测) ─────────────────────────── + +// 与 capture.mjs 的注入保持同一语义: 秒 → 整数毫秒, 负数归零。属性名 --t。 +export function stageVars(stages = {}, { w, h } = {}) { + const out = []; + for (const k of Object.keys(stages).sort()) { + const v = Number(stages[k]); + if (!Number.isFinite(v)) continue; + out.push(`--t${Number(k)}:${Math.max(0, Math.round(v * 1000))}ms`); + } + if (Number.isFinite(w)) out.push(`--stage-w:${w}px`); + if (Number.isFinite(h)) out.push(`--stage-h:${h}px`); + return out.join(';'); +} + +// 从 slide HTML 里读出用到哪些 stage —— 用于"还没对时"时的等间隔兜底 +export function stagesFromHtml(html) { + const ids = new Set(); + for (const m of String(html ?? '').matchAll(/\bdata-stage\s*=\s*["'](\d+)["']/g)) ids.add(Number(m[1])); + return [...ids].sort((a, b) => a - b); +} + +// 没有 timings.json(口播还没做)时, 按 0.3s 起步、每层 +1s 错开, 让人至少看清入场顺序; +// 页面会明确标注"不是成片时序"。tokens.css 的占位值会把所有动画挤在 2 秒内, 更看不懂。 +export function fallbackStages(html, { start = 0.3, step = 1 } = {}) { + const out = {}; + stagesFromHtml(html).forEach((n, idx) => { out[n] = start + idx * step; }); + return out; +} + +// 属性匹配要认双引号/单引号/无引号三种写法(二审 P2): 旧实现只认双引号, 遇到 +// 会再插一组重复属性, 浏览器保留**先出现的那组** → +// 实测延迟/画布尺寸全部失效、no-fx 也加不上(实测: 原延迟仍在、没有画布宽、动效关不掉) +// 前置不能紧跟 `-`/字母数字: 否则 data-style / data-class 这类属性名里含 style/class 的, +// 会被误认成真属性(于是真 style 没被改、画布尺寸与动画延迟静默丢失)。 +const ATTR_RE = name => new RegExp(`(?]+))`, 'i'); +const attrValue = (tag, name) => { + const m = ATTR_RE(name).exec(tag); + return m ? (m[2] ?? m[3] ?? m[4]) : null; +}; + +// 属性值重新序列化进双引号前必须转义(2026-10-10 独立安全审计 H1): 值可能是从**单引号** +// 属性里取出来的原文, 原样塞进双引号就能提前闭合属性并追加一个活的 on* 处理器 —— +// 实测 经 addNoFx 后变成 +// class="x" onmouseover=window.FIRED=1 data-x= no-fx", 打开放映页即任意 JS 执行。 +// & 先转, 免得把作者的 & 再编一次(& 本身是实体前缀)。 +const escapeAttrValue = v => String(v) + .replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + +function firstTag(html, name) { + // 属性值里可以合法出现 >, 所以标签体不能简单用 [^>]* 一刀切(否则 style='--x: calc(1>0)' + // 之后的属性全部读不到); 双引号/单引号区段放行。 + // 注释里的 是诱饵: 命中它就会去改注释, 真标签反而没被改(no-fx 加不上 → X 键对照 + // 静默失效, 画布尺寸/动画延迟也丢)。所以先算出注释区段, 落在里面的命中一律跳过。 + const comments = [ + ...[...html.matchAll(//g)].map(m => [m.index, m.index + m[0].length]), + ...[...html.matchAll(/\n` + copy; + copy = addStepScript(copy, stagesFromHtml(html)); // 动效开的逐级步进(?s=k 重设各级延迟) + if (ctCss) copy = injectStyle(copy, ctCss, `图表/表格工具箱(${ctLabel})`); + // 三个叶子写点全部过 safeOut(1.7.5 复查①): outDir 本身收监了, 但目录里的**叶子**若是 + // 预置的文件符号链接, writeFileSync 会顺着写出去 —— 叶子也要过收监, 与 capture 等一致。 + fs.writeFileSync(safeOut(dir, 'preview', 'play', base), copy); + const nofxName = base.replace(/\.html?$/i, '') + '.nofx.html'; + let nofx = addNoFx(copy); + if (nofxInjected) nofx = injectStyle(nofx, nofxInjected, 'no-fx 规则'); + fs.writeFileSync(safeOut(dir, 'preview', 'play', nofxName), nofx); + + const stageVals = Object.values(stages).filter(Number.isFinite); + rows.push({ + id: sid, name: base, title: s.title ?? s.layout ?? '', + copy: base, copyNofx: nofxName, + steps: stagesFromHtml(html), + duration: t?.duration, + lastStage: stageVals.length ? Math.max(...stageVals) : null, + clauses: t?.clauses ?? (s.clauses ?? []).map(c => ({ stage: c.stage ?? null, start: null, text: c.text, text2: c.text2 })), + }); + } + + const p2 = n => String(n).padStart(2, '0'); + const now = new Date(); + if (!rows.length) { console.error('✗ 一张可放映的 slide 都没有(检查 script.json 的 html 字段与 slides/ 目录)'); process.exit(1); } + + // 总览缩略图随 play/thumbs/ 携带(外部报告 P1: 总览曾写死 '../'+id+'.png' 指到父目录的 + // preview/, 只拷 play/ 或只发 index.html 就全挂)。preview/*.png 是 capture 的产物 —— 缺的 + // 用占位并如实计数, 没跑过 capture 的项目不再静默全占位。先清空重建, 删掉的旧张不留死图。 + const thumbsDir = safeOut(dir, 'preview', 'play', 'thumbs'); + fs.rmSync(thumbsDir, { recursive: true, force: true }); + fs.mkdirSync(thumbsDir, { recursive: true }); + let thumbN = 0; + for (const r of rows) { + const srcPng = path.join(dir, 'preview', `${r.id}.png`); + if (fs.existsSync(srcPng)) { fs.copyFileSync(srcPng, safeOut(dir, 'preview', 'play', 'thumbs', `${r.id}.png`)); thumbN++; } + } + + // 口播 UI 三种状态: 有文案+有对时 → 标注正常;有文案没对时 → 标注"未对时";没文案或 --no-script → 不出 + const anyClauses = rows.some(r => r.clauses.length > 0); + const narration = anyClauses && !NO_SCRIPT; + const timing = narration && !!timings && rows.some(r => r.clauses.some(c => Number.isFinite(c.start))); + // 提示与口播无关: 动画时序不是成片的, 这件事看画面的人也必须知道 + const fallbackNote = [ + (!hasTimings || fallbackUsed) ? L('口播还没对时: 入场顺序按等间隔 0.3/1.3/2.3s 估算, 不是成片时序(缺 build/timings.json)', + 'Narration not timed yet: entrance order estimated at 0.3/1.3/2.3s intervals — not the final timing (missing build/timings.json)') : '', + L('动画为手动逐级步进: 按 → 先出下一级, 出完再翻页(动效关则直接翻页)', + 'Animations step manually: press → to reveal the next level first, then the next slide (motion off = switch slides directly)'), + narration && !timing ? L('口播还没对时: 只列文案', 'Narration not timed yet: script text only') : '', + ].filter(Boolean).join(' · '); + + const page = buildPlayPage({ + topic: script.topic ?? '', lang: script.lang ?? 'zh', slides: rows, cssNote, + narration, timing, fallbackNote, + canvas: { w: cvW, h: cvH }, + generatedAt: `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())} ${p2(now.getHours())}:${p2(now.getMinutes())}`, + }); + fs.writeFileSync(safeOut(dir, 'preview', 'play', 'index.html'), page); + + const modes = !narration ? (NO_SCRIPT ? '关(--no-script)' : '关(本片没有 clauses)') : timing ? '口播文案(有对时)' : '口播文案(未对时)'; + console.log(`✓ 放映页 → ${path.relative(process.cwd(), path.join(outDir, 'index.html'))}`); + console.log(` 快照 ${rows.length} 张 ×2(动效/关动效) → ${path.relative(process.cwd(), outDir)}/${rows[0]?.name ?? '.html'}`); + console.log(` 口播 UI: ${modes}`); + if (thumbN === rows.length) console.log(` 总览缩略图: ${thumbN}/${rows.length} 张已随 play/thumbs/ 携带`); + else console.warn(`⚠ 总览缩略图: 仅 ${thumbN}/${rows.length} 张(其余没跑 capture, 缺 preview/.png —— 总览用占位; 跑 capture 后重新 preview-page 会补齐)`); + if (!hasTimings) console.warn('⚠ 缺 build/timings.json: 副本按等间隔预览入场顺序, 不是成片时序 —— 先跑 plan-timings.mjs'); + else if (fallbackUsed) console.warn('⚠ 部分张在 timings.json 里没有 stage 数据: 那几张按等间隔预览'); + else console.log(` 已注入实测 stage 延迟: ${rows.map(r => `${r.id}(${r.lastStage != null ? '末层 ' + r.lastStage.toFixed(1) + 's' : '无 stage'})`).join(' ')}`); + if (kitIssues.length) console.warn(`⚠ slides/tokens.css 落后于技能当前版(缺: ${kitIssues.map(k => k.label).join(' · ')}) —— 副本已兜底注入;想让成片与项目文件也用上, 跑 node scripts/init-project.mjs <项目目录> --upgrade-css`); + console.log(` 操作: ← → 逐级入场/翻页(动效开)或翻页(动效关) · Space/PgDn 前进 · PgUp 后退 · Home/End 首末张${narration ? ' · P 口播开/关' : ''} · X 动效开/关 · T 切页方式 · O 总览(Esc 关) · F 全屏;触屏左右滑同 ← →;外部自动化用 window.playPage(state/go/next/prev/setFx/setNarr/setTrans/overview)`); + + if (OPEN) { + const target = path.join(outDir, 'index.html'); + const { cmd, args, verbatim } = openCmdArgv(target); + spawn(cmd, args, { detached: true, stdio: 'ignore', windowsVerbatimArguments: verbatim }).unref(); + console.log(' 已在浏览器打开(若没弹出, 手动双击上面的 index.html)'); + } +} + +// 打开浏览器的子进程参数。**导出来是为了可测**(单一来源, 同 buildSrt / assertConcatPathSafe 的做法)。 +// +// 空标题 `""` 不是可选的, 两个原因各自独立: +// ① start 的**首个带引号 token 是窗口标题, 不是要打开的东西**。实测(2026-10-10): +// `cmd /c start "T" cmd /c <命令>` → T 成了标题, 后面的命令真的执行了; +// `cmd /c start T cmd /c <命令>` → T 成了命令(找不到该程序), 后面的命令**根本没跑**。 +// 所以 `start "C:\...\index.html"`(只有一个带引号 token) = 路径被当标题吃掉, 一个都不打开。 +// ② windowsVerbatimArguments 下 Node 不再替我们给参数加引号, `''` 就真的是空串、`""` 会消失。 +// 空标题必须写成**字面量** '""'。这一条是审计 M1 修复里的返工面: 当时保留了 `''` 作为空标题, +// 以为 verbatim 只影响引用 —— 结果浏览器打不开了。 +// 引号由我们自己补(q), 不依赖 Node 的"含空格才加引号": Windows 路径允许含 & 而不含空格, +// 未被引用的 & 会让 cmd 起第二条命令(实测 `&rem` 真的执行了)。 +export function openCmdArgv(target, platform = process.platform) { + const q = t => `"${String(t).replace(/"/g, '""')}"`; + if (platform === 'win32') return { cmd: 'cmd', args: ['/c', 'start', '""', q(target)], verbatim: true }; + if (platform === 'darwin') return { cmd: 'open', args: [target], verbatim: false }; + return { cmd: 'xdg-open', args: [target], verbatim: false }; +} + +// 被 tests/ import 时不要跑主流程(只有当脚本直接执行才跑)。判定走 tools.isMainModule: +// realpath 两侧 + 同名兜底 —— 经符号链接/异形路径调用曾恒假静默 no-op(2026-09-22 云沙箱实测) +if (isMainModule(import.meta.url)) main(); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/table-css.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/table-css.mjs new file mode 100644 index 00000000..0dfb1689 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/table-css.mjs @@ -0,0 +1,50 @@ +// html2video-for-mcode · 表格原语的唯一来源。 +// init-project 把它写进 tokens.css 的受管块; `--upgrade-css` 按内容 rev 原地更新(见 css-kit.mjs)。 +// 为什么要有原语: 以前每种表都靠内联样式各写一套, 行高/对齐/涨跌色各页不一致 —— +// 而表格最容易出的问题正是"行高压不住、数字不右对齐、涨跌色用错"(见 authoring.md 表格纪律)。 + +export const TABLE_CSS = `/* ── 表格原语(数据表/规格表/对比矩阵/排名表统一用它) ───────────── + 1080p 下远处要读得清: 行高 ≥72px、数值右对齐且等宽、表头弱化、只留横线不画竖线。 + 涨跌色一律 var(--up)/var(--down)(财经按受众翻转, 见 compliance.md)。 */ +.tbl { width: 100%; border-collapse: collapse; font-size: var(--fs-body); } /* 主数据用正文号(≥30px): 视频在手机上也要读得清 */ +.tbl th { text-align: left; font-weight: 500; color: var(--fg-3); font-size: var(--fs-caption); + letter-spacing: .04em; padding: 0 0 var(--sp-2); border-bottom: 1px solid var(--line-strong); } +.tbl td { padding: var(--sp-3) var(--sp-4) var(--sp-3) 0; border-bottom: 1px solid var(--line); } +.tbl tr:last-child td { border-bottom: 0; } +.tbl tbody tr { height: 72px; } /* 行高保底: 再挤就远处读不出 */ +.tbl .num { text-align: right; padding-right: 0; font-family: var(--font-mono); + font-variant-numeric: tabular-nums; } +.tbl .up { color: var(--up); } +.tbl .down { color: var(--down); } +.tbl .muted { color: var(--muted); } +/* 高亮行(通常是"我们"或当前阶段)与合计行 */ +.tbl tr.key td { background: color-mix(in srgb, var(--accent) 8%, transparent); } +.tbl tr.key td:first-child { box-shadow: inset 3px 0 0 var(--accent); padding-left: var(--sp-4); } +.tbl tr.sum td { border-top: 1px solid var(--line-strong); font-weight: 600; color: var(--fg); } +/* 无表头规格表: 左标签右值, 只有细线 */ +.kv { width: 100%; border-collapse: collapse; font-size: var(--fs-body); } +.kv td { padding: var(--sp-3) 0; border-bottom: 1px solid var(--line); vertical-align: top; } +.kv td:first-child { color: var(--muted); width: 32%; } +.kv td:last-child { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +/* 对比矩阵: 行是维度、列是方案, 单元格只放勾叉/等级 */ +.matrix { width: 100%; border-collapse: collapse; font-size: var(--fs-body); text-align: center; } +.matrix th { color: var(--fg-3); font-size: var(--fs-caption); font-weight: 500; padding: 0 0 var(--sp-2); + border-bottom: 1px solid var(--line-strong); } +.matrix th:first-child, .matrix td:first-child { text-align: left; } +.matrix td { padding: var(--sp-3) 0; border-bottom: 1px solid var(--line); height: 72px; } +.matrix .yes { color: var(--accent); font-size: var(--fs-h3); } +.matrix .no { color: var(--fg-3); } +/* 高亮列: td 与 th 都要覆盖 —— 只写 td.hi 时表头那格拿不到背景, "我们"那一列看着只亮了一半(2026-09-18 排查抓到) */ +.matrix td.hi, .matrix th.hi { background: color-mix(in srgb, var(--accent) 8%, transparent); } +/* 排名表: 名次 + 微缩条 + 数值(表格里的条形, 复用 --w 定长) */ +.rank { width: 100%; border-collapse: collapse; font-size: var(--fs-body); } +.rank td { padding: var(--sp-2) var(--sp-3) var(--sp-2) 0; border-bottom: 1px solid var(--line); height: 72px; } +.rank .no { color: var(--fg-3); font-family: var(--font-mono); width: 3em; } +.rank .bar { width: 46%; } +.rank .bar span { display: block; height: 12px; border-radius: 999px; background: var(--accent); } +.rank .bar span.dim { background: color-mix(in srgb, var(--accent) 30%, var(--bg)); } +.rank .val { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }`; + +export function hasTableKit(css) { + return /\.tbl\s+tbody\s+tr\s*\{[^}]*height:\s*72px/.test(String(css ?? '')) && /\.kv\s*\{/.test(String(css ?? '')); +} diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/tokens-template.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/tokens-template.mjs new file mode 100644 index 00000000..ac386890 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/tokens-template.mjs @@ -0,0 +1,432 @@ +// html2video-for-mcode · tokens.css 的生成模板(整段受管: 由 tokens 块包住, 升级时整体替换) +// 从 init-project.mjs 抽出, 让 init-project / check-slides / preview-page 共用同一个"当前版本"。 +// 模板里的三处 wrapKit 由 css-kit 提供(内部带 KIT_REV), 所以 TOKENS_REV 天然覆盖三个工具箱的每次改动。 +import { createHash } from 'node:crypto'; +import { wrapKit } from './css-kit.mjs'; +import { NOFX_CSS } from './nofx-css.mjs'; +import { CHART_CSS } from './chart-css.mjs'; +import { TABLE_CSS } from './table-css.mjs'; + +export const TOKENS_BODY = `/* html2video-for-mcode 设计令牌 · 由 init-project 生成 + 主题切换: + + a / b / c 是初版三主题(向后兼容, 老项目继续有效); 其余 10 套于 2026-09-17 移植自 + html-ppt-skill (MIT, Copyright (c) 2026 lewis) —— 详见技能内 THIRD-PARTY-NOTICES.md。 + + 约定: 新增或覆写主题必须覆盖全部颜色令牌 + --accent-ink, 并跑 + \`node <技能>/scripts/check-theme.mjs <项目目录>\` 校验对比度(深色主题另需覆写 --sub-bg/--sub-ring)。 + + 受管块: 下面 >>> html2video:nofx / chart / table <<< 三段(带 rev 定界注释)由技能单源生成, + 不要手工编辑 —— 改了也会在下次 --upgrade-css 时被按源重写; 自己的规则加在文件尾即可(后写覆盖)。 +*/ +:root { + /* ── 画布(渲染管线按 script.json 的 width/height 注入真值; 默认横屏 1920×1080) ── */ + --stage-w: 1920px; --stage-h: 1080px; + + /* ── 颜色 · 表面 ── */ + --bg: #FAFAF7; --bg-soft: #F4F3ED; + --panel: #F1F0EA; --panel-2: #E9E8E0; + --line: #E3E1D8; --line-strong: #CFCDC0; + + /* ── 颜色 · 文本(三级) ── */ + --fg: #1A1A1A; --muted: #6B6B66; --fg-3: #9A9A92; + + /* ── 颜色 · 强调(accent-ink = 压在 accent 上的文字色, 每条主题必须自带对比度注释) ── */ + --accent: #FF5B2E; --accent-ink: #FFFFFF; /* on --accent 3.1:1 — 仅可用于大字/图形 */ + --accent-2: #FF8A5C; --accent-3: #D94A20; + + /* ── 颜色 · 语义(数据页涨跌/正负) ── */ + --good: #1AAF6C; --warn: #C98500; --bad: #C13A3A; + + /* ── 涨跌专用(指标卡写 var(--up)/var(--down), 别直接写 good/bad): + 默认 = 欧美读法(绿涨红跌); A股/港股受众在项目 tokens.css 末尾覆写成红涨绿跌: + :root { --up: #D92B2B; --down: #12A150; } 见 references/compliance.md ── */ + --up: var(--good); --down: var(--bad); + + /* ── 渐变 ── */ + --grad: linear-gradient(135deg, #FF5B2E, #FF8A5C 55%, #D94A20); + --grad-soft: linear-gradient(135deg, #FBF0E8, #F7E7DA); + + /* ── 字幕(capture 烧录的字幕读这些钩子; 深色主题必须覆写 --sub-bg 与 --sub-ring) ── */ + --sub-bg: rgba(12,12,16,.62); --sub-fg: #FFFFFF; --sub-ring: 0 solid transparent; + + /* ── 圆角 / 阴影 ── */ + --radius: 16px; --radius-sm: 10px; --radius-lg: 24px; + --shadow: 0 8px 24px rgba(18,24,40,.07), 0 2px 6px rgba(18,24,40,.04); + --shadow-lg: 0 22px 56px rgba(18,24,40,.13), 0 6px 16px rgba(18,24,40,.06); + + /* ── 字体(全部走本地系统栈, 禁外链; 外链字体在离线沙箱会退化成 FOUT/方框) ── */ + --font-display: "Source Han Serif SC", "Noto Serif CJK SC", "Noto Serif SC", "SimSun", serif; + --font-body: "Inter", "Source Han Sans SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; + --font-mono: "JetBrains Mono", "Cascadia Code", Consolas, monospace; + + /* ── 字号(7 档 modular scale) ── */ + --fs-display: 92px; --fs-h1: 64px; --fs-h2: 44px; --fs-h3: 34px; + --fs-body: 30px; --fs-caption: 24px; --fs-tiny: 20px; + + /* ── 间距(8 的倍数) ── */ + --sp-1: 8px; --sp-2: 16px; --sp-3: 24px; --sp-4: 32px; + --sp-5: 48px; --sp-6: 64px; --sp-7: 96px; --sp-8: 128px; + + /* ── 缓动 / 时长 ── */ + --ease-out: cubic-bezier(.16, 1, .3, 1); + --ease-in-out: cubic-bezier(.65, 0, .35, 1); + --dur-fast: .4s; --dur-mid: .6s; --dur-slow: .9s; + + /* ── stage 延迟: 浏览器里直接打开时的占位值; 渲染管线会按 TTS 实测时长注入真值 ── */ + --t1: 0ms; --t2: 800ms; --t3: 2000ms; +} + +/* ══ 主题 b · 深色科技绿(初版) ══ */ +[data-theme="b"] { + --bg: #0E0F12; --bg-soft: #12141A; --panel: #171A1F; --panel-2: #1E2229; + --line: #262A31; --line-strong: #363B45; + --fg: #F4F5F3; --muted: #9A9C9F; --fg-3: #6E7276; + --accent: #10A37F; --accent-ink: #04110C; /* on --accent 7.4:1 */ + --accent-2: #3DD9AC; --accent-3: #0B7A5F; + --good: #3DD9AC; --warn: #E0AF68; --bad: #F7768E; + --grad: linear-gradient(135deg, #10A37F, #3DD9AC 55%, #0B7A5F); + --grad-soft: linear-gradient(135deg, #171A1F, #1E2229); + --sub-bg: rgba(0,0,0,.58); --sub-ring: 1px solid rgba(255,255,255,.16); + --shadow: 0 10px 30px rgba(0,0,0,.45); --shadow-lg: 0 24px 60px rgba(0,0,0,.6); +} + +/* ══ 主题 c · 极简蓝(初版) ══ */ +[data-theme="c"] { + --bg: #FFFFFF; --bg-soft: #F7F8FA; --panel: #F5F6F8; --panel-2: #EDEFF3; + --line: #E5E7EB; --line-strong: #CFD3DA; + --fg: #111111; --muted: #666666; --fg-3: #9AA0A8; + --accent: #1F6FEB; --accent-ink: #FFFFFF; /* on --accent 4.9:1 */ + --accent-2: #5B9BFF; --accent-3: #1550B8; + --good: #0E9F6E; --warn: #D97706; --bad: #DC2626; + --grad: linear-gradient(135deg, #1F6FEB, #5B9BFF 55%, #1550B8); + --grad-soft: linear-gradient(135deg, #F0F4FB, #E4ECF7); + --shadow: 0 1px 3px rgba(10,37,64,.08), 0 4px 12px rgba(10,37,64,.05); + --shadow-lg: 0 4px 12px rgba(10,37,64,.1), 0 16px 40px rgba(10,37,64,.08); +} + +/* ════════════════════════════════════════════════════════════════ + 以下 10 套主题移植自 html-ppt-skill(MIT, © 2026 lewis) + 映射规则: --surface→--panel, --surface-2→--panel-2, --border→--line, + --border-strong→--line-strong, --text-1→--fg, --text-2→--muted, --text-3→--fg-3; + 外链字体(Playfair/Space Grotesk 等)一律改映射到我们的本地字体栈。 + 每套的 --accent-ink 后保留原作者实测的对比度数值。 + ════════════════════════════════════════════════════════════════ */ + +/* 商务汇报 · 极简白, 克制高级 */ +[data-theme="minimal-white"] { + --bg: #FFFFFF; --bg-soft: #FAFAFA; --panel: #FFFFFF; --panel-2: #F5F5F6; + --line: rgba(17,18,22,.08); --line-strong: rgba(17,18,22,.16); + --fg: #0C0D10; --muted: #55596A; --fg-3: #9CA1B0; + --accent: #111216; --accent-ink: #FFFFFF; /* on --accent 18.7:1 */ + --accent-2: #3B3F4A; --accent-3: #6B6F7A; + --good: #1AAF6C; --warn: #C98500; --bad: #C13A3A; + --grad: linear-gradient(135deg, #111216, #3B3F4A); + --grad-soft: linear-gradient(135deg, #F5F5F6, #FFFFFF); + --radius: 14px; --radius-sm: 8px; --radius-lg: 22px; + --shadow: 0 1px 2px rgba(17,18,22,.04), 0 8px 24px rgba(17,18,22,.06); + --shadow-lg: 0 20px 60px rgba(17,18,22,.1); + --font-display: var(--font-body); +} + +/* 商务汇报 · 瑞士网格(Helvetica 感; .stage 带 12 栏竖线) */ +[data-theme="swiss-grid"] { + --bg: #FFFFFF; --bg-soft: #F4F4F4; --panel: #FFFFFF; --panel-2: #F4F4F4; + --line: #111111; --line-strong: #111111; + --fg: #111111; --muted: #444444; --fg-3: #888888; + --accent: #D6001C; --accent-ink: #FFFFFF; /* on --accent 5.4:1 */ + --accent-2: #111111; --accent-3: #888888; + --good: #0F8A2F; --warn: #D38A00; --bad: #D6001C; + --grad: linear-gradient(135deg, #D6001C, #111111); + --grad-soft: linear-gradient(135deg, #F4F4F4, #FFFFFF); + --radius: 0; --radius-sm: 0; --radius-lg: 0; + --shadow: none; --shadow-lg: none; + --font-display: var(--font-body); +} +[data-theme="swiss-grid"] .card { border-top: 2px solid var(--fg); border-bottom: 1px solid var(--fg); border-left: none; border-right: none; box-shadow: none; background: var(--bg); } +[data-theme="swiss-grid"] .stage { background-image: linear-gradient(90deg, rgba(0,0,0,.04) 1px, transparent 1px); background-size: calc(100% / 12) 100%; } + +/* 商务汇报 · 企业商务(深蓝) */ +[data-theme="corporate-clean"] { + --bg: #FFFFFF; --bg-soft: #F5F7FA; --panel: #FFFFFF; --panel-2: #F0F3F7; + --line: rgba(10,37,64,.12); --line-strong: rgba(10,37,64,.28); + --fg: #0A2540; --muted: #425466; --fg-3: #8898AA; + --accent: #0A2540; --accent-ink: #FFFFFF; /* on --accent 15.5:1 */ + --accent-2: #1D4ED8; --accent-3: #64748B; + --good: #0E9F6E; --warn: #D97706; --bad: #DC2626; + --grad: linear-gradient(135deg, #0A2540, #1D4ED8); + --grad-soft: linear-gradient(135deg, #F0F4FB, #E4ECF7); + --radius: 6px; --radius-sm: 4px; --radius-lg: 10px; + --shadow: 0 1px 3px rgba(10,37,64,.08), 0 4px 12px rgba(10,37,64,.05); + --shadow-lg: 0 4px 12px rgba(10,37,64,.1), 0 16px 40px rgba(10,37,64,.08); + --font-display: var(--font-body); +} +[data-theme="corporate-clean"] .kicker { color: var(--accent-2); } + +/* 编辑杂志 · 杂志风衬线(原主题给标题配斜体, 中文伪斜体观感差故略去) */ +[data-theme="editorial-serif"] { + --bg: #FAF7F2; --bg-soft: #F3EFE6; --panel: #FFFFFF; --panel-2: #F7F2E8; + --line: rgba(40,28,18,.12); --line-strong: rgba(40,28,18,.24); + --fg: #1B1410; --muted: #5C4A3E; --fg-3: #8A7868; + --accent: #8A2A1C; --accent-ink: #FFFFFF; /* on --accent 8.6:1 */ + --accent-2: #C97A4A; --accent-3: #1B1410; + --good: #3F7D4F; --warn: #B07A1F; --bad: #8A2A1C; + --grad: linear-gradient(135deg, #8A2A1C, #C97A4A); + --grad-soft: linear-gradient(135deg, #FAF7F2, #F3EFE6); + --radius: 4px; --radius-sm: 2px; --radius-lg: 8px; + --shadow: 0 2px 12px rgba(40,28,18,.06); --shadow-lg: 0 20px 50px rgba(40,28,18,.14); +} + +/* 编辑杂志 · 大标题(硬阴影 + 衬线巨字) */ +[data-theme="magazine-bold"] { + --bg: #F5EFE2; --bg-soft: #EBE4D2; --panel: #FBF6E8; --panel-2: #EDE5D0; + --line: rgba(10,10,10,.16); --line-strong: #0A0A0A; + --fg: #0A0A0A; --muted: #2A2A2A; --fg-3: #6A6458; + --accent: #EA5A1A; --accent-ink: #0B1024; /* on --accent 5.4:1 */ + --accent-2: #0A0A0A; --accent-3: #C42A10; + --good: #2A6A2A; --warn: #EA5A1A; --bad: #C42A10; + --grad: linear-gradient(135deg, #EA5A1A, #C42A10); + --grad-soft: linear-gradient(135deg, #FBE4D0, #F5D6C0); + --radius: 0; --radius-sm: 0; --radius-lg: 2px; + --shadow: none; --shadow-lg: 6px 6px 0 var(--accent); +} +[data-theme="magazine-bold"] .card { border: 1.5px solid var(--fg); } +[data-theme="magazine-bold"] .divider-accent { height: 6px; width: 90px; } + +/* 科技深色 · tokyo-night */ +[data-theme="tokyo-night"] { + --bg: #1A1B26; --bg-soft: #16161E; --panel: #24283B; --panel-2: #2F334D; + --line: rgba(192,202,245,.12); --line-strong: rgba(192,202,245,.24); + --fg: #C0CAF5; --muted: #A9B1D6; --fg-3: #565F89; + --accent: #7AA2F7; --accent-ink: #0B1024; /* on --accent 7.5:1 */ + --accent-2: #BB9AF7; --accent-3: #7DCFFF; + --good: #9ECE6A; --warn: #E0AF68; --bad: #F7768E; + --grad: linear-gradient(135deg, #7AA2F7, #BB9AF7 55%, #F7768E); + --grad-soft: linear-gradient(135deg, #24283B, #2F334D); + --sub-bg: rgba(0,0,0,.58); --sub-ring: 1px solid rgba(255,255,255,.16); + --radius: 12px; --radius-sm: 8px; --radius-lg: 20px; + --shadow: 0 10px 30px rgba(0,0,0,.45); --shadow-lg: 0 24px 62px rgba(0,0,0,.6); +} + +/* 科技深色 · catppuccin mocha */ +[data-theme="catppuccin-mocha"] { + --bg: #1E1E2E; --bg-soft: #181825; --panel: #313244; --panel-2: #45475A; + --line: rgba(205,214,244,.12); --line-strong: rgba(205,214,244,.24); + --fg: #CDD6F4; --muted: #A6ADC8; --fg-3: #7F849C; + --accent: #CBA6F7; --accent-ink: #0B1024; /* on --accent 9.3:1 */ + --accent-2: #89B4FA; --accent-3: #F5C2E7; + --good: #A6E3A1; --warn: #F9E2AF; --bad: #F38BA8; + --grad: linear-gradient(135deg, #CBA6F7, #89B4FA 50%, #94E2D5); + --grad-soft: linear-gradient(135deg, #313244, #45475A); + --sub-bg: rgba(0,0,0,.58); --sub-ring: 1px solid rgba(255,255,255,.16); + --radius: 14px; --radius-sm: 10px; --radius-lg: 22px; + --shadow: 0 10px 30px rgba(0,0,0,.35); --shadow-lg: 0 24px 60px rgba(0,0,0,.5); +} + +/* 科技深色 · nord */ +[data-theme="nord"] { + --bg: #2E3440; --bg-soft: #272B35; --panel: #3B4252; --panel-2: #434C5E; + --line: rgba(236,239,244,.12); --line-strong: rgba(236,239,244,.24); + --fg: #ECEFF4; --muted: #D8DEE9; --fg-3: #7B8394; + --accent: #88C0D0; --accent-ink: #0B1024; /* on --accent 9.4:1 */ + --accent-2: #81A1C1; --accent-3: #B48EAD; + --good: #A3BE8C; --warn: #EBCB8B; --bad: #BF616A; + --grad: linear-gradient(135deg, #88C0D0, #81A1C1 50%, #B48EAD); + --grad-soft: linear-gradient(135deg, #3B4252, #434C5E); + --sub-bg: rgba(0,0,0,.58); --sub-ring: 1px solid rgba(255,255,255,.16); + --radius: 12px; --radius-sm: 8px; --radius-lg: 20px; + --shadow: 0 10px 30px rgba(0,0,0,.35); --shadow-lg: 0 22px 60px rgba(0,0,0,.5); +} + +/* 消费生活 · 小红书白底高级感 */ +[data-theme="xiaohongshu-white"] { + --bg: #FFFDFB; --bg-soft: #FFF6F1; --panel: #FFFFFF; --panel-2: #FFF1EA; + --line: rgba(60,30,20,.1); --line-strong: rgba(60,30,20,.22); + --fg: #1A1210; --muted: #4F3A32; --fg-3: #A08D85; + --accent: #FF2742; --accent-ink: #0B1024; /* on --accent 5.0:1 */ + --accent-2: #FF7A90; --accent-3: #FFB38A; + --good: #3BA55C; --warn: #F5A524; --bad: #FF2742; + --grad: linear-gradient(135deg, #FF2742, #FF7A90 55%, #FFB38A); + --grad-soft: linear-gradient(135deg, #FFF6F1, #FFEAE0); + --radius: 20px; --radius-sm: 14px; --radius-lg: 28px; + --shadow: 0 12px 30px rgba(255,39,66,.08); --shadow-lg: 0 24px 60px rgba(255,39,66,.14); +} + +/* 消费生活 · 柔和马卡龙 */ +[data-theme="soft-pastel"] { + --bg: #FDF7FB; --bg-soft: #FBEEF3; --panel: #FFFFFF; --panel-2: #FDF0F5; + --line: rgba(120,70,110,.12); --line-strong: rgba(120,70,110,.22); + --fg: #3A1F33; --muted: #6B4D62; --fg-3: #A28A99; + --accent: #F49BB8; --accent-ink: #0B1024; /* on --accent 9.2:1 */ + --accent-2: #B5D5F0; --accent-3: #F7D08A; + --good: #9DD9A3; --warn: #F7D08A; --bad: #EF9A9A; + --grad: linear-gradient(135deg, #F49BB8, #B5D5F0 55%, #C4A0E8); + --grad-soft: linear-gradient(135deg, #FBEEF3, #EAF4FC); + --radius: 24px; --radius-sm: 16px; --radius-lg: 32px; + --shadow: 0 8px 28px rgba(244,155,184,.18); --shadow-lg: 0 24px 70px rgba(181,213,240,.3); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { width: var(--stage-w, 1920px); height: var(--stage-h, 1080px); overflow: hidden; } +.stage { + position: relative; width: var(--stage-w, 1920px); height: var(--stage-h, 1080px); overflow: hidden; + background: var(--bg); color: var(--fg); font-family: var(--font-body); +} +.brand { + position: absolute; top: 44px; left: 64px; + font-size: var(--fs-tiny); letter-spacing: .14em; color: var(--muted); +} +.slide-num { + position: absolute; bottom: 40px; right: 64px; + font-size: var(--fs-tiny); letter-spacing: .1em; color: var(--muted); +} +.accent { color: var(--accent); } +.gradient-text { background: var(--grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; } + +/* ── 排版角色 ───────────────────────────────────────────────── */ +.kicker { font-size: var(--fs-tiny); font-weight: 600; color: var(--accent); letter-spacing: .08em; } +.eyebrow { font-size: var(--fs-tiny); font-weight: 500; letter-spacing: .16em; color: var(--fg-3); } +.lede { font-size: var(--fs-h3); line-height: 1.55; color: var(--muted); font-weight: 300; max-width: 1400px; } + +/* ── 卡片 / 标签 / 分隔 ─────────────────────────────────────── */ +.card { + background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); + padding: var(--sp-4) var(--sp-5); box-shadow: var(--shadow); position: relative; overflow: hidden; +} +.card-soft { background: var(--panel-2); border-color: transparent; box-shadow: none; } +.card-outline { background: transparent; border: 1.5px solid var(--line-strong); box-shadow: none; } +.card-accent { border-top: 3px solid var(--accent); } +.pill { + display: inline-block; padding: 4px 14px; border-radius: 999px; font-size: var(--fs-tiny); + font-weight: 500; background: var(--panel-2); color: var(--muted); border: 1px solid var(--line); +} +.pill-accent { + background: var(--panel-2); + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); border-color: var(--line); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); +} +.divider-accent { height: 4px; width: 72px; background: var(--accent); border-radius: 2px; } + +/* ── 图片框(硬规则: 图片必须套框, 禁止裸放 ) ───────────── + .img-frame 比例与裁切归框所有; 图片只管填满 + .img-frame.contain 不裁切(截图/图表/logo 必须用这个), 居中留白 + --img-ratio 框比例(默认 16/10); --img-pos 主体位置(如 top 保住头部) + .img-scrim 底部渐变压暗, 让白字压在照片上仍可读 */ +.img-frame { + position: relative; margin: 0; overflow: hidden; display: block; + border-radius: var(--radius); box-shadow: var(--shadow); background: var(--panel-2); + aspect-ratio: var(--img-ratio, 16/10); +} +.img-frame > img { width: 100%; height: 100%; object-fit: cover; object-position: var(--img-pos, center); display: block; } +.img-frame.contain { background: var(--bg-soft); box-shadow: none; border: 1px solid var(--line); } +.img-frame.contain > img { object-fit: contain; } +.img-frame.fill { height: 100%; aspect-ratio: auto; } +.img-scrim { position: absolute; inset: 0; background: linear-gradient(180deg, transparent 42%, rgba(8,10,20,.72)); } +.img-cap { margin: 10px 2px 0; font-size: var(--fs-tiny); line-height: 1.5; color: var(--fg-3); } +.img-tag { + position: absolute; top: 14px; left: 14px; padding: 5px 14px; border-radius: 999px; + background: rgba(10,12,20,.55); color: #fff; font-size: var(--fs-tiny); letter-spacing: .06em; +} + +/* ── 免责声明 / 出处标注行(受监管题材: 财经/医疗/法律/政策, 见 references/compliance.md) + 小字不抢视觉; 出现在 closing 张并停留 ≥3s; 口播不念、不进字幕; 加了它也不替代核实。 + 2026-09-28 去掉内置 max-width:1240px —— 那是给单行出处标注设计的, 会把多行整宽 + 免责块卡死在 1240px 居中(项目里显式 width:100% 也压不过它); 需要收窄的项目自己覆写 */ +.disclaimer { font-size: var(--fs-tiny); line-height: 1.6; color: var(--fg-3); } +.disclaimer-box { border-left: 2px solid var(--line-strong); padding-left: var(--sp-3); } + +/* ── 分步入场系统 ────────────────────────────────────────────── + 要入场的块: data-stage="1|2|3" + fx-* 工具类, 例: +

大标题

+

展开内容

+ 延迟由管线按口播实测时长注入 --t1/--t2/--t3, HTML 里不要写死。 + 同层内错峰: 用容器 .fx-stagger(基准时刻用 style="--stagger-base:var(--t3)" 指定), + 或内联 style="animation-delay:calc(var(--t2) + 150ms)" + ⚠ data-stage 必须与 fx-* 类同时用 —— 只有 data-stage 没有动画类, 元素会永远停在 opacity:0; + 容器用 .fx-stagger 时容器自身不要再加 data-stage(子元素自己管入场)。 + 氛围动画(无限循环, 不承载信息)不加 data-stage: fx-pulse / fx-shimmer / fx-kenburns */ +[data-stage] { opacity: 0; --fx-delay: 0ms; } +[data-stage="1"] { --fx-delay: var(--t1); } +[data-stage="2"] { --fx-delay: var(--t2); } +[data-stage="3"] { --fx-delay: var(--t3); } + +/* 入场(有限时长, 可被逐帧 seek)。延迟写进 shorthand 的变量槽 —— 不能用独立的 + animation-delay 声明: 它与下面的 .fx-* 简写同优先级且更靠前, 会被简写重置为 0, + 导致所有 stage 都在 0 秒入场(2026-09-17 实测确认的坑)。inline animation-delay 仍可覆盖(错峰用)。 */ +.fx-up { animation: fx-up var(--dur-mid) var(--ease-out) var(--fx-delay, 0ms) both; } +.fx-fade { animation: fx-fade var(--dur-fast) var(--ease-out) var(--fx-delay, 0ms) both; } +.fx-grow { animation: fx-grow var(--dur-slow) var(--ease-out) var(--fx-delay, 0ms) both; } +.fx-blur { animation: fx-blur .8s var(--ease-out) var(--fx-delay, 0ms) both; } /* 模糊聚焦 */ +.fx-rise { animation: fx-rise .9s var(--ease-out) var(--fx-delay, 0ms) both; } /* 上浮+微缩放+去模糊 */ +.fx-pop { animation: fx-pop .7s cubic-bezier(.22,1.3,.36,1) var(--fx-delay, 0ms) both; } /* 过冲弹入 */ +.fx-spotlight { animation: fx-spotlight 1.1s var(--ease-out) var(--fx-delay, 0ms) both; } /* 圆形揭示 */ +.fx-ripple { animation: fx-ripple 1.2s var(--ease-out) var(--fx-delay, 0ms) both; } /* 斜角冲开 */ +.fx-glitch { animation: fx-glitch .8s steps(5, end) var(--fx-delay, 0ms) both; } /* 故障切入 */ +.fx-draw { animation: fx-draw 1.2s var(--ease-in-out) var(--fx-delay, 0ms) both; } /* 需元素自设 stroke-dasharray:800 */ +.fx-grow-x { transform-origin: left center; animation: fx-grow-x-kf .9s var(--ease-out) var(--fx-delay, 0ms) both; } /* 图表: 条形从左长出 */ +.fx-grow-y { transform-origin: bottom center; animation: fx-grow-y-kf .9s var(--ease-out) var(--fx-delay, 0ms) both; } /* 图表: 柱状从底长出 */ + +${wrapKit('nofx', NOFX_CSS)} + +@keyframes fx-up { from { opacity: 0; transform: translateY(32px); } to { opacity: 1; transform: none; } } +@keyframes fx-fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes fx-grow { from { opacity: 0; transform: scale(.9); } to { opacity: 1; transform: scale(1); } } +@keyframes fx-blur { from { opacity: 0; filter: blur(18px); } to { opacity: 1; filter: none; } } +@keyframes fx-rise { from { opacity: 0; transform: translateY(60px) scale(.97); filter: blur(6px); } to { opacity: 1; transform: none; filter: none; } } +@keyframes fx-pop { 0% { opacity: 0; transform: scale(.6); } 60% { transform: scale(1.04); } 100% { opacity: 1; transform: scale(1); } } +@keyframes fx-spotlight { from { clip-path: circle(0% at 50% 50%); opacity: 1; } to { clip-path: circle(140% at 50% 50%); opacity: 1; } } +@keyframes fx-ripple { from { clip-path: circle(0% at 20% 80%); opacity: .4; } to { clip-path: circle(160% at 20% 80%); opacity: 1; } } +@keyframes fx-glitch { + 0% { opacity: 0; transform: translateX(0); clip-path: inset(0 0 0 0); } + 20% { opacity: 1; transform: translateX(-6px); clip-path: inset(20% 0 30% 0); } + 40% { transform: translateX(4px); clip-path: inset(50% 0 10% 0); } + 60% { transform: translateX(-3px); clip-path: inset(10% 0 60% 0); } + 80% { transform: translateX(2px); clip-path: inset(0 0 0 0); } + 100% { opacity: 1; transform: none; } +} +@keyframes fx-draw { from { stroke-dashoffset: 800; opacity: 1; } to { stroke-dashoffset: 0; opacity: 1; } } +/* 注意: 只做 transform 的动画必须显式带上 opacity:1 —— [data-stage] 的基础态是 opacity:0, + 靠动画抬回 1; 关键帧不碰 opacity 的动画会让元素永远隐形(2026-09-18 实测) */ +@keyframes fx-grow-x-kf { from { transform: scaleX(0); opacity: 1; } to { transform: scaleX(1); opacity: 1; } } +@keyframes fx-grow-y-kf { from { transform: scaleY(0); opacity: 1; } to { transform: scaleY(1); opacity: 1; } } + +/* 氛围(无限循环, 不参与时长计算) */ +.fx-pulse { animation: fx-pulse 2.4s var(--ease-in-out) infinite; } +@keyframes fx-pulse { 0%, 100% { opacity: .55; } 50% { opacity: 1; } } +.fx-shimmer { position: relative; overflow: hidden; } +.fx-shimmer::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(110deg, transparent 40%, rgba(255,255,255,.5) 50%, transparent 60%); + transform: translateX(-100%); animation: fx-shimmer-kf 2.4s var(--ease-in-out) infinite; +} +@keyframes fx-shimmer-kf { to { transform: translateX(100%); } } +.fx-kenburns { animation: fx-kenburns-kf 14s ease-in-out infinite alternate; } +@keyframes fx-kenburns-kf { from { transform: scale(1) translate(0, 0); } to { transform: scale(1.15) translate(-2%, -1%); } } + +${wrapKit('chart', CHART_CSS)} + +${wrapKit('table', TABLE_CSS)} + +/* 依次入场容器: 子元素逐个上浮, 基准时刻取 --stagger-base(默认 --t2)。 + :not([data-stage]) 是必须的(2026-09-18 实测踩坑): nth-child 延迟规则特异度(0,2,0)高于 + fx-* 类(0,1,0), 不排除的话会覆盖带 data-stage 子元素自己的 --fx-delay, 元素提前几秒冒出来; + 语义: 要错峰的块当子元素(不带 data-stage), 要自己管入场时刻的块放容器外面 */ +.fx-stagger > *:not([data-stage]) { opacity: 0; animation: fx-rise .65s var(--ease-out) both; } +.fx-stagger > *:not([data-stage]):nth-child(1) { animation-delay: calc(var(--stagger-base, var(--t2)) + 0ms); } +.fx-stagger > *:not([data-stage]):nth-child(2) { animation-delay: calc(var(--stagger-base, var(--t2)) + 120ms); } +.fx-stagger > *:not([data-stage]):nth-child(3) { animation-delay: calc(var(--stagger-base, var(--t2)) + 240ms); } +.fx-stagger > *:not([data-stage]):nth-child(4) { animation-delay: calc(var(--stagger-base, var(--t2)) + 360ms); } +.fx-stagger > *:not([data-stage]):nth-child(5) { animation-delay: calc(var(--stagger-base, var(--t2)) + 480ms); } +.fx-stagger > *:not([data-stage]):nth-child(6) { animation-delay: calc(var(--stagger-base, var(--t2)) + 600ms); } +.fx-stagger > *:not([data-stage]):nth-child(7) { animation-delay: calc(var(--stagger-base, var(--t2)) + 720ms); } +.fx-stagger > *:not([data-stage]):nth-child(n+8) { animation-delay: calc(var(--stagger-base, var(--t2)) + 840ms); } +`; + +/** 生成完整 tokens.css 内容(LF 归一): init-project 写入用它, --upgrade-css 原地替换也用它 */ +export function generateTokensCss() { + return TOKENS_BODY.replace(/\r\n/g, '\n'); +} + +/** 当前版本的指纹: 内容(含三个工具箱)变了它就变 */ +export const TOKENS_REV = createHash('sha256').update(generateTokensCss()).digest('hex'); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/tools.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/tools.mjs new file mode 100644 index 00000000..b8240598 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/tools.mjs @@ -0,0 +1,631 @@ +// html2video-for-mcode · ffmpeg/ffprobe 探测: PATH → node_modules → 常见安装位置 +// 找不到时给可诊断的提示, 而不是让下游脚本报莫名其妙的错。 +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { kitStatuses } from './css-kit.mjs'; +import { generateTokensCss, TOKENS_REV } from './tokens-template.mjs'; +import { MAX_SCAN_BYTES } from './limits.mjs'; + +const IS_WIN = process.platform === 'win32'; +const EXE = IS_WIN ? '.exe' : ''; + +function onPath(name) { + const r = spawnSync(name + EXE, ['-version'], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && r.stdout ? name + EXE : null; +} + +const SELF_DIR = path.dirname(fileURLToPath(import.meta.url)); // Node 20.11 以下没有 import.meta.dirname, 用 fileURLToPath 保兼容 + +function candidatePaths(name, projectDir) { + const dirs = []; + // 调用方项目目录(若给了)、当前工作目录、技能自身位置的**全部祖先**(与 Node import 的模块 + // 解析同型)。只看固定两级时, 技能被深嵌在 monorepo(plugins///skills//scripts, + // 6 层)会漏掉"仓库根明明装了 ffmpeg-static"的布局 —— 测试进程从仓库根找得到, spawn 出去的 + // asr.mjs(cwd=临时项目)却找不到, 同一套测试两种结果(发布树实测踩到, 2026-09-28)。 + // cwd 必须查(二审 P2): README 明说支持把 ffmpeg-static 装在**视频项目**里, 而 prep-image / asr + // 是从项目目录被调用的 —— 不查 cwd 就会"项目里明明装了却报找不到" + const upDirs = start => { + const out = []; let d = path.resolve(start); + for (let i = 0; i < 12; i++) { + out.push(d); + const parent = path.dirname(d); + if (parent === d) break; + d = parent; + } + return out; + }; + const anchors = [...new Set([process.env.KIT_PROJECT_DIR, projectDir, process.cwd(), ...upDirs(SELF_DIR)] + .filter(Boolean))]; + for (const base of anchors) { + dirs.push(path.join(base, 'node_modules', 'ffmpeg-static')); + dirs.push(path.join(base, 'node_modules', 'ffprobe-static', 'bin', process.platform, process.arch)); + } + if (IS_WIN) { + const la = process.env.LOCALAPPDATA; + if (la) { + dirs.push(path.join(la, 'Microsoft', 'WinGet', 'Links')); + dirs.push(path.join(la, 'scoop', 'shims')); + } + dirs.push('C:\\ffmpeg\\bin'); + } + return dirs.map(d => path.join(d, name + EXE)); +} + +export function findTool(name, projectDir) { + try { const p = onPath(name); if (p) return p; } catch { /* not on PATH */ } + if (projectDir) { + const local = [ + path.join(projectDir, 'node_modules', 'ffmpeg-static', name + EXE), + path.join(projectDir, 'node_modules', 'ffprobe-static', 'bin', process.platform, process.arch, name + EXE), + ]; + for (const p of local) if (fs.existsSync(p)) return p; + } + for (const p of candidatePaths(name, projectDir)) if (fs.existsSync(p)) return p; + return null; +} + +export function requireTool(name, projectDir) { + const p = findTool(name, projectDir); + if (!p) { + console.error([ + `✗ 找不到 ${name}。按顺序排查:`, + ` 1. 系统 PATH 上没有 ${name}`, + ` 2. 项目/仓库 node_modules 里没有 (试试: npm i ffmpeg-static ffprobe-static)`, + ` 3. 常见安装位置 (winget Links / scoop / C:\\ffmpeg\\bin) 也没有`, + ` 最快解法: winget install Gyan.FFmpeg (Windows) · brew install ffmpeg (macOS) · apt install ffmpeg (Linux)`, + ].join('\n')); + process.exit(2); // 2 = 环境错误, 与 1 (业务错误) 区分 + } + return p; +} + +// ── 输入收监(script.json 是 agent 可编辑文件, 其派生路径必须关进项目目录) ──── +// 威胁模型: script.json 里的 slides[].id / html / audio、顶层 bgm.file 若含 +// "../" 或绝对路径, 可让 fs 读写删与 ffmpeg argv 落到项目目录之外。 +// 所有消费者在 JSON.parse 之后立即调用本节的校验, 失败即退出, 不带病运行。 + +const ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +export function isSafeId(v) { + return typeof v === 'string' && ID_RE.test(v); +} + +// 白名单校验 slide id; 返回原值, 不合格直接退出(并指明是哪个字段) +export function safeId(v, where = 'slides[].id') { + if (!isSafeId(v)) { + console.error(`✗ ${where} 非法: ${JSON.stringify(v)} — 只允许字母/数字/下划线/连字符, 长度 1–64`); + process.exit(1); + } + return v; +} + +// 纯字符串判断: resolve 之后 target 是否落在 root 内(target 等于 root 时返回 false)。 +// 这里**不做** realpath —— 符号链接复核在 safeRel / assertContained 里做(它们会取最深已存在祖先的 +// realpath 并 fail closed)。谁需要防"root 自身是指向外面的链接"就用那两个, 别指望这个。 +export function inside(root, target) { + const rel = path.relative(path.resolve(root), path.resolve(target)); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +// 校验"root 下的相对路径"并返回绝对路径: +// 拒绝绝对路径 → inside(root) → 符号链接复核(取最深已存在祖先的 realpath, +// 必须仍在 root 的 realpath 内, 防 root 内某段是指向外面的 symlink) +export function safeRel(root, rel, { where = 'path', mustExist = false } = {}) { + if (typeof rel !== 'string' || rel === '') { + console.error(`✗ ${where} 非法: ${JSON.stringify(rel)} — 需要非空字符串`); + process.exit(1); + } + if (path.isAbsolute(rel)) { + console.error(`✗ ${where} 必须是相对路径: ${JSON.stringify(rel)}`); + process.exit(1); + } + const abs = path.resolve(root, rel); + if (!inside(root, abs)) { + console.error(`✗ ${where} 越出项目目录: ${JSON.stringify(rel)} → ${abs}`); + process.exit(1); + } + // 符号链接复核: 目标(或其最深"存在或是指向外的悬空链接"的祖先)的 realpath, + // 必须仍在 root 的 realpath 内(或是 root realpath 的祖先 —— root 尚不存在时属正常)。 + // 复查修正(1.7.0): ①回溯用 lstat 而不是 existsSync —— 悬空符号链接 existsSync=false, + // 旧写法会把它当"不存在"继续往上走, 从而漏检(写进去就落到项目外); ②realpath 取不到时 + // **fail closed**(以前 return abs 静默放行, 正好给"造一个解不开的链接"留了门)。 + // 复查修正(1.7.5): "real 是 root 祖先也放行"的回退只留给 **root 尚不存在** 的场景(合法: + // 走过 root 停在已存在祖先)。root 已存在时, 该回退会被"项目内链接指向项目父目录"穿透 —— + // 链接的 realpath 是 root 的祖先, 回退放行后, 链接下的**新文件**就写到项目外了(实测复现)。 + { + let probe = abs; + while (true) { + let st = null; + try { st = fs.lstatSync(probe); } catch { /* 真不存在 */ } + if (st) break; // 存在, 或者是悬空链接(此时也要停下来判) + const up = path.dirname(probe); + if (up === probe) break; + probe = up; + } + // root 侧与 probe 侧必须用**同一套**规范化(见 canonicalRoot: 七审 blocker 2 的修正)。 + const { exists: rootExists, real: realRoot } = canonicalRoot(root, where); + let real = null; + try { real = fs.realpathSync(probe); } + catch { + // 悬空/自环/无权限的链接: 解不开就不能证明它落在项目内 → 拒绝, 不猜 + const st = (() => { try { return fs.lstatSync(probe); } catch { return null; } })(); + if (st && st.isSymbolicLink()) { + console.error(`✗ ${where} 命中一个解不开的符号链接: ${path.relative(path.resolve(root), probe) || probe}\n 无法证明它指向项目内, 按越界拒绝(悬空链接常被用来把写入引到项目外)`); + process.exit(1); + } + // 非链接却 realpath 失败(权限等): 同样不放过 + console.error(`✗ ${where} 无法解析真实路径: ${probe}(realpath 失败)`); + process.exit(1); + } + if (real !== realRoot && !inside(realRoot, real) && !(rootExists ? false : inside(real, realRoot))) { + console.error(`✗ ${where} 经符号链接越出项目目录: ${JSON.stringify(rel)}`); + process.exit(1); + } + } + if (mustExist && !fs.existsSync(abs)) { + console.error(`✗ ${where} 不存在: ${rel}`); + process.exit(1); + } + return abs; +} + +// ── 输出路径收监(2026-09-18 二审 P1: 输出侧此前只查了输入侧) ────────── +// 威胁模型: 派生输出(preview/.png、build/frames//…)是"项目内固定位置 + 受监 id", +// 看起来安全, 但**项目内的目录段本身可能是指向项目外的符号链接**(preview → /tmp/canary, +// 或 build/frames → 项目外)。此时 writeFileSync/rmSync(recursive) 会穿透符号链接, +// 在项目外写/删, 而命令一路报成功 —— 用一次性 canary 数据可复现。 +// 规则: 从 root 到目标, **每一段已存在祖先**的 realpath 都必须仍在 root 的 realpath 之内; +// 叶子自身是符号链接也要拦(否则 ffmpeg/fs 会从那个链接写出去)。 +export function assertContained(root, abs, { where = 'output' } = {}) { + // root 侧与 probe 侧同一套规范化(canonicalRoot, 见其注释里的七审 blocker 2 修正)。 + const { exists: rootExists, real: realRoot } = canonicalRoot(root, where); + let probe = path.resolve(abs); + while (true) { + let st = null; + try { st = fs.lstatSync(probe); } catch { /* 真不存在 */ } + if (st) break; // 存在或悬空链接: 停下来判 + const up = path.dirname(probe); + if (up === probe) break; + probe = up; + } + let real; + try { real = fs.realpathSync(probe); } + catch { + const st = (() => { try { return fs.lstatSync(probe); } catch { return null; } })(); + if (st && st.isSymbolicLink()) { + console.error(`✗ ${where} 命中解不开的符号链接: ${path.relative(path.resolve(root), probe) || probe}\n 无法证明它指向项目内, 按越界拒绝`); + process.exit(1); + } + console.error(`✗ ${where} 无法解析真实路径: ${probe}(realpath 失败)`); + process.exit(1); + } + // "probe 是 root 的祖先"只在 **root 尚不存在** 时合法(root 到目标之间的段都还没建, + // 落盘只会在 root 下新建); root 已存在时绝不放行 —— 那正是"项目内链接指向项目父目录"的 + // 穿透形状(与 safeRel 的同一处收敛, 1.7.5 已把 safeRel 侧钉死)。 + if (real !== realRoot && !inside(realRoot, real) && !(rootExists ? false : inside(real, realRoot))) { + console.error(`✗ ${where} 经符号链接越出项目目录: ${path.relative(path.resolve(root), abs) || abs}\n ${probe} → ${real}(项目根 ${realRoot}) — 检查项目内是否有指向外部的符号链接`); + process.exit(1); + } + return abs; +} + +// 派生输出路径的安全拼装: 只接受受监 id 之类的安全片段, 并做上述祖先复核 +export function safeOut(root, ...segs) { + const abs = path.join(path.resolve(root), ...segs); + if (!inside(path.resolve(root), abs)) { + console.error(`✗ 输出路径越出项目目录: ${abs}`); + process.exit(1); + } + return assertContained(root, abs, { where: `输出 ${segs.join('/')}` }); +} + +// ── ffprobe 探测(第十三轮 review 去重: duration 三处、尺寸两处逐字重复) ────────── +// 失败统一返回 null —— 各调用方自己决定报错还是回退, 与既有实现逐字等价。 +export function probeDuration(ffprobe, file) { + const r = spawnSync(ffprobe, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], + { encoding: 'utf8', windowsHide: true }); + if (r.status !== 0 || !r.stdout) return null; + const d = parseFloat(r.stdout.trim().split('\n')[0]); + return Number.isFinite(d) ? d : null; +} +export function probeSize(ffprobe, file) { + const r = spawnSync(ffprobe, ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', file], + { encoding: 'utf8', windowsHide: true }); + const m = (r.stdout || '').trim().match(/(\d+),(\d+)/); + return m ? [parseInt(m[1], 10), parseInt(m[2], 10)] : null; +} + +// ── 对时边界匹配(单一来源, check-timing 消费) ──────────────────────── +// 静音段对齐用**发音形态**(say ?? text)的句读标点 —— 音频里停顿跟的是朗读(1.9.12 第 25 轮: +// 按 text 对齐时, say 与 text 标点不一致 → 精确模式错位, --calibrate 把错的 start 写回)。 +// 精确模式: 句读标点总数-1 == 静音段数 → 按标点累积下标对齐(最可信)。 +// 最近邻模式: 否则在估算时刻 ±1s 窗内取最近的静音段末端, 时序单调; 找不到就报未测(宁缺勿错)。 +const PAUSE_PUNCT = /[,,、;;::。!?!?…]/g; +export function matchBoundaries(gaps, clauses) { + const nBound = clauses.length - 1; + if (nBound <= 0) return { method: 'none', meas: [] }; + const marks = clauses.map(c => ((c.say ?? c.text).match(PAUSE_PUNCT) || []).length); + const totalMarks = marks.reduce((a, b) => a + b, 0); + const meas = new Array(nBound).fill(null); // 精确模式自己一份 + if (gaps.length === totalMarks - 1 || gaps.length === totalMarks) { + let cum = 0, gi = 0; + for (let k = 0; k < nBound; k++) { + cum += marks[k]; // 第 k 边界 = 前 k 句累积标点数对应的那个停顿 + const idx = Math.min(cum - 1, gaps.length - 1); + if (idx >= gi) { meas[k] = gaps[idx].end; gi = idx + 1; } + } + if (meas.every(v => v != null)) return { method: 'exact', meas }; + } + // 最近邻: **重新分配**, 绝不从上面那份里继承已填值 —— 继承的话写回的就是"精确 + 最近邻"的 + // 混合值, 而 method 只报 nearest, 属于谎报方法(2026-09-18 复查 D1: 精确模式失败即走到此处)。 + const near = new Array(nBound).fill(null); + const est = clauses.slice(1).map(c => c.start); + let lastUsed = -1; + for (let k = 0; k < nBound; k++) { + let best = -1, bestD = Infinity; + for (let g = lastUsed + 1; g < gaps.length; g++) { + const d = Math.abs(gaps[g].end - est[k]); + if (d < bestD && d <= 1.0) { bestD = d; best = g; } + } + if (best > -1) { near[k] = gaps[best].end; lastUsed = best; } + } + // 只有**每条边界都有实测**才叫 nearest(可写回); 有缺口的报 sparse, 只展示不写回 + return { method: near.every(v => v != null) ? 'nearest' : 'sparse', meas: near }; +} + +// ── 字幕关键帧(单一来源) ───────────────────────────────────────────── +// capture 的页面注入(生成 CSS)与窗口自检(算重叠)都从这里取数, 防两处算式漂移。 +// 偏移是"占整张时长的百分比"(0–100)。b 夹在 [a+0.5, 100]: 下一句开口太近时窗口至少留 0.5%, +// 最后一句起点太晚时不得越过 100%(CSS 关键帧选择器只认 [0%,100%], 越界整条被丢弃)。 +export function subtitleWindows(clauses, duration) { + const pct = x => Math.max(0, Math.min(100, (x / duration) * 100)); + return clauses.map((c, i) => { + const a = pct(c.start); + const b = Math.min(100, Math.max(pct(clauses[i + 1]?.start ?? duration), a + 0.5)); + return { a, b, isLast: i === clauses.length - 1 }; + }); +} + +// 每句一对关键帧: 0%→a 藏, a→p1 淡入, [p1,p2] 满亮, p2→b 淡出后藏到片尾。 +// 两个形状约束(2026-09-22 双回归的教训, unit 测试背书): +// ①同偏移不得声明两次 —— 同偏移多次声明时后者生效, 平台会被尾帧整个吃掉 +// (灰字幕根因: 末句 b=100 时 "100.000%,100%{opacity:0}" 撞掉 "100.000%{opacity:1}", 整句缓慢淡出); +// ②两端必须隐藏 —— fill both 下 finish() 后回到 opacity:0, 封面/静帧基底图靠它保持无字幕 +// (1.9.4 曾改成"末句无尾帧"保住①, 却让终值停在 1: 最后一句被烙进封面与基底)。 +// 于是末句平台收到 99.999%、尾帧单占 100%: 可见部分零淡出, 终值仍归 0。 +export function subtitleKeyframes(clauses, duration) { + return subtitleWindows(clauses, duration).map(({ a, b, isLast }, i) => { + const win = b - a; + const fin = Math.min(Math.max(0.15, win * 0.06), 0.8); + const fout = isLast ? 0 : Math.min(Math.max(0.15, win * 0.06), 0.8); + const p1 = isLast ? Math.min(a + fin, 99.999) : Math.min(a + fin, b); + const p2 = isLast ? 99.999 : Math.max(p1, b - fout); + return `@keyframes kit-sub-${i}{0%,${a.toFixed(3)}%{opacity:0}` + + `${p1.toFixed(3)}%,${p2.toFixed(3)}%{opacity:1}` + + `${b.toFixed(3)}%,100%{opacity:0}}`; + }).join(''); +} + +// 控制字符与双向覆盖字符剔除(2026-10-10 审计 L4): 字幕/转写文本来自不可信输入, +// OSC/ANSI 控制序列能把终端染色改标题, bidi 覆盖能让"IMDb"之类显示成别的词。 +// 注意: **不**动普通空格与制表, 也不做大小写或符号改写 —— 只剔掉不可见控制面。 +export function stripControlChars(s) { + return String(s ?? '') + .replace(/[\u202a-\u202e\u2066-\u2069\u200e\u200f\u202c\u202d\u202e]/g, '') + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '') + .replace(/\r\n?/g, '\n'); +} + +// ffmpeg concat 列表的路径守卫(2026-10-10 审计 L6): concat 解复用器用 av_get_token 读条目, +// 单引号里**放不下**一个单引号, shell 风格的 '\'' 在这里不是转义 —— 路径被悄悄改坏比报错更糟。 +// 路径 = 用户自选项目目录 + 受监 id, 撞上就明确失败, 不产出被改坏的列表文件。 +export function assertConcatPathSafe(p, { where = 'concat 列表' } = {}) { + const s = String(p); + if (/['\r\n]/.test(s)) { + throw new Error(`${where}: 路径里不能有单引号或换行(ffmpeg concat 列表无法安全表示): ${s}\n 请把项目目录或文件名里的 ' 去掉, 或在 script.json 里改用 transition:"xfade"`); + } + return s; +} + +// ── 字幕文本净化 + SRT 生成(单一来源, 2026-10-10 独立安全审计 H2/L4) ── +// clause 文案是**不可信输入**, 而 out/subs.srt 是要上传给平台的成品: 文本里带换行就能 +// 伪造成额外的 cue/时间码(实测 2 条 clause 产出 3 个时间码, 伪造块随视频上屏), 控制字符 +// 与 bidi 覆盖字符还能污染播放器/清单的显示。 +// 规则(**承重的一步是最后那条空白折叠** —— JS 的 \s 含 \r \n \u2028 \u2029, 所以换行/CR/ +// 段分隔符都在这一步被折成空格, cue 必然单行): bidi 与控制字符剔除、行内 "-->" 中和、限长。 +// 烧录字幕那条路不需要同一套处理: 那里的文本经 DOM textContent 写入, 没有任何标记语义。 +export function sanitizeCaptionText(s, { maxLen = 400 } = {}) { + return String(s ?? '') + .replace(/[\u202a-\u202e\u2066-\u2069\u200e\u200f]/g, '') + // 行内残留的 "-->" 也要中和: 结构已经安全, 但宽松的 SRT 解析器(ffmpeg 的 srt + // demuxer 会**扫行**找时间码)可能把正文里的时间码样式当新 cue 起点。 + // 字幕几乎不需要字面 "-->", 换成箭头语义不变、歧义消失。 + .replace(/-->/g, '→') + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLen); +} + +const srtTime = s => { + const ms = Math.round((s % 1) * 1000), h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')},${String(ms).padStart(3, '0')}`; +}; + +// timings.json → SRT 正文。测试直接调它, 所以**别**在这里做 CLI 副作用。 +export function buildSrt(timings) { + const cues = []; + let idx = 1, cum = 0; + for (const t of timings?.slides ?? []) { + if (Array.isArray(t.clauses)) { + for (let i = 0; i < t.clauses.length; i++) { + const c = t.clauses[i]; + const start = cum + c.start; + const end = cum + (t.clauses[i + 1]?.start ?? t.duration); + const main = sanitizeCaptionText(c.text); + const second = sanitizeCaptionText(c.text2); + if (!main && !second) continue; + if (end - start < 0.05) continue; + cues.push(`${idx++}\n${srtTime(start)} --> ${srtTime(end)}\n${main}${second ? '\n' + second : ''}\n`); + } + } + cum += t.duration; + } + return { text: cues.join('\n'), count: cues.length }; +} + +// ── 路径规范化(比较用) ───────────────────────────────────────────────── +// 取最深已存在祖先的 realpath 再拼回来: macOS 上 /var/... 与 /private/var/... 是同一目录的两种 +// 写法, 纯字符串比较会把"项目内的合法路径"误判成越界(二审 P2 在官方 CI 的 macOS 上实测踩到)。 +// 取不到 realpath 就返回原样(这是**比较**用的助手, 与 safeRel/assertContained 的 fail-closed 不同)。 +// 2026-09-18 复查 E5: fetch-official-images 里曾有一份同样的实现, 统一到这里。 +export function canonicalPath(p) { + const abs = path.resolve(p); + let probe = abs; + while (!fs.existsSync(probe)) { + const up = path.dirname(probe); + if (up === probe) return abs; + probe = up; + } + try { + const real = fs.realpathSync(probe); + return probe === abs ? real : path.join(real, path.relative(probe, abs)); + } catch { return abs; } +} + +// root 侧的规范化(safeRel / assertContained 共用一份 —— 逐字重复两份正是审查点名的地方): +// 规则**只有一条**: 必须与 probe 侧同规则, 否则同名不同写法的同一目录会被比成"越界"。 +// · root 已存在 → realpath(root); 解不开就 fail closed(解不开就不能证明它不是指向外面的链接) +// · root 尚不存在 → canonicalPath: 最深已存在祖先的 realpath + 未创建的尾部 +// (macOS 上 /var/… 与 /private/var/… 是同一目录的两种写法, 拿未规范化字符串去比 +// probe 的 realpath 会把项目内合法路径判成越界 —— 官方 CI 的 macOS 实测过) +// 返回 { exists, real }; exists 让调用方决定"root 未建时容忍祖先 probe"这条兜底是否生效。 +export function canonicalRoot(root, where = 'path') { + if (!fs.existsSync(root)) return { exists: false, real: canonicalPath(root) }; + try { + return { exists: true, real: fs.realpathSync(path.resolve(root)) }; + } catch { + console.error(`✗ ${where} 无法解析项目目录真实路径: ${root}(realpath 失败)`); + process.exit(1); + } +} + +// ── 位置参数(项目目录)的取法 ─────────────────────────────────────────── +// 各脚本的用法是 `node x.mjs <项目目录> [--flag 值]`, 但 `<项目目录>` 可以放在任意位置, +// 于是"第一个非 -- 开头的参数"这个取法会把**取值型 flag 的值**当目录 —— 实测 +// `init-project.mjs --topic 我的主题 <项目目录>` 会在 cwd 下静默建出 `我的主题/` 骨架, 而真正的 +// 项目目录一个字都没写。取值型 flag 名字全技能统一(见下表), 所以用一份清单跳过它们的值。 +// **只列真的带值的**: 布尔开关(--json / --allow-stale-css / --no-subs / --force / --both …)绝不能 +// 进来 —— 进来就会把它后面那个位置参数当"值"吃掉, 于是 <项目目录> 解析成 cwd: 实测 +// `capture --allow-stale-css <项目>` 会对着调用目录干活, 真正的项目一个字节都没被碰。 +// 同理 prep-image 的 --check/--crop 也**不列**: 它们的"参数"本来就是位置参数(一串文件名)。 +// tests/review-round3.test.mjs 有一条从 scripts 源码反扫 argv.includes('--x') 的守卫, 防以后再犯。 +// ── 字幕条(.kit-sub)几何 —— 唯一来源 ───────────────────────────────── +// capture 注入的字幕胶囊: 宽 = 画布宽 × 0.729167、左右 padding 34px、字号 40px, 其中字号与 +// padding 再乘 --sub-scale = clamp(W/1920, 0.75, 1.25)。行宽(全宽字) = (0.729167·W − 2·34·s)/(40·s·emPer): +// 中文 1080≈24 字、1920≈33 字、2560≈35 字 —— 2026-09-23 复核: 缩放带钳位, 按宽度线性外推是错的 +// (1.9.9 的 18×W/1080 在 1080 过报 27%、2560 漏报真折行)。emPer = 单字宽按 em 折算(中文全宽 1, 拉丁 ≈0.44)。 +// 2026-09-28 实测勘误(Mavis A/B): 这些行宽值是 width:max-content 生效后的**设计值** —— 旧实现 +// left:50% 的绝对定位可用宽度只有画布一半(1920→960px), max-width 永远够不到, 实际 22 字就折行; +// 修复后设计值与实测一致(见 subPillCss 的 width:max-content)。 +export const SUB_GEOMETRY = { boxW: 0.729167, padPx: 34, padYPx: 12, fontPx: 40, radiusPx: 14, bottomFrac: 0.077778, sub2Em: 0.74, baseW: 1920 }; +export const subScale = width => Math.min(1.25, Math.max(0.75, (width ?? SUB_GEOMETRY.baseW) / SUB_GEOMETRY.baseW)); +export function subLineCap(width, emPer = 1) { + const w = width ?? SUB_GEOMETRY.baseW; + const s = subScale(w); + return Math.max(8, Math.floor((SUB_GEOMETRY.boxW * w - 2 * SUB_GEOMETRY.padPx * s) / (SUB_GEOMETRY.fontPx * s * emPer))); +} + +// .kit-sub 胶囊样式(单一来源: capture 的页面注入与几何测试共用; 常量全部来自 SUB_GEOMETRY)。 +// width:max-content 是硬约束, 不是优化: left:50% 的绝对定位元素 shrink-to-fit 的可用宽度 +// = 包含块宽 − left 偏移 = 画布的一半(1920→960px), transform 只位移不改布局宽度 —— +// 于是 max-width: 1400px 永远够不到, 字幕提前一半折行、双行胶囊侵占到距底 230px 压住 +// 图注/免责(2026-09-28 A/B 实测: 22 句 8 句折错; 加 width:max-content 后 0 折行, 恒单行 170px)。 +// 通用教训: left:50%+translateX(-50%) 居中与 max-width 互斥; 要"居中+能撑满"必须显式 +// width:max-content, 或改用 left:0;right:0;margin-inline:auto;max-width:<设计值>。 +export function subPillCss(geom = SUB_GEOMETRY) { + return '.kit-sub{position:absolute;left:50%;bottom:calc(var(--stage-h, 1080px) * ' + geom.bottomFrac + ');transform:translateX(-50%);width:max-content;' + + 'max-width:calc(var(--stage-w, 1920px) * ' + geom.boxW + ');' + + 'background:var(--sub-bg, rgba(12,12,16,.62));color:var(--sub-fg, #fff);' + + 'border:var(--sub-ring, 0 solid transparent);' + + 'font-size:calc(' + geom.fontPx + 'px * var(--sub-scale, 1));line-height:1.5;' + + 'padding:calc(' + geom.padYPx + 'px * var(--sub-scale, 1)) calc(' + geom.padPx + 'px * var(--sub-scale, 1));' + + 'border-radius:calc(' + geom.radiusPx + 'px * var(--sub-scale, 1));text-align:center;opacity:0;z-index:9;pointer-events:none;' + + 'box-shadow:0 2px 12px rgba(0,0,0,.18)}' + + '.kit-sub-2{font-size:' + geom.sub2Em + 'em;opacity:.88;margin-top:6px;letter-spacing:.01em}'; +} + +// 字幕带顶部(距画布底的距离): check-slides 的闸门与 authoring 的安全区共用同一公式。 +// 单行 = bottom 偏移(不随 --sub-scale 缩放, CSS 里就是这么写的) + 胶囊高(字号×1.5 行高 +// + 上下 padding, 随缩放) + 2px 渲染余量(实测单行 86px vs 公式 84px)。 +// 双语(text2)再叠一行 .kit-sub-2(0.74em + 6px margin)。 +export function subBandTop(height, width, { bilingual = false } = {}) { + const s = subScale(width); + const g = SUB_GEOMETRY; + const pill = (g.padYPx * 2 + g.fontPx * 1.5) * s + 2; + const pill2 = pill + (g.fontPx * g.sub2Em * 1.5 + 6) * s + 2; + return Math.round(g.bottomFrac * (height ?? 1080) + (bilingual ? pill2 : pill)); +} + +// ── CLI 入口守卫(单一来源) ─────────────────────────────────────────── +// "被 import 不跑主流程, 直接执行才跑" 的判定要稳健: 两侧都取 realpath —— 经符号链接/挂载点 +// 调用时 argv[1] 是调用路径而 import.meta.url 是归一后的路径, 纯字符串比较恒假 → 脚本静默 +// no-op(exit 0 无输出; 2026-09-22 云沙箱实测, 排查花了 10 分钟)。 +// 另注: `node -e "import('...')"` 走的是 import 语义, 不跑 main(设计如此) —— CLI 请 `node <脚本> ...`。 +export function isMainModule(metaUrl, entry = process.argv[1]) { + if (!entry) return false; + const realpathOf = p => { try { return fs.realpathSync(path.resolve(p)); } catch { return path.resolve(p); } }; + const meta = fileURLToPath(metaUrl); + const eq = process.platform === 'win32' + ? (a, b) => a.toLowerCase() === b.toLowerCase() + : (a, b) => a === b; + if (eq(realpathOf(entry), realpathOf(meta))) return true; + // 同名兜底: realpath 归一不了的**未知**路径形态按直接执行处理 —— 这是拿"带提示的多跑一次" + // 换"静默不跑"(后者实测烧掉 10 分钟)。命中必须大声宣告, 不许再静默; 误判方向(被同名入口 + // import)本脚本随后也会响亮报错, 影响面只有 preview-page 一个消费者。 + if (eq(path.basename(entry), path.basename(meta))) { + console.error(`⚠ 入口判定经同名兜底(realpath 未归一: ${entry} vs ${meta}) — 按直接执行处理`); + return true; + } + return false; +} + +export const VALUE_FLAGS = new Set([ + '--topic', '--mode', '--dsf', '--ids', '--at', '--min', '--max-mb', '--get', '--out', '--out-dir', + '--file', '--format', '--from', '--url', '--base-url', '--api-key', '--language', + '--ratio', '--timestamp', '--anchor', '--transition', +]); +/** 所有位置参数(跳过取值型 flag 的值)。prep-image 这类"一串文件名"的用法用它 */ +export function positionals(argv) { + const out = []; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (typeof a !== 'string') continue; + if (a.startsWith('--')) { if (VALUE_FLAGS.has(a)) i++; continue; } + out.push(a); + } + return out; +} +/** 取位置参数(项目目录): 第一个非 flag 参数; 没有位置参数时用 dflt(默认 cwd) */ +export function positionalDir(argv, { dflt = '.' } = {}) { + const first = positionals(argv)[0]; + return path.resolve(first ?? dflt); +} +/** 取值型 flag: flag('--mode', 'still') —— 没有该 flag 时返回 dflt */ +export const flagValue = (argv, name, dflt) => { + const i = argv.indexOf(name); + return i > -1 ? argv[i + 1] : dflt; +}; + +// ── 切页方式的单一解析来源 ───────────────────────────────────────────── +// script.json 的 transition 允许三种写法: 省略 / "cut" / "xfade" / {type:"xfade",duration:0.4}。 +// 2026-09-18 复查 D6: check-slides 认裸字符串、build-video 只认对象里的 type —— 于是 +// `"xfade"`(字符串)在闸门里显示"交叉溶解", 成片却是硬切, 两边说法不一致还没有任何报错。 +// 现在两边都调这个函数: 同一个值必然得到同一个结论, 非法值都在自己的入口报错。 +export const XFADE_DEFAULT_DUR = 0.4; +export function readTransition(v, { where = 'script.transition' } = {}) { + const raw = v == null ? 'cut' : (typeof v === 'string' ? v : v?.type); + const type = typeof v === 'object' && v !== null && raw == null ? 'cut' : raw; + if (!['cut', 'xfade'].includes(type)) { + throw new Error(`${where} 非法: ${JSON.stringify(typeof v === 'object' ? v?.type : v)} — 只支持 "cut"(硬切, 默认) 或 "xfade"(交叉溶解), 或 {type,duration} 对象`); + } + const durRaw = typeof v === 'object' && v !== null ? (v.duration ?? XFADE_DEFAULT_DUR) : XFADE_DEFAULT_DUR; + const dur = Number(durRaw); + if (type === 'xfade' && (!Number.isFinite(dur) || dur <= 0 || dur > 2)) { + throw new Error(`${where}.duration 非法: ${JSON.stringify(durRaw)} — 需要 0–2 秒`); + } + return { type, dur: type === 'xfade' ? dur : 0, label: type === 'cut' ? '硬切 cut' : `交叉溶解 xfade ${dur}s` }; +} + +// ── tokens.css 受管块新鲜度闸门(entry point 共用) ────────────────────── +// 为什么要在 capture / build-video 入口硬拦: 受管块落后时**画面照出, 只是旧样式** —— +// 新类没有样式、旧规则按层叠压过新版, 全程没有任何报错, 成片出来才发现(audit 2026-09-18)。 +// 所以"陈旧"必须是阻断级, 并把修复命令直接给到手上; 确实要用旧 CSS 出片时显式 --allow-stale-css。 +const EXPECT_TOKENS = { text: generateTokensCss(), rev: TOKENS_REV }; +/** 受管区的新鲜度判据(技能当前版正文 + rev) —— check-slides / preview-page 与本模块共用同一份, + * 免得各自再拼一次 generateTokensCss()(散着拼就会出现"某个调用点忘了带 rev"这类漂移) */ +export const expectedTokens = () => EXPECT_TOKENS; +/** tokens.css 里所有非 ok 的受管块(缺/旧/重复/坏定界/区外残留副本) */ +export function cssStaleIssues(cssText) { + return kitStatuses(cssText, { expected: EXPECT_TOKENS }).filter(s => s.status !== 'ok'); +} +/** 读项目 tokens.css 并做新鲜度闸门: 落后 → 打印清单 + 修复命令并退出 1(除非 allowStale) */ +export function requireFreshCss(dir, { who, allowStale = false } = {}) { + const cssPath = path.join(dir, 'slides', 'tokens.css'); + if (!fs.existsSync(cssPath)) return ''; + // 扫描上限与 check-slides / init-project / preview-page 同一道门(声明在 limits.mjs): + // 少了这一步, 这里就是唯一一个会去读 6MB tokens.css 的入口。 + const size = fs.statSync(cssPath).size; + if (size > MAX_SCAN_BYTES) { + console.error(`✗ ${who}: slides/tokens.css 有 ${(size / 1e6).toFixed(1)}MB, 超过 ${MAX_SCAN_BYTES / 1e6}MB 上限, 拒绝扫描(正常项目 ≈15KB)`); + process.exit(1); + } + const css = fs.readFileSync(cssPath, 'utf8'); + const bad = cssStaleIssues(css); + if (!bad.length) return css; + const lines = bad.map(s => ` - ${s.label}: [${s.status}] ${s.detail}`); + if (allowStale) { + console.warn(`⚠ ${who}: slides/tokens.css 的受管块与技能当前版不一致(--allow-stale-css 已显式跳过闸门):\n${lines.join('\n')}`); + return css; + } + console.error(`✗ ${who}: slides/tokens.css 的受管块与技能当前版不一致 —— 画面会照出, 但用的是旧 CSS(新类静默无样式, 全程不报错):\n${lines.join('\n')}`); + console.error(` → 先修: node scripts/init-project.mjs ${dir} --upgrade-css(原地替换受管区, 不动区外的规则; 原件备份在 tokens.css.bak)`); + console.error(' → 确实要用项目里的旧 CSS 出片: 加 --allow-stale-css 显式跳过'); + process.exit(1); +} + +// 一次性走查 script.json 的全部路径派生字段(两个调用点: 每个 consumer 读入后) +export function validateScriptPaths(script, dir) { + const list = Array.isArray(script?.slides) ? script.slides : []; + for (const s of list) { + safeId(s.id, `slides[].id (${JSON.stringify(s.title ?? '')})`); + if (s.html !== undefined) safeRel(path.join(dir, 'slides'), s.html, { where: `slides[${s.id}].html` }); + if (s.audio !== undefined) safeRel(path.join(dir, 'audio'), s.audio, { where: `slides[${s.id}].audio` }); + } + const bgm = script?.bgm; + const bgmFile = typeof bgm === 'string' ? bgm : bgm?.file; + if (bgmFile !== undefined) safeRel(dir, bgmFile, { where: 'bgm.file' }); +} + +// timings.json 的 slides[].id 是 script.json 的二次传播, 消费侧同样校验 +export function validateTimingsIds(timings) { + const list = Array.isArray(timings?.slides) ? timings.slides : []; + for (const t of list) safeId(t.id, 'timings.slides[].id'); +} + +// ── Node 包解析(playwright) ────────────────────────────────────── +// 技能通常装在 ~/.claude/skills/ 或 ~/.openclaw/skills/ —— 不在项目树里, +// 而 Node 的 import 是按"脚本所在位置"向上找 node_modules 的, 于是会出现 +// "项目里明明装了 playwright 却报找不到"。这里按多个锚点依次尝试。 +import { createRequire } from 'node:module'; +import { pathToFileURL, fileURLToPath } from 'node:url'; + +const PKG_ANCHORS = () => { + const list = [process.env.KIT_PROJECT_DIR, process.cwd()]; + // 全局 npm root(全局装的 playwright 也能用) + for (const npm of IS_WIN ? ['npm.cmd', 'npm'] : ['npm']) { + try { + const r = spawnSync(npm, ['root', '-g'], { encoding: 'utf8', windowsHide: true }); + if (r.status === 0 && r.stdout) { list.push(r.stdout.trim()); break; } + } catch { /* ignore */ } + } + return [...new Set(list.filter(Boolean))]; +}; + +export async function loadPackage(name, { projectDir } = {}) { + // CJS 包(如 playwright)动态 import 后命名导出可能拿不到, 统一解包出真实模块对象 + const unwrap = mod => (mod && (mod.chromium || mod.default?.chromium)) ? (mod.chromium ? mod : mod.default) : mod; + // 1) 脚本自身位置(技能装在项目内, 或全局 node_modules 可被解析时) + try { const m = unwrap(await import(name)); if (m?.default !== undefined || m) return m; } catch { /* 继续找 */ } + // 2) 项目目录 / 调用目录 / 全局 npm root, 逐个用 createRequire 解析真实入口 + const anchors = [...new Set([projectDir, ...PKG_ANCHORS()].filter(Boolean))]; + for (const base of anchors) { + try { + const req = createRequire(path.join(base, 'package.json')); + const entry = req.resolve(name); + return unwrap(await import(pathToFileURL(entry).href)); + } catch { /* 试下一个 */ } + } + return null; +} diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/url-policy.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/url-policy.mjs new file mode 100644 index 00000000..e0225440 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/url-policy.mjs @@ -0,0 +1,355 @@ +// html2video-for-mcode · 网络 URL 策略(纯函数, 供 asr.mjs / fetch-official-images.mjs 与测试复用) +// 两个职责: +// 1. 出网端点白名单 —— asr.mjs 只把 API Key 发往官方 MiniMax 域; 换端点必须显式 +// --allow-any-endpoint(危险项), 防止被环境变量/提示词偷换后凭证外发。 +// 2. 抓图目标校验 —— fetch-official-images.mjs 的页面 URL、每个候选图 src、每一跳 +// 重定向都过同一套 host 策略: 拦 loopback / 链路本地(含云元数据)/私网 / 无点主机名, +// 只允许 http(s), file:// 需显式 --allow-file, 禁 userinfo。 +// host 判定是纯函数; DNS 相关(assertResolvedHost/checkedLookup/policyGet/startVetoProxy) +// 做 IO —— 那是它们的职责。抛 PolicyError(带 reason code)由调用方决定退出码与文案。 + +import dns from 'node:dns'; +import http from 'node:http'; +import https from 'node:https'; +import net from 'node:net'; + +export const OFFICIAL_ASR_BASES = ['https://api.minimaxi.com', 'https://api.minimax.io']; + +export class PolicyError extends Error { + constructor(reason, detail) { + super(`${reason}: ${detail ?? ''}`); + this.reason = reason; + this.detail = detail; + } +} + +// ── host 判定 ──────────────────────────────────────────────────── + +function ipv4ToLong(ip) { + const parts = ip.split('.').map(Number); + if (parts.length !== 4 || parts.some(p => !Number.isInteger(p) || p < 0 || p > 255)) return null; + return parts.reduce((a, p) => a * 256 + p, 0); +} + +// [网络基址, 前缀位数]; 计算一律走无符号, 避开 int32 符号位比较的坑 +// 2026-09-18 复查补全: 除私网/回环/链路本地外, 还要拦保留段与文档段 —— +// 前者常被内网设备使用, 后者虽不可路由, 但放行等于把"地址分类"这层判断交给下游。 +const BLOCKED_V4 = [ + [0b00000000, 8], // 0.0.0.0/8 "this host" + [10 << 24, 8], // 10.0.0.0/8 私网 + [100 << 24 | 64 << 16, 10], // 100.64.0.0/10 CGNAT(常被漏掉的内网段) + [127 << 24, 8], // 127.0.0.0/8 loopback + [169 << 24 | 254 << 16, 16], // 169.254.0.0/16 链路本地(含云元数据 169.254.169.254) + [172 << 24 | 16 << 16, 12], // 172.16.0.0/12 私网 + [192 << 24 | 0 << 16, 24], // 192.0.0.0/24 IETF 协议保留 + [192 << 24 | 0 << 16 | 2 << 8, 24], // 192.0.2.0/24 TEST-NET-1 + [192 << 24 | 168 << 16, 16],// 192.168.0.0/16 私网 + [198 << 24 | 18 << 16, 15], // 198.18.0.0/15 基准测试段 + [198 << 24 | 51 << 16 | 100 << 8, 24], // 198.51.100.0/24 TEST-NET-2 + [203 << 24 | 0 << 16 | 113 << 8, 24], // 203.0.113.0/24 TEST-NET-3 + [224 << 24, 4], // 224.0.0.0/4 组播 + [240 << 24, 4], // 240.0.0.0/4 保留(含 255.255.255.255) +].map(([base, bits]) => [base >>> 0, bits]); + +// host 是否属于禁止出网/抓取的地址(或形态) +export function isBlockedHost(hostname) { + // 尾点 FQDN 归一: `localhost.` 与 `localhost` 是同一个名字(RFC 1034 绝对名), + // 只判裸名会让 `http://localhost./` 直接穿过去(2026-09-18 实测放行) + const h = String(hostname ?? '').toLowerCase().replace(/^\[|\]$/g, '').replace(/\.+$/, ''); + if (!h) return true; + if (h.includes(':')) { // IPv6 + if (h === '::' || h === '::1') return true; // unspecified / loopback + if (/^f[cd][0-9a-f]{2}:/.test(h)) return true; // fc00::/7 私网(ULA) + if (/^fe[89ab][0-9a-f]:/.test(h)) return true; // fe80::/10 链路本地 + if (/^fec[0-9a-f]:/.test(h)) return true; // fec0::/10 站点本地(已废弃) + if (/^2001:0?db8:/.test(h)) return true; // 2001:db8::/32 文档段 + // 内嵌 IPv4 的三种载体都要换算回点分再判, 否则可借壳穿透: + // ::ffff:x:y IPv4-mapped(WHATWG URL 会把点分规范成 hex 组) + // 64:ff9b::x:y NAT64 6to4 2002:AABB:CCDD::/48(前 32 位就是 IPv4) + const embedded = (() => { + let m = /^::ffff:(.+)$/.exec(h); + if (m) return m[1]; + // 展开写法 0:0:0:0:0:ffff:7f00:1 —— 同一台机器的 net.isIP() 认它是 IPv6(6), + // 而它不以 ::ffff: 开头, 旧写法整条漏判(2026-10-10 审计 L5; 目前经 new URL() 规范化 + // 后不可达, 属于潜伏缺陷, 但这是安全函数, 补上更便宜)。 + m = /^(?:0*:)*ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(h); + if (m) return `${m[1]}:${m[2]}`; + m = /^64:ff9b::(.+)$/.exec(h); + if (m) return m[1]; + m = /^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4})/.exec(h); + if (m) { + const hi = parseInt(m[1], 16) >>> 0, lo = parseInt(m[2], 16) >>> 0; + return `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`; + } + return null; + })(); + if (embedded) { + const m = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(embedded); // 两个 hex 组 = 32 位 IPv4 + if (m) { + const hi = parseInt(m[1], 16) >>> 0, lo = parseInt(m[2], 16) >>> 0; + return isBlockedHost(`${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`); + } + return isBlockedHost(embedded); // 已经是点分或还带别的形态, 继续判 + } + return false; + } + const ipv4 = ipv4ToLong(h); + if (ipv4 !== null) { + for (const [base, prefixBits] of BLOCKED_V4) { + const mask = prefixBits === 0 ? 0 : (0xffffffff << (32 - prefixBits)) >>> 0; + if (((ipv4 & mask) >>> 0) === ((base & mask) >>> 0)) return true; + } + return false; + } + // 非字面量: 无点主机名(localhost / 内网裸名 / NetBIOS)拦下; 内网风格后缀拦下 + if (!h.includes('.')) return true; + if (['.localhost', '.local', '.internal', '.localdomain', '.home.arpa'].some(sfx => h.endsWith(sfx))) return true; + return false; +} + +// ── 出网端点(asr) ──────────────────────────────────────────────── + +// base 必须是官方端点; 允许用 --base-url/env 在两个官方域之间切换(region 对齐), +// 任何其他值都拒 —— 除非 allowAny(显式危险项)。http 一律拒绝(官方端点全是 https)。 +export function assertAsrEndpoint(base, { allowAny = false } = {}) { + const normalized = String(base ?? '').replace(/\/+$/, ''); + if (OFFICIAL_ASR_BASES.includes(normalized)) return normalized; + if (allowAny) { + let u; + try { u = new URL(normalized); } catch { throw new PolicyError('bad-url', normalized); } + if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new PolicyError('bad-scheme', normalized); + console.warn(`⚠ --allow-any-endpoint: API Key 将发往非官方端点 ${normalized}(自担风险, 仅用于自建网关/测试)`); + return normalized; + } + throw new PolicyError('endpoint-not-allowed', + `${normalized} 不在 ASR 端点白名单(${OFFICIAL_ASR_BASES.join(' / ')})。确需自定义请显式加 --allow-any-endpoint`); +} + +// ── 抓图目标(fetch-official-images) ───────────────────────────── + +// 校验一个要访问的 URL; allowFile 时放行 file: 协议(本地离线页是文档化场景) +export function assertFetchableUrl(raw, { allowFile = false, where = 'url' } = {}) { + let u; + try { u = new URL(raw); } catch { throw new PolicyError('bad-url', `${where}: ${raw}`); } + if (u.protocol === 'file:') { + if (!allowFile) throw new PolicyError('file-not-allowed', `${where}: file:// 需要显式 --allow-file`); + if (u.username || u.password) throw new PolicyError('userinfo', where); + return u; + } + if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new PolicyError('bad-scheme', `${where}: ${u.protocol}`); + if (u.username || u.password) throw new PolicyError('userinfo', `${where}: 带凭据的 URL`); // 凭据会进日志/请求头 + if (isBlockedHost(u.hostname)) throw new PolicyError('blocked-host', `${where}: ${u.hostname} 是内网/本地/元数据地址`); + return u; +} + +// 重定向跳转是否允许(逐跳调用; 每一跳都要过同一套校验) +export function assertRedirectTarget(location, { where = 'redirect' } = {}) { + return assertFetchableUrl(location, { allowFile: false, where }); +} + +export const MAX_REDIRECTS = 5; + +// 解析后再验一层(2026-09-18 二审 P1): 字符串层拦得住字面 IP, 拦不住"公网域名解析回内网" +// (DNS rebinding / 内网域名)。**每个真实请求**都要过这里 —— 页面导航、它的重定向、浏览器子资源、 +// 逐跳下载 —— 否则策略只覆盖了"最初输入的那个 URL"。lookup 可注入(测试用假解析器, 无需真 DNS)。 +// 2026-09-25 六审 blocker 1: 解析失败/空答案不再放行 —— 旧实现 catch 后 return 等于 +// "解析不了就放行"(fail open), 钉死的语义应该是 fail closed。 +export async function assertResolvedHost(urlStr, { lookup, where = 'url' } = {}) { + let h; + try { h = new URL(urlStr).hostname; } catch { return; } + if (!h || !h.includes('.')) return; // 裸主机名/非法 URL 已被字符串层拦掉 + const doLookup = lookup || (async name => dns.promises.lookup(name, { all: true })); + let addr; + try { + addr = await doLookup(h); + } catch (e) { + throw new PolicyError('dns-error', `${where}: ${h} 解析失败(${e?.message ?? e}) — fail closed, 拒绝继续`); + } + const list = (Array.isArray(addr) ? addr : [addr]).filter(a => a && typeof a.address === 'string'); + if (!list.length) throw new PolicyError('dns-error', `${where}: ${h} 解析结果为空 — fail closed`); + const bad = list.find(a => isBlockedHost(a.address)); + if (bad) throw new PolicyError('dns-rebinding', `${where}: ${h} 解析到被拦地址 ${bad.address}(DNS rebinding 或内网域名? 换官方 CDN 直链)`); +} + +// ── 连接期绑定(2026-09-25 六审 blocker 1) ───────────────────────── +// 预查(assertResolvedHost)与真正建连之间隔着一次**独立解析** —— 攻击面就是这道缝: +// 预查时公网、建连时私网(DNS rebinding), 或两台解析器答案不一致。唯一权威的检查点是 +// socket 建连用的那次 lookup。本节三个件: +// checkedLookup 把否决权挂到 http(s)/net 的 lookup 选项上(Node lookup 兼容签名) +// policyGet 配套出网 GET(不跟重定向, 由调用方逐跳复核), 建连带否决 +// startVetoProxy 浏览器(Chromium 自己解析, 注入不了 lookup)的全部出网收进本地隧道, +// 上游建连仍走同一否决 + +// Node lookup 签名 (hostname, options, callback) 的策略包装: 解析失败按失败处理(建连即败, +// fail closed); 结果里**任何一个**地址被拦就整体拒绝 —— Happy-Eyeballs 会在地址列表里挑, +// "部分放行"等于没拦。resolve 可注入(与 assertResolvedHost 的 lookup 同一形态, 测试免真 DNS)。 +export function checkedLookup(resolve) { + const doResolve = resolve || ((name, opts) => dns.promises.lookup(name, { family: opts?.family ?? 0, hints: opts?.hints, all: true })); + return (hostname, options, callback) => { + doResolve(hostname, options).then(raw => { + const all = (Array.isArray(raw) ? raw : [raw]).filter(a => a && typeof a.address === 'string'); + const fam = options?.family; + const pool = (fam === 4 || fam === 6) ? all.filter(a => a.family === fam) : all; + if (!pool.length) return callback(new Error(`dns: ${hostname} 无可用解析结果${fam ? `(family ${fam})` : ''}`)); + const bad = pool.find(a => isBlockedHost(a.address)); + if (bad) return callback(new PolicyError('dns-rebinding', `建连: ${hostname} 解析到被拦地址 ${bad.address}(连接期否决)`)); + // 只行使否决权, 不改变解析语义: 原样回传让 net 自己挑地址 + if (options?.all) return callback(null, pool); + callback(null, pool[0].address, pool[0].family); + }, callback); + }; +} + +// 出网 GET, 建连那次解析经 checkedLookup 否决。不跟重定向 —— 重定向由调用方逐跳复核 +// (fetch-official-images 的 followRedirects), 每一跳的建连都被单独否决。 +// 返回与 followRedirects 的 get 契约一致的归一化结果。maxBytes 在**流式读取时**掐断, +// 不信用 content-length(恶意服务器会谎报)。createConnection 仅测试注入假上游用。 +export function policyGet(rawUrl, { headers = {}, timeoutMs = 30000, maxBytes = Infinity, lookup, createConnection } = {}) { + return new Promise((resolve, reject) => { + let u; + try { u = new URL(rawUrl); } catch { return reject(new PolicyError('bad-url', String(rawUrl))); } + // 建连**之前**的字符串层 host 策略(七审 blocker 1): IP 字面量目标根本不走 lookup —— + // Node 直接连 IP, checkedLookup 对它是空转, 于是 "http://127.0.0.1:8080/" 这类目标 + // 没有任何否决点(生产 startVetoProxy 的绝对形式 HTTP 分支经本地 origin 实测拿到 200 + // 且 origin 实收请求)。这一层是所有 policyGet 调用方的**唯一**字面量入口。 + try { assertFetchableUrl(u.href, { where: 'policyGet' }); } + catch (e) { return reject(e); } + const mod = u.protocol === 'https:' ? https : u.protocol === 'http:' ? http : null; + if (!mod) return reject(new PolicyError('bad-scheme', String(u.protocol))); + const req = mod.request(u, { + headers, + lookup: checkedLookup(lookup), + ...(createConnection ? { createConnection } : { agent: false }), // agent:false = 不留池化连接(工具进程要能自然退出) + }, res => { + const chunks = []; let total = 0; + res.on('data', c => { + total += c.length; + if (total > maxBytes) { res.destroy(new Error(`响应超限: 已收 ${(total / 1048576).toFixed(0)}MB 超过上限(读取中途掐断, 不信用 content-length)`)); return; } + chunks.push(c); + }); + res.on('end', () => resolve({ + status: res.statusCode, + location: res.headers.location, + headers: { ...res.headers }, + header: n => res.headers[String(n).toLowerCase()] || '', + arrayBuffer: async () => Buffer.concat(chunks), + })); + res.on('error', reject); + }); + req.setTimeout(timeoutMs, () => req.destroy(new Error(`请求超时(${timeoutMs}ms): ${u.host}`))); + req.on('error', reject); + req.end(); + }); +} + +// 本地否决代理: Chromium 的页面导航/子资源自己解析 DNS, 没有 lookup 钩子可注入 —— +// 把它的全部出网收进这条隧道才能把策略绑到"实际建连的那次解析"上: +// CONNECT(https) → 上游建连带 checkedLookup, 建连前还做一次字符串层+解析预否决(可命名告警) +// 绝对形式 http → 经 policyGet 出网(连接期否决), 原样回贴 +// 只监听 127.0.0.1(它本身就是策略咽喉, 不是出网目标)。connect 可注入(测试假上游)。 +const HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade']; +export async function startVetoProxy({ lookup, connect = net.connect, maxBytes = 30 * 1024 * 1024 } = {}) { + const sockets = new Set(); + const track = s => { sockets.add(s); s.on('close', () => sockets.delete(s)); return s; }; + const server = http.createServer((req, res) => { + if (!req.url || !/^https?:\/\//i.test(req.url)) { res.writeHead(400); return res.end('veto-proxy: 只收绝对形式 URL'); } + // 每个代理 HTTP 请求都先过 host 策略(与下面 CONNECT 分支同形的可命名预否决): 字面量 + // 目标不触发 checkedLookup, 缺这道预否决时 policyGet 之前的绝对形式分支等于没有闸门。 + try { assertFetchableUrl(req.url, { where: '代理 HTTP' }); } + catch (e) { res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }); return res.end(`veto-proxy: ${e.message}`); } + const fwd = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (k === 'host' || HOP_BY_HOP.includes(k)) continue; // Host 由目标 URL 决定, 逐跳头不转发 + fwd[k] = v; + } + policyGet(req.url, { headers: fwd, lookup, createConnection: connect, maxBytes, timeoutMs: 45000 }) + .then(async r => { + const out = { ...r.headers }; + delete out['transfer-encoding']; delete out['connection']; // 已整包缓冲, 传输头自算 + res.writeHead(r.status, out); + res.end(await r.arrayBuffer()); + }) + .catch(e => { + try { res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }); res.end(`veto-proxy: ${e.message}`); } + catch { /* 客户端已断 */ } + }); + }); + server.on('connect', (req, clientSocket, head) => { + // 可命名的拒绝: 隧道没建起来时回一个 502 + 原因, 而不是静默掐断 —— 客户端拿到的是 + // "代理拒绝了并说明理由", 排查不必去猜。绝对形式 HTTP 分支同样带名字, 两个分支对齐。 + const fail = (why) => { + const body = `veto-proxy: ${why ?? 'refused'}`; + try { + clientSocket.write('HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain; charset=utf-8\r\n' + + `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`); + } catch { /* 客户端已断 */ } + clientSocket.destroy(); + }; + const m = /^(\[[^\]]+\]|[^:]+):(\d+)$/.exec(req.url || ''); + if (!m) return fail(); + const host = m[1], port = parseInt(m[2], 10); + const bare = host.replace(/^\[|\]$/g, ''); + const literal = /^[\d.]+$/.test(bare) || (bare.includes(':') && /^[0-9a-f:]+$/i.test(bare)); + (async () => { + if (isBlockedHost(bare)) throw new PolicyError('blocked-host', `代理 CONNECT: ${bare}`); + // 名字(非字面 IP)先做一次可命名的预否决; 权威检查点仍是下面建连里的 checkedLookup + if (!literal && (bare.includes('.') || bare.includes(':'))) { + await assertResolvedHost(`https://${bare}/`, { lookup, where: '代理 CONNECT' }); + } + const up = track(connect({ host, port, lookup: checkedLookup(lookup), noDelay: true }, () => { + track(clientSocket); + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head && head.length) up.write(head); + up.pipe(clientSocket); + clientSocket.pipe(up); + })); + up.on('error', e => fail(`上游连接失败: ${e?.message ?? e}`)); + clientSocket.on('error', () => up.destroy()); + up.on('close', () => clientSocket.destroy()); + clientSocket.on('close', () => up.destroy()); + })().catch(e => fail(e?.message ?? e)); + }); + server.on('connection', track); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + return { + port: server.address().port, + close: async () => { for (const s of sockets) s.destroy(); await new Promise(r => server.close(r)); }, + }; +} + +// ── 落盘文件名(抓图下载) ───────────────────────────────────────── + +const WIN_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + +// alt/标题转成安全文件名: 去路径分隔符与控制字符、压长度、挡 Windows 保留名与开头点/空格 +export function sanitizeFilename(name, { maxLen = 30, fallback = 'official' } = {}) { + let s = String(name ?? '') + .replace(/[\u0000-\u001f\u007f]/g, '') + .replace(/[\\/:*?"<>|]+/g, '-') + .replace(/\s+/g, '-') + .replace(/^[.\s-]+/, '') // 不允许开头点/空格/连字符(点开头=隐藏文件) + .replace(/[.\s-]+$/, '') + .slice(0, maxLen) + .replace(/^[.\s-]+|[.\s-]+$/g, ''); // 截断后再清一次边缘 + if (!s || WIN_RESERVED.test(s)) s = fallback; + return s; +} + +// 图片 URL → 落盘文件名(下载零散 URL 时用): 取路径末段, 扩展名优先用 URL 自己的, +// 没有/不像扩展名就按 content-type 推, 都不行给 .bin(交给人眼确认后再登记)。 +const EXT_BY_TYPE = { + 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', + 'image/gif': '.gif', 'image/svg+xml': '.svg', 'image/avif': '.avif', +}; + +export function imageNameFromUrl(raw, contentType = '') { + let pathname = '', base = ''; + try { + pathname = new URL(raw).pathname; + base = decodeURIComponent(pathname.split('/').filter(Boolean).pop() || ''); + } catch { /* 非法 URL: 走 fallback */ } + const extInPath = /^\.[a-z0-9]{1,5}$/i.test(pathname.slice(pathname.lastIndexOf('.'))) ? pathname.slice(pathname.lastIndexOf('.')).toLowerCase() : ''; + const ext = extInPath || EXT_BY_TYPE[String(contentType).split(';')[0].trim().toLowerCase()] || '.bin'; + const stem = base.replace(/\.[a-z0-9]{1,5}$/i, ''); + return sanitizeFilename(stem, { maxLen: 30, fallback: 'official' }) + ext; +} diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/chart-kit.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/chart-kit.test.mjs new file mode 100644 index 00000000..30eb76f8 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/chart-kit.test.mjs @@ -0,0 +1,77 @@ +// 图表工具箱: 模板里必须真带着这些规则, 且"关动效 = 终态"这条不变量成立。 +// 覆盖过三类静默失败: 缺 keyframes → 条形不生长; 静态默认 ≠ 终值 → 关动效后是空的; +// 柱高相对整列算 → 被 flex-shrink 压回, 柱高不再等于数值。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runSkill, tmpdir } from './helpers.mjs'; +import { CHART_CSS, hasChartKit } from '../scripts/chart-css.mjs'; + +const proj = tmpdir(); +const r = runSkill('init-project.mjs', [proj, '--topic', 'T']); +assert.equal(r.status, 0, r.stderr); +const css = fs.readFileSync(path.join(proj, 'slides', 'tokens.css'), 'utf8'); + +describe('图表工具箱 · 模板内容', () => { + test('五个新动效类都在, 且关键帧都声明了 opacity(否则入场后永远隐形)', () => { + for (const cls of ['fx-grow-w', 'fx-grow-h', 'fx-sweep', 'fx-count', 'fx-dot']) { + assert.ok(css.includes('.' + cls + ' {') || css.includes('.' + cls + '{'), `缺 .${cls}`); + } + const names = ['fx-grow-w-kf', 'fx-grow-h-kf', 'fx-sweep-kf', 'fx-count-kf', 'fx-dot-kf']; + for (const n of names) { + const i = css.indexOf('@keyframes ' + n); + assert.ok(i > -1, `缺关键帧 ${n}`); + const body = css.slice(i, css.indexOf('\n}', i)); + assert.match(body, /opacity\s*:/, `${n} 缺 opacity(该元素会永远隐形)`); + } + }); + + test('注册属性存在(没有它, 扫出/数字滚动无法被逐帧 seek)', () => { + assert.match(css, /@property\s+--pv\s*\{\s*syntax:\s*''/); + assert.match(css, /@property\s+--cnt\s*\{\s*syntax:\s*''/); + }); + + test('静态默认 = 终值(关掉动效后画面停在完成态, 不是空的)', () => { + assert.match(css, /\.fx-sweep\s*\{[^}]*--pv:\s*var\(--p-to/); + assert.match(css, /\.fx-count\s*\{[^}]*--cnt:\s*var\(--n-to/); + assert.match(css, /\.fx-grow-w\s*\{[^}]*width:\s*var\(--w/); + }); + + test('柱状三层结构齐全(绘图区 / 高度基准 / 标签), 网格在绘图区里', () => { + assert.match(css, /\.chart-plot-cell\s*\{/, '缺 .chart-plot-cell'); + assert.match(css, /\.chart-plot-cell\.grid\s*\{[^}]*repeating-linear-gradient/, '网格必须画在绘图区上(否则与柱高两把尺子)'); + assert.match(css, /\.chart-bar-wrap\s*\{/, '缺 .chart-bar-wrap(柱高的百分比基准)'); + assert.match(css, /\.chart-bar-wrap\s+\.chart-val\s*\{[^}]*position:\s*absolute/, '数值要贴各自柱顶'); + assert.match(css, /\.chart-col\s*\{[^}]*grid-template-rows/, '列必须是 [绘图区 1fr] + [轴标签]'); + assert.match(css, /\.chart-plot-cell\s+\.chart-target\s*\{/, '目标虚线要落在绘图区里(才有 0 与轴上限的参照)'); + }); + + test('横向条形的可比性: 行是三列定宽 grid, 数值外置不影响轨道长度', () => { + assert.match(css, /\.chart-row\s*\{[^}]*grid-template-columns:\s*var\(--lbl-w[^;]*var\(--val-w/, '行必须是定宽三列'); + assert.match(css, /\.chart-val\.out\s*\{/, '缺 .chart-val.out(条外数值)'); + assert.match(css, /\.chart-bar\.dim\s+\.chart-val\s*\{/, '弱化条上的数值要自动改墨色(白字读不出来)'); + }); +}); + +describe('图表工具箱 · 老项目升级', () => { + test('--upgrade-css 会把图表工具箱补进老 tokens.css, 且幂等', () => { + const old = tmpdir(); + fs.mkdirSync(path.join(old, 'slides'), { recursive: true }); + fs.writeFileSync(path.join(old, 'slides', 'tokens.css'), ':root { --accent: #111; }\n'); + assert.equal(hasChartKit(':root { --accent: #111; }'), false); + const r1 = runSkill('init-project.mjs', [old, '--upgrade-css']); + assert.equal(r1.status, 0, r1.stderr); + const after = fs.readFileSync(path.join(old, 'slides', 'tokens.css'), 'utf8'); + assert.match(after, /@property\s+--pv/); + assert.match(after, /fx-sweep-kf/); + assert.ok(after.startsWith(':root { --accent: #111; }'), '原内容必须在前'); + const r2 = runSkill('init-project.mjs', [old, '--upgrade-css']); + assert.equal(fs.readFileSync(path.join(old, 'slides', 'tokens.css'), 'utf8'), after, '第二次跑不得再追加'); + assert.ok(r2.stdout.includes('无需升级')); + }); + + test('CHART_CSS 自身通过 hasChartKit 判定(防止判定与内容脱节)', () => { + assert.equal(hasChartKit(CHART_CSS), true); + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/connect-binding.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/connect-binding.test.mjs new file mode 100644 index 00000000..0f9b9e41 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/connect-binding.test.mjs @@ -0,0 +1,432 @@ +// 连接期绑定(2026-09-25 六审 blocker 1): 两件事 —— ① 解析失败从"放行"改为 fail closed; +// ② 策略从"请求前预查"绑到"实际建连的那次解析"上(预查时公网、建连时私网 = DNS rebinding 的缝)。 +// 判据锚在"被拒目标收到 0 个连接"这类几何事实上, 不看文案; 假解析器注入走 url-policy 的 +// lookup/resolve 接缝, 不需要真 DNS。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import net from 'node:net'; +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runSkill, tmpdir, SCRIPTS } from './helpers.mjs'; +import { assertResolvedHost, checkedLookup, policyGet, startVetoProxy, PolicyError } from '../scripts/url-policy.mjs'; + +const PUB = [{ address: '93.184.216.34', family: 4 }]; // example.com 的公网地址, 只当"公网样子"用 +const LOOPBACK = [{ address: '127.0.0.1', family: 4 }]; + +const listen = (srv, host = '127.0.0.1') => new Promise(r => srv.listen(0, host, r)); + +describe('fail closed: 解析失败/空答案不再是放行(六审 blocker 1 前半)', () => { + test('assertResolvedHost: 解析器抛错 → 拒绝(旧实现静默放行)', async () => { + await assert.rejects( + () => assertResolvedHost('https://gone.example.com/x.png', { lookup: async () => { throw new Error('ENOTFOUND gone.example.com'); } }), + e => e instanceof PolicyError, + ); + }); + test('assertResolvedHost: 空答案 → 拒绝', async () => { + await assert.rejects( + () => assertResolvedHost('https://empty.example.com/x', { lookup: async () => [] }), + e => e instanceof PolicyError, + ); + }); +}); + +describe('checkedLookup(连接期否决, Node lookup 兼容签名)', () => { + // 断言不能写进 lookup 的 callback 里直接抛 —— 抛错会落进 .then 吞成 unhandled rejection, + // 测试假绿(否决被临时拿掉时当场抓到); 一律先 await 捕获结果再断言。 + const call = (lk, host, opts) => new Promise(res => lk(host, opts, (...a) => res(a))); + + test('公网地址原样放行(单地址与 all 两种回传形态)', async () => { + const lk = checkedLookup(async () => PUB); + const [e1, list] = await call(lk, 'ok.example.com', { all: true }); + assert.equal(e1, null); assert.deepEqual(list, PUB); + const [e2, addr, fam] = await call(lk, 'ok.example.com', {}); + assert.equal(e2, null); assert.equal(addr, '93.184.216.34'); assert.equal(fam, 4); + }); + test('解析到被拦地址 → callback 收到 dns-rebinding, 建连必败', async () => { + const lk = checkedLookup(async () => LOOPBACK); + const [e] = await call(lk, 'rebind.example.com', { all: true }); + assert.ok(e instanceof PolicyError, '应抛 PolicyError, 实得: ' + e); + assert.equal(e.reason, 'dns-rebinding'); + }); + test('解析器报错 → 错误透传(建连失败, 不降级放行)', async () => { + const lk = checkedLookup(async () => { throw new Error('ESERVFAIL'); }); + const [e] = await call(lk, 'x.example.com', { all: true }); + assert.match(String(e?.message), /ESERVFAIL/); + }); +}); + +describe('policyGet: 建连那次解析被否决(rebinding 的权威检查点)', () => { + test('连接期解析回环 → 拒绝, 目标服务器收到 0 个请求(真走 net.lookup 路径, 无注入建连)', async () => { + let hits = 0; + const srv = http.createServer((q, s) => { hits++; s.writeHead(200); s.end('x'); }); + await listen(srv); + try { + await assert.rejects( + () => policyGet(`http://rebind.example.test:${srv.address().port}/a.png`, { lookup: async () => LOOPBACK }), + e => /被拦地址|dns-rebinding/.test(e.message), + ); + assert.equal(hits, 0, '否决必须发生在连接之前 —— 服务器一个请求都不能收到'); + } finally { srv.close(); } + }); + test('管道贯通: 状态/头/体回传正确, lookup 确实接线到建连层', async () => { + const srv = http.createServer((q, s) => { s.writeHead(200, { 'content-type': 'image/png' }); s.end('PNGDATA'); }); + await listen(srv); + let sawLookup = false; + try { + const res = await policyGet(`http://ok.example.test:${srv.address().port}/a.png`, { + lookup: async () => PUB, + // createConnection 仅替换传输(测试假上游): lookup 必须作为建连参数传进来, 否则否决没接线 + createConnection: opts => { sawLookup = typeof opts.lookup === 'function'; return net.connect({ host: '127.0.0.1', port: srv.address().port }); }, + }); + assert.equal(sawLookup, true, 'lookup 必须传进建连层'); + assert.equal(res.status, 200); + assert.equal(res.header('content-type'), 'image/png'); + assert.equal(String(await res.arrayBuffer()), 'PNGDATA'); + } finally { srv.close(); } + }); + test('响应超限在读取中途掐断(不信用 content-length)', async () => { + const srv = http.createServer((q, s) => { s.writeHead(200); s.end('A'.repeat(64)); }); // 不写长度=分块, 全量送达 + await listen(srv); + try { + await assert.rejects( + () => policyGet(`http://ok.example.test:${srv.address().port}/big`, { + lookup: async () => PUB, + createConnection: () => net.connect({ host: '127.0.0.1', port: srv.address().port }), + maxBytes: 16, + }), + e => /超限/.test(e.message), + ); + } finally { srv.close(); } + }); +}); + +describe('startVetoProxy: 浏览器全部出网的否决隧道', () => { + const lookupByName = map => async name => map[name] ?? PUB; + // 注入的 connect 必须保留第二参 connectListener —— 生产路径 net.connect(options, cb) 靠它回调 + const connectTo = port => (opts, cb) => net.connect({ host: '127.0.0.1', port }, cb); + + function connectHandshake(port) { + // 模拟浏览器侧: 裸 socket 发 CONNECT / 绝对形式请求由各用例自己拼 + return net.connect({ host: '127.0.0.1', port }); + } + + test('CONNECT 公网名 → 隧道建立且双向通(回显上游)', async () => { + const echo = net.createServer(sock => sock.on('data', d => sock.write(d))); + await listen(echo); + const proxy = await startVetoProxy({ lookup: lookupByName({}), connect: connectTo(echo.address().port) }); + try { + const verdict = await new Promise((resolve, reject) => { + const c = connectHandshake(proxy.port); + let phase = 'head', buf = ''; + const timer = setTimeout(() => { c.destroy(); reject(new Error('隧道握手/回显超时')); }, 5000); + c.on('connect', () => c.write('CONNECT ok.example.com:443 HTTP/1.1\r\n\r\n')); + c.on('data', d => { + buf += d.toString('latin1'); + if (phase === 'head') { + if (!buf.includes('\r\n\r\n')) return; + assert.ok(/^HTTP\/1\.1 200/.test(buf), `握手应 200: ${buf.slice(0, 40)}`); + phase = 'echo'; c.write('PING'); + } else if (buf.endsWith('PING')) { clearTimeout(timer); c.destroy(); resolve('tunnel-ok'); } + }); + c.on('error', e => { clearTimeout(timer); reject(e); }); + }); + assert.equal(verdict, 'tunnel-ok'); + } finally { proxy.close(); echo.close(); } + }); + + test('CONNECT 解析回环(rebinding) → 建连前拒绝, 上游收到 0 个连接', async () => { + const echo = net.createServer(sock => sock.on('data', d => sock.write(d))); + await listen(echo); + let upstreamConnects = 0; + const proxy = await startVetoProxy({ + lookup: lookupByName({ 'rebind.example.com': LOOPBACK }), + connect: opts => { upstreamConnects++; return connectTo(echo.address().port)(opts); }, + }); + try { + const seen = await new Promise(resolve => { + let buf = ''; + const c = connectHandshake(proxy.port); + c.on('connect', () => c.write('CONNECT rebind.example.com:443 HTTP/1.1\r\n\r\n')); + c.on('data', d => { buf += d.toString(); c.destroy(); resolve(buf); }); + c.on('error', () => resolve(buf)); // destroy 可能以 ECONNRESET 形态到达 + c.on('close', () => resolve(buf)); + setTimeout(() => { c.destroy(); resolve(buf); }, 5000); + }); + // 1.9.17: CONNECT 的拒绝从"静默掐断"改为可命名的 502 + 原因(不建隧道, 原因写明) + assert.match(seen, /^HTTP\/1\.\d 502/, `拒绝应是 502, 实得: ${seen.slice(0, 120)}`); + assert.match(seen, /dns-rebinding|连接期否决/, `拒绝要点名连接期否决: ${seen.slice(0, 200)}`); + assert.ok(!/200 Connection Established/.test(seen), '不许建立隧道'); + assert.equal(upstreamConnects, 0, '否决必须发生在上游建连之前'); + } finally { proxy.close(); echo.close(); } + }); + +test('CONNECT 字面内网地址(字符串层) → 拒绝可命名(1.9.17: 从静默掐断改为 502 + 原因)', async () => { + let upstreamConnects = 0; + const proxy = await startVetoProxy({ lookup: lookupByName({}), connect: opts => { upstreamConnects++; return net.connect(opts); } }); + try { + const seen = await new Promise(resolve => { + let buf = ''; + const c = connectHandshake(proxy.port); + c.on('connect', () => c.write('CONNECT 169.254.169.254:80 HTTP/1.1\r\n\r\n')); + c.on('data', d => { buf += d.toString(); c.destroy(); resolve(buf); }); + c.on('error', () => resolve(buf)); + c.on('close', () => resolve(buf)); + setTimeout(() => { c.destroy(); resolve(buf); }, 5000); + }); + assert.match(seen, /^HTTP\/1\.\d 502/, `拒绝应是可读的 502, 实得: ${seen.slice(0, 120)}`); + assert.match(seen, /blocked-host/, `拒绝要点名原因: ${seen.slice(0, 200)}`); + assert.ok(!/200 Connection Established/.test(seen), '不许建立隧道'); + assert.equal(upstreamConnects, 0, '否决必须发生在上游建连之前'); + } finally { proxy.close(); } + }); + + test('绝对形式 http: 连接期解析回环 → 502 且源站 0 请求(生产路径, 未注入建连)', async () => { + let hits = 0; + const srv = http.createServer((q, s) => { hits++; s.writeHead(200); s.end('page'); }); + await listen(srv); + const proxy = await startVetoProxy({ lookup: lookupByName({ 'rebind.example.test': LOOPBACK }) }); + try { + const res = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { req.destroy(); reject(new Error('代理绝对形式请求超时')); }, 5000); + const req = http.get({ host: '127.0.0.1', port: proxy.port, path: `http://rebind.example.test:${srv.address().port}/page` }, r => { + const chunks = []; r.on('data', c => chunks.push(c)); r.on('end', () => { clearTimeout(timer); resolve({ status: r.statusCode, body: Buffer.concat(chunks).toString() }); }); + }); + req.on('error', e => { clearTimeout(timer); reject(e); }); + }); + assert.equal(res.status, 502, '回源被否决应回 502'); + assert.equal(hits, 0, '源站一个请求都不能收到'); + } finally { proxy.close(); srv.close(); } + }); + + test('绝对形式 http: 公网名 → 经代理透传, 状态/头/体一致(传输注入假上游)', async () => { + let hits = 0; + const srv = http.createServer((q, s) => { hits++; s.writeHead(200, { 'content-type': 'text/html' }); s.end('ok'); }); + await listen(srv); + const proxy = await startVetoProxy({ lookup: lookupByName({}), connect: connectTo(srv.address().port) }); + try { + const res = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { req.destroy(); reject(new Error('代理绝对形式请求超时')); }, 5000); + const req = http.get({ host: '127.0.0.1', port: proxy.port, path: `http://ok.example.test:${srv.address().port}/page` }, r => { + const chunks = []; r.on('data', c => chunks.push(c)); r.on('end', () => { clearTimeout(timer); resolve({ status: r.statusCode, type: r.headers['content-type'], body: Buffer.concat(chunks).toString() }); }); + }); + req.on('error', e => { clearTimeout(timer); reject(e); }); + }); + assert.equal(res.status, 200); + assert.equal(res.type, 'text/html'); + assert.equal(res.body, 'ok'); + assert.equal(hits, 1); + } finally { proxy.close(); srv.close(); } + }); +}); + +// CLI 的 --url 负向接线(127.0.0.1 字面量 + localtest.me 解析回环)由 review-round2 的既有用例 +// 覆盖 —— 它们走的就是换装后的 openUrl→policyGet 路径; 本文件不重复造环境依赖的判据 +// (.invalid 之类"必然解析失败"的域名在不同 DNS 环境行为不一, 不作测试依据)。 + +describe('渲染 Chromium 也必须进否决隧道(第 23 轮收口, 结构钉)', () => { + test('capture.mjs 的浏览器启动带 proxy(去隧道 = slide 子资源可直连私网, 第 23 轮半做项)', () => { + const src = fs.readFileSync(path.join(SCRIPTS, 'capture.mjs'), 'utf8'); + assert.match(src, /startVetoProxy/, 'capture 必须启动否决代理'); + assert.match(src, /proxy:\s*\{\s*server:\s*`http:\/\/127\.0\.0\.1:\$\{veto\.port\}`/, 'launch 必须把浏览器流量收进隧道'); + }); +}); + +describe('浏览器模式接线(带否决代理启动; 需 playwright/chromium, 缺则按能力 skip)', () => { + test('file:// 页面 --allow-file --list --json 正常列出候选(代理在链上不碍事)', async t => { + const d = tmpdir(); + // 1×1 PNG: naturalWidth=1, 过滤器(默认 --min 0)必放行 + fs.writeFileSync(path.join(d, 'shot.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')); + fs.writeFileSync(path.join(d, 'page.html'), 'hero'); + const pageUrl = 'file:///' + path.join(d, 'page.html').replace(/\\/g, '/'); + const r = await runSkill('fetch-official-images.mjs', [pageUrl, '--allow-file', '--json'], { cwd: d }); + const out = String(r.stdout + r.stderr); + if (/未找到 playwright|chromium 未安装|Executable doesn't exist|Looks like Playwright/.test(out)) return t.skip('无 playwright/chromium'); + assert.equal(r.status, 0, out.slice(0, 400)); + assert.match(out, /shot\.png/, '候选里应列出页面图片: ' + out.slice(0, 300)); + }); +}); + +// 七审 blocker 1: 生产 startVetoProxy 的**绝对形式 HTTP** 分支此前不进 host 策略 —— 而 +// IP 字面量目标 Node 不调用 lookup, checkedLookup 对它是空转。本地 origin 实测拿到 200 且 +// 实收请求。判据钉两件事: ①被拒目标收到 **0 个请求**; ②502 正文点名 blocked-host —— +// 否则"目标不可达"同样表现为 502, 会让断言假绿。 +describe('七审 blocker 1 · 生产否决代理: IP 字面量目标 0 请求', () => { + const viaProxy = (proxyPort, target) => new Promise(resolve => { + const req = http.request({ + host: '127.0.0.1', port: proxyPort, path: target, // 绝对形式 URL = 代理请求行 + headers: { host: new URL(target).host }, + }, r => { + const chunks = []; + r.on('data', c => chunks.push(c)); + r.on('end', () => resolve({ status: r.statusCode, body: Buffer.concat(chunks).toString('utf8') })); + }); + req.on('error', e => resolve({ status: 'ERR', body: e.message })); + req.end(); + }); + const countingOrigin = async () => { + const hits = { n: 0 }; + const srv = http.createServer((req, res) => { hits.n++; res.writeHead(200); res.end('SECRET'); }); + await listen(srv); + return { srv, hits, port: srv.address().port }; + }; + + test('loopback 字面量 → 0 请求, 且拒绝来自策略(正文点名 blocked-host)', async () => { + const { srv, hits, port } = await countingOrigin(); + const proxy = await startVetoProxy({}); // 生产参数: 不注入 connect/lookup + try { + const r = await viaProxy(proxy.port, `http://127.0.0.1:${port}/secret`); + assert.equal(r.status, 502, r.body); + assert.match(r.body, /blocked-host/, `拒绝必须来自 host 策略, 实得: ${r.body.slice(0, 200)}`); + assert.equal(hits.n, 0, '被拒目标必须收到 0 个请求'); + } finally { await proxy.close(); srv.close(); } + }); + + test('私网/元数据/IPv6/零/裸名 字面量 → 全部点名 blocked-host(不可达目标也要证明是策略拦的)', async () => { + const proxy = await startVetoProxy({}); + try { + for (const u of ['http://192.168.1.1/admin', 'http://169.254.169.254/latest/meta-data/', + 'http://[::1]:9/x', 'http://0.0.0.0:9/x', 'http://10.0.0.5/internal', 'http://localhost:9/x', + 'http://100.64.0.1/x', 'http://198.18.0.1/x']) { + const r = await viaProxy(proxy.port, u); + assert.equal(r.status, 502, `${u} → ${r.status} ${r.body.slice(0, 120)}`); + assert.match(r.body, /blocked-host/, `${u} 的 502 必须点名 host 策略, 实得: ${r.body.slice(0, 160)}`); + } + } finally { await proxy.close(); } + }); + + test('主机名解析回环 → 仍由 checkedLookup 在建连期否决(0 请求, 正文点名 dns-rebinding)', async () => { + const { srv, hits, port } = await countingOrigin(); + const proxy = await startVetoProxy({ lookup: async () => LOOPBACK }); // 真建连路径 + try { + const r = await viaProxy(proxy.port, `http://rebind.example.test:${port}/secret`); + assert.equal(r.status, 502, r.body); + assert.match(r.body, /dns-rebinding|连接期否决/, r.body.slice(0, 200)); + assert.equal(hits.n, 0, '被拒目标必须收到 0 个请求'); + } finally { await proxy.close(); srv.close(); } + }); + + test('正向对照: 放行的主机名仍能走通绝对形式 HTTP 分支(别把管道焊死)', async () => { + const upstream = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('upstream-ok'); }); + await listen(upstream); + const upPort = upstream.address().port; + const proxy = await startVetoProxy({ + lookup: async () => PUB, + connect: () => net.connect({ host: '127.0.0.1', port: upPort }), // 假上游: 只换传输 + }); + try { + const r = await viaProxy(proxy.port, 'http://cdn.example.com/a.png'); + assert.equal(r.status, 200, r.body.slice(0, 200)); + assert.match(r.body, /upstream-ok/); + } finally { await proxy.close(); upstream.close(); } + }); +}); + +// ── 自审轮(1.9.17): 把两层否决各自钉住 ────────────────────────────────── +// policyGet 的 assertFetchableUrl(where:'policyGet') 与代理 HTTP 分支的预否决(where:'代理 HTTP') +// 此前各自撤掉都测不出来(只撤一处, 另一处照样拦)。两处 where 标签不同 → 断言各自的名字, +// 两层就都成了载荷: 少一层, 对应断言立刻红。 +describe('自审 1.9.17 · 两层否决各自有名字、各自被钉住', () => { + test('policyGet 直连字面量 → 拒, 理由带 policyGet 这一层的名字', async () => { + let hits = 0; + const srv = http.createServer((req, res) => { hits++; res.writeHead(200); res.end('SECRET'); }); + await listen(srv); + const port = srv.address().port; + try { + await assert.rejects( + () => policyGet(`http://127.0.0.1:${port}/secret`), + e => { + assert.equal(e.reason, 'blocked-host', `应点名 blocked-host, 实得 ${e.reason}`); + assert.match(e.message, /policyGet/, `拒绝应带本层名字, 实得: ${e.message}`); + return true; + }, + ); + assert.equal(hits, 0, '被拒目标必须收到 0 个请求'); + } finally { srv.close(); } + }); + + test('代理明文 HTTP 分支的拒绝带"代理 HTTP"这一层的名字(撤掉这层预否决则断言红)', async () => { + const proxy = await startVetoProxy({}); + try { + const r = await new Promise(resolve => { + const req = http.request({ + host: '127.0.0.1', port: proxy.port, path: 'http://127.0.0.1:9/x', + headers: { host: '127.0.0.1:9' }, + }, res => { + const c = []; + res.on('data', d => c.push(d)); + res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(c).toString() })); + }); + req.on('error', e => resolve({ status: 'ERR', body: e.message })); + req.end(); + }); + assert.equal(r.status, 502, r.body); + assert.match(r.body, /代理 HTTP/, `应带代理层名字, 实得: ${r.body.slice(0, 160)}`); + assert.match(r.body, /blocked-host/); + } finally { await proxy.close(); } + }); + + test('CONNECT 分支的拒绝同样可命名(不是静默掐断)', async () => { + const proxy = await startVetoProxy({}); + try { + // 用裸 socket: Node 的 http.request 对 method:'CONNECT' 走 connect 事件而非 response, + // 挂在 response 上会永远等不到(挂死过一次)。 + const r = await new Promise(resolve => { + const sock = net.connect(proxy.port, '127.0.0.1', () => { + sock.write('CONNECT 169.254.169.254:80 HTTP/1.1\r\nHost: 169.254.169.254:80\r\n\r\n'); + }); + let buf = ''; + const done = () => { + const head = buf.split('\r\n\r\n')[0] || ''; + const status = Number(/HTTP\/1\.\d (\d{3})/.exec(head)?.[1] ?? 0); + const body = buf.slice(head.length + 4); + sock.destroy(); + resolve({ status, body }); + }; + sock.setTimeout(4000, done); + sock.on('data', d => { buf += d.toString(); if (buf.includes('\r\n\r\n')) done(); }); + sock.on('error', e => resolve({ status: 'ERR', body: e.message })); + }); + assert.equal(r.status, 502, `CONNECT 拒绝应是 502, 实得 ${r.status} ${r.body.slice(0, 120)}`); + assert.match(r.body, /blocked-host/, `CONNECT 的拒绝也要点名原因: ${r.body.slice(0, 160)}`); + } finally { await proxy.close(); } + }); +}); + +// 浏览器侧不变量: 幻灯片里的 http://127.0.0.1 子资源**必须**经过否决代理(Chromium 对 loopback +// 有隐式代理绕过; 目前 Playwright + 显式 proxy 实测是走代理的, 但那是**依赖默认值**的行为 —— +// 这里把它钉成契约, 默认值一变 CI 立刻报)。 +describe('自审 1.9.17 · 浏览器的 loopback 子资源必须经过否决代理(需 chromium)', () => { + test('capture 形态的浏览器: 页面里的 loopback 图片 → origin 收到 0 个请求', async t => { + const pw = await import('node:module').then(m => m.createRequire(import.meta.url)); + let chromium; + for (const p of ['playwright', 'C:/Users/mjc39/.minimax/skills/node_modules/playwright']) { + try { ({ chromium } = pw(p)); break; } catch { /* 下一处 */ } + } + if (!chromium) return t.skip('无 playwright'); + const dir = tmpdir(); + let hits = 0; + const origin = http.createServer((req, res) => { hits++; res.writeHead(200, { 'content-type': 'image/png' }); res.end('x'); }); + await listen(origin); + const port = origin.address().port; + const slide = path.join(dir, 'slide.html'); + fs.writeFileSync(slide, ``); + const veto = await startVetoProxy({}); + let browser; + try { + browser = await chromium.launch({ headless: true, proxy: { server: `http://127.0.0.1:${veto.port}` } }); + const ctx = await browser.newContext({ viewport: { width: 320, height: 240 } }); + const p2 = await ctx.newPage(); + await p2.goto('file:///' + slide.replace(/\\/g, '/')); + await p2.waitForTimeout(1200); + assert.equal(hits, 0, '浏览器必须把 loopback 子资源交给否决代理(而不是绕过它直连)'); + } catch (e) { + if (/Executable doesn't exist|browserType.launch|Looks like Playwright/i.test(String(e?.message ?? e))) + return t.skip('chromium 未安装'); + throw e; + } finally { + if (browser) await browser.close().catch(() => {}); + await veto.close(); origin.close(); + } + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/cover-transition.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/cover-transition.test.mjs new file mode 100644 index 00000000..fd5571f2 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/cover-transition.test.mjs @@ -0,0 +1,120 @@ +// 1.6.0 回归测试: 封面内嵌与首帧非黑、切页无黑帧(硬切/溶解)、语速闸门、单级/多级都能出片 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { findTool, loadPackage, safeId } from '../scripts/tools.mjs'; +import { runSkill, tmpdir } from './helpers.mjs'; + +const FFMPEG = findTool('ffmpeg'); +const FFPROBE = findTool('ffprobe'); +const ffprobe = args => (spawnSync(FFPROBE, ['-v', 'error', ...args], { encoding: 'utf8', windowsHide: true }).stdout || '').trim(); + +// 造一个 n 张的项目(每张 1 段静音 + 多级入场), 返回项目目录 +function mkVideoProject({ n = 3, stagesPerSlide = 2, speed = 1.1, transition = null, audioSec = 3 } = {}) { + const proj = tmpdir(); + assert.equal(runSkill('init-project.mjs', [proj, '--topic', 'CoverTest']).status, 0); + for (let i = 1; i <= n; i++) { + const id = String(i).padStart(2, '0'); + const g = spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', '-t', String(audioSec), + '-c:a', 'libmp3lame', '-b:a', '64k', '-y', path.join(proj, 'audio', `${id}.mp3`)], { windowsHide: true }); + assert.equal(g.status, 0, g.stderr ?? ''); + } + const slides = []; + for (let i = 1; i <= n; i++) { + const id = String(i).padStart(2, '0'); + const cls = []; + for (let k = 1; k <= stagesPerSlide; k++) cls.push({ stage: k, text: `${'\u5b57'.repeat(6)}${i}-${k}\u3002` }); + slides.push({ id, layout: 'statement', html: `${id}-s.html`, audio: `${id}.mp3`, title: `\u7b2c${i}\u5f20`, clauses: cls }); + const layers = Array.from({ length: stagesPerSlide }, (_, k) => + `

\u5c42 ${k + 1}

`).join(''); + fs.writeFileSync(path.join(proj, 'slides', `${id}-s.html`), + ` +

\u7b2c ${i} \u5f20

${layers}
`); + } + const scriptPath = path.join(proj, 'script.json'); + const script = JSON.parse(fs.readFileSync(scriptPath, 'utf8')); + script.slides = slides; + script.speed = { default: speed, first: speed, last: speed }; + if (transition) script.transition = transition; + else delete script.transition; + fs.writeFileSync(scriptPath, JSON.stringify(script, null, 2)); + return proj; +} + +const meanLuma = (file, t) => { + const o = spawnSync(FFMPEG, ['-hide_banner', '-ss', String(t), '-i', file, '-frames:v', '1', + '-vf', 'signalstats,metadata=print:file=-', '-f', 'null', '-'], { encoding: 'utf8', windowsHide: true }); + const m = /YAVG=([0-9.]+)/.exec((o.stdout || '') + (o.stderr || '')); + return m ? parseFloat(m[1]) : null; +}; + +describe('1.6.0 · 封面与切页(需 ffmpeg+playwright)', () => { + const needTools = async t => { + if (!FFMPEG || !FFPROBE) { t.skip('无 ffmpeg/ffprobe'); return false; } + const pw = await loadPackage('playwright'); + if (!pw) { t.skip('无 playwright'); return false; } + try { const b = await pw.chromium.launch({ headless: true }); await b.close(); } + catch { t.skip('chromium 未安装'); return false; } + return true; + }; + + test('封面: capture 出 preview/cover.png, 成片内嵌 attached_pic + 导出 out/cover.png, 首帧非黑', async t => { + if (!await needTools(t)) return; + const proj = mkVideoProject({ n: 3 }); + assert.equal(runSkill('plan-timings.mjs', [proj]).status, 0); + assert.equal(runSkill('capture.mjs', [proj, '--mode', 'motion']).status, 0); + const cover = path.join(proj, 'preview', 'cover.png'); + assert.ok(fs.existsSync(cover), 'capture 要为第 1 张出 preview/cover.png'); + assert.equal(runSkill('build-video.mjs', [proj]).status, 0); + const final = path.join(proj, 'out', 'final.mp4'); + assert.ok(fs.existsSync(path.join(proj, 'out', 'cover.png')), '要导出 out/cover.png 供平台上传'); + // 内嵌封面流 + const disp = ffprobe(['-select_streams', 'v', '-show_entries', 'stream_disposition=attached_pic', '-of', 'csv=p=0', final]); + assert.match(disp, /1/, `成片里要有 attached_pic 流, 实际: ${JSON.stringify(disp)}`); + // 首帧不是黑的(封面溶解后 ≈ 封面本身; 阈值取远高于黑电平 16) + const l0 = meanLuma(final, 0.05); + assert.ok(l0 != null && l0 > 60, `首帧亮度 ${l0} 太低 —— 分享出去的缩略图会很难看`); + // 封面尺寸 = 画布尺寸 + const sz = ffprobe(['-select_streams', 'v', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', path.join(proj, 'out', 'cover.png')]); + assert.equal(sz.split(',').slice(0, 2).join('x'), '1920x1080', `封面尺寸应为画布尺寸, 实际 ${sz}`); + }); + + test('切页: 硬切与溶解都不经过黑场, 总时长都等于 timings.total', async t => { + if (!await needTools(t)) return; + const proj = mkVideoProject({ n: 3 }); + assert.equal(runSkill('plan-timings.mjs', [proj]).status, 0); + assert.equal(runSkill('capture.mjs', [proj, '--mode', 'motion']).status, 0); + const timings = JSON.parse(fs.readFileSync(path.join(proj, 'build', 'timings.json'), 'utf8')); + const final = path.join(proj, 'out', 'final.mp4'); + const cuts = []; + let cum = 0; + for (let i = 0; i < timings.slides.length - 1; i++) { cum += timings.slides[i].duration; cuts.push(cum); } + for (const mode of ['cut', 'xfade']) { + const r = runSkill('build-video.mjs', [proj, ...(mode === 'xfade' ? ['--transition', 'xfade'] : [])]); + assert.equal(r.status, 0, `${mode}: ${r.stdout}${r.stderr}`); + const dur = parseFloat(ffprobe(['-show_entries', 'format=duration', '-of', 'csv=p=0', final])); + assert.ok(Math.abs(dur - timings.total) <= 0.25, `${mode} 总时长 ${dur} 应≈${timings.total}`); + for (const c of cuts) { + const l = meanLuma(final, Math.max(0, c - 0.1)); + assert.ok(l != null && l > 60, `${mode} 在切页 ${c.toFixed(2)}s 检出黑帧(亮度 ${l})`); + } + } + }); + + test('语速闸门: 实测语速与 script.speed 对不上要告警; 单级入场也能出片', async t => { + if (!await needTools(t)) return; + // 语速 1.1 → 期望 ≈5.3 字/s; 这里给 3s 静音 + 13 字 → ≈4.3 字/s, 偏离 ~18% 时不报, + // 把 speed 写成 1.4(期望 6.7)就必然报警 —— 用它验证闸门真的接上了 script.speed + const proj = mkVideoProject({ n: 1, stagesPerSlide: 1, speed: 1.4, audioSec: 3 }); + const r = runSkill('plan-timings.mjs', [proj]); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout + r.stderr, /script\.speed=1\.4/, '应在汇总行回显 script.speed'); + assert.match(r.stdout + r.stderr, /实测语速|超出常规区间/, '语速与 speed 对不上时必须告警(而不是等用户听出来)'); + assert.equal(runSkill('capture.mjs', [proj, '--mode', 'motion']).status, 0); + const b = runSkill('build-video.mjs', [proj]); + assert.equal(b.status, 0, b.stdout + b.stderr); // 单级(一次性出现)的 slide 也要能正常出片 + assert.ok(fs.existsSync(path.join(proj, 'out', 'final.mp4'))); + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/css-kit.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/css-kit.test.mjs new file mode 100644 index 00000000..056efa1c --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/css-kit.test.mjs @@ -0,0 +1,468 @@ +// CSS 工具箱受管块: "块在但内容旧"必须被发现并原地修复。 +// 这是"改了 CSS 但项目里没生效"的根因回归 —— 2026-09-18 双代理审计定案: 旧判定只探 +// 1–2 个标记字符串, 项目补过一次后就永远报"无需升级"(源模块后续改动永不传播); +// 追加式升级还会压掉项目端覆写并留下整份重复块。本文件逐条锁死这些行为。 +// +// 2026-09-18 第二轮(整段受管化 + 三条静默路径): 生成物主体也进了受管区(`tokens`), 于是 +// 判定多了 duplicate(同 id 多块 / 整份重复) / broken(开标记无配对收尾) / legacy-outside +// (受管区之外残留旧副本) 三个状态; 升级顺序也定死: 先落 tokens 区, 再清区外副本 —— +// 反过来的话, 裸文本主体里的工具箱原文会被当区外副本删掉, 于是 indexOf 失配 → 又多插一份、 +// 旧主体还留在后面按层叠压过新版(本项目实测踩到, 见"整段受管化 · 顺序与层叠"用例)。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runSkill, mkproj, tmpdir, SCRIPTS, LEGACY_TOKENS } from './helpers.mjs'; + +const { KITS, KIT_REV, TOKENS_ID, wrapKit, wrapTokens, findAllBlocks, findBlock, outsideRegions, + kitStatuses, tokensStatus, applyKitUpgrade, MAX_SCAN_BYTES } = + await import('file://' + path.join(SCRIPTS, 'css-kit.mjs').replace(/\\/g, '/')); +const { TOKENS_REV, generateTokensCss } = + await import('file://' + path.join(SCRIPTS, 'tokens-template.mjs').replace(/\\/g, '/')); +const { TABLE_CSS } = + await import('file://' + path.join(SCRIPTS, 'table-css.mjs').replace(/\\/g, '/')); +const { NOFX_CSS } = + await import('file://' + path.join(SCRIPTS, 'nofx-css.mjs').replace(/\\/g, '/')); +const { CHART_CSS } = + await import('file://' + path.join(SCRIPTS, 'chart-css.mjs').replace(/\\/g, '/')); + +const EXPECT = { text: generateTokensCss(), rev: TOKENS_REV }; +const allStatus = css => kitStatuses(css, { expected: EXPECT }); +const initProj = () => { + const p = tmpdir(); + const r = runSkill('init-project.mjs', [p, '--topic', 'T']); + assert.equal(r.status, 0, r.stderr); + return p; +}; +const tokensOf = p => path.join(p, 'slides', 'tokens.css'); +const read = p => fs.readFileSync(tokensOf(p), 'utf8'); +const write = (p, css) => fs.writeFileSync(tokensOf(p), css); +// 把三个受管块的 rev 全部换成假值 = 模拟"源模块后来改了, 项目里还是旧 rev" +const withStaleRev = css => { + let out = css; + for (const k of KITS) { + const b = findBlock(out, k.id); + out = out.slice(0, b.start) + wrapKit(k.id, k.css, '0000deadbeef0000') + out.slice(b.end); + } + return out; +}; +// 老格式(老版 init 直出): 只有整段生成物外面没有 tokens 定界注释, **内嵌的 kit 子块照样带定界** +// (老模板把三个工具箱块放在主体里)。所以这里只抹掉 tokens 那一对, 不动 kit 的。 +const stripTokensTag = css => css + .replace(/\/\* >>> html2video:tokens rev=[0-9a-f]{8,64} >>> \*\/\n?/, '') + .replace(/\/\* <<< html2video:tokens <<< \*\/\n?/, ''); + +describe('受管块 · 单元', () => { + test('KIT_REV 是内容哈希(64 位 hex), wrapKit/findBlock 可往返', () => { + assert.match(KIT_REV, /^[0-9a-f]{64}$/); + assert.match(TOKENS_REV, /^[0-9a-f]{64}$/); + for (const k of KITS) { + const wrapped = wrapKit(k.id, k.css); + const b = findBlock(wrapped, k.id); + assert.ok(b, `${k.id} 应能找回受管块`); + assert.equal(b.rev, KIT_REV); + assert.equal(b.body.trim(), k.css.trim()); + assert.equal(findBlock(`/* 无关 */\n.foo{}`, k.id), null); + } + const t = findAllBlocks(wrapTokens(generateTokensCss(), TOKENS_REV), TOKENS_ID); + assert.equal(t.length, 1); + assert.equal(t[0].rev, TOKENS_REV); + assert.equal(t[0].body.trim(), EXPECT.text.trim()); + }); + + test('内容变了 rev 必须变(判定依据是内容, 不是"出现过没有")', () => { + const r1 = applyKitUpgrade(`:root{}\n${KITS.map(k => wrapKit(k.id, k.css, 'a'.repeat(64))).join('\n')}\n`); + assert.equal(r1.actions.filter(a => a.action === 'replaced').length, 3, '假 rev 的块全部判旧'); + const mutated = KITS[2].css.replace('72px', '60px'); + const st = kitStatuses(`${wrapKit(KITS[2].id, mutated)}\n`); + assert.equal(st[2].status, 'stale', '块内容与 rev 不符应判 stale(被手工改过)'); + }); +}); + +describe('受管块 · 新项目直出', () => { + test('init 生成的 tokens.css 带 tokens 受管区(内含三个工具箱), --check-css 绿', () => { + const p = initProj(); + const css = read(p); + for (const k of KITS) { + const b = findBlock(css, k.id); + assert.ok(b, `缺 ${k.id} 受管块`); + assert.equal(b.rev, KIT_REV, `${k.id} rev 应为当前`); + } + const t = findAllBlocks(css, TOKENS_ID); + assert.equal(t.length, 1, 'tokens 受管区恰好一个'); + assert.equal(t[0].rev, TOKENS_REV, 'tokens 区 rev 应为当前'); + assert.equal(t[0].body.trim(), EXPECT.text.trim(), 'tokens 区内容应与当前模板逐字节一致'); + assert.equal((css.match(/rev=[0-9a-f]{64}/g) ?? []).length, 4, 'rev 标记恰好四处(tokens + 三个工具箱; 头部注释不携带 rev)'); + assert.ok(allStatus(css).every(s => s.status === 'ok'), JSON.stringify(allStatus(css).map(s => s.id + ':' + s.status))); + const r = runSkill('init-project.mjs', [p, '--check-css']); + assert.equal(r.status, 0, r.stderr); + }); +}); + +describe('受管块 · 块在但旧(旧判定的漏检区)', () => { + test('块 rev 落后 → 原地替换: 行数不变、无重复、.bak 先落盘', () => { + const p = initProj(); + const fresh = read(p); + write(p, withStaleRev(fresh)); + const before = read(p); + assert.notEqual(before, fresh); + const r = runSkill('init-project.mjs', [p, '--upgrade-css']); + assert.equal(r.status, 0, r.stderr); + const after = read(p); + assert.equal(after, fresh, '按 rev 原地替换后应与最新模板逐字节一致'); + assert.equal(fs.readFileSync(tokensOf(p) + '.bak', 'utf8'), before, '写前必须先备份'); + assert.equal((after.match(/@property --pv/g) ?? []).length, 1, '不得出现重复块'); + }); + + test('块被手工改坏(rev 没变) → --check-css 报 stale, 升级按源重写', () => { + const p = initProj(); + const tampered = read(p).replace('.tbl tbody tr { height: 72px; }', '.tbl tbody tr { height: 60px; }'); + assert.notEqual(tampered, read(p), '前置: 确实改到了受管块内的规则'); + write(p, tampered); + const chk = runSkill('init-project.mjs', [p, '--check-css']); + assert.equal(chk.status, 1, '块内容与 rev 不符必须被发现'); + assert.ok(chk.stderr.includes('手工改过'), chk.stderr); + runSkill('init-project.mjs', [p, '--upgrade-css']); + assert.ok(read(p).includes('.tbl tbody tr { height: 72px; }'), '升级后按技能当前源恢复'); + }); + + test('用户覆写在受管块之后 → 升级不碰它(旧追加式会把它压掉)', () => { + const p = initProj(); + const fresh = read(p); + const override = '\n/* 项目端覆写: 行高按这台投影调过 */\n.tbl tbody tr { height: 96px; }\n'; + write(p, withStaleRev(fresh) + override); + runSkill('init-project.mjs', [p, '--upgrade-css']); + const after = read(p); + assert.ok(after.trimEnd().endsWith('.tbl tbody tr { height: 96px; }'), '覆写必须原样留在文件尾'); + assert.equal((after.match(/height: 96px/g) ?? []).length, 1, '覆写不得被复制或挪动'); + const b = findBlock(after, 'table'); + assert.ok(b.start < after.indexOf('height: 96px'), '受管块仍在覆写之前(覆写继续生效)'); + }); + + test('旧判定核心缺陷不复存在: 升级过的项目在源模块再改后仍能继续升级(审计实验 D)', () => { + const p = initProj(); + runSkill('init-project.mjs', [p, '--upgrade-css']); // 第一次: 无需升级 + // 模拟源模块又改了: 把项目里的块回滚成"旧 rev + 旧内容"(少一条规则) + let css = read(p); + const k = KITS.find(x => x.id === 'chart'); + const b = findBlock(css, 'chart'); + const older = wrapKit('chart', k.css.replace(/\/\* 左侧刻度列[\s\S]*?padding-right: var\(--sp-2\); \}/, ''), '1111222233334444'); + css = css.slice(0, b.start) + older + css.slice(b.end); + write(p, css); + assert.ok(!read(p).includes('.chart-ticks'), '前置: .chart-ticks 已不在项目里'); + const chk = runSkill('init-project.mjs', [p, '--check-css']); + assert.equal(chk.status, 1, '旧 rev 必须报落后(旧实现此处报"无需升级")'); + runSkill('init-project.mjs', [p, '--upgrade-css']); + assert.ok(read(p).includes('.chart-ticks'), '源里的新规则必须传播进项目'); + }); +}); + +describe('受管块 · 无定界的老文件迁移', () => { + test('内容已是当前版(老 init 直出的裸文本) → check 绿, 升级原地包裹且与新模板逐字节一致', () => { + const p = initProj(); + const fresh = read(p); + const legacy = stripTokensTag(fresh); + assert.ok(!/>>> html2video:tokens rev=/.test(legacy), '前置: tokens 定界已去掉(头部注释里的示例文字无 rev, 不算)'); + assert.ok(/>>> html2video:nofx rev=/.test(legacy), '前置: 内嵌的 kit 子块照旧留着(老格式就是这样)'); + write(p, legacy); + const chk = runSkill('init-project.mjs', [p, '--check-css']); + assert.equal(chk.status, 0, '内容当前就该是 ok, 不该骚扰'); + assert.ok((chk.stdout + chk.stderr).includes('老格式'), '要说明是靠裸文本内容判定的'); + const st = tokensStatus(legacy, EXPECT); + assert.equal(st.status, 'ok', JSON.stringify(st)); + assert.ok(st.detail.includes('无受管区'), st.detail); + runSkill('init-project.mjs', [p, '--upgrade-css']); + assert.equal(read(p), fresh, '原地包裹后应回到与新模板完全一致'); + }); + + test('裸文本重复两份(旧追加时代留的) → check 报错并归并为一', () => { + const p = initProj(); + const fresh = read(p); + const legacy = stripTokensTag(fresh); + // (a) 整份生成物出现两次 → tokens 判 duplicate + const dup = legacy + '\n' + EXPECT.text.trim() + '\n'; + write(p, dup); + assert.equal(tokensStatus(dup, EXPECT).status, 'duplicate', '整份生成物出现两次必须报出来'); + assert.equal(runSkill('init-project.mjs', [p, '--check-css']).status, 1, '重复的生成物不许静默通过'); + runSkill('init-project.mjs', [p, '--upgrade-css']); + assert.equal(findAllBlocks(read(p), TOKENS_ID).length, 1, '归并后只剩一个 tokens 区'); + assert.equal((read(p).match(/>>> html2video:table/g) ?? []).length, 1); + assert.equal(runSkill('init-project.mjs', [p, '--check-css']).status, 0); + // (b) 只有某个工具箱被追加过第二份(无定界的裸副本) → 该 kit 判 legacy-outside + write(p, legacy + '\n' + KITS[2].css + '\n'); + assert.equal(kitStatuses(read(p), { expected: EXPECT }).find(s => s.id === 'table').status, 'legacy-outside'); + assert.equal(runSkill('init-project.mjs', [p, '--check-css']).status, 1, '区外多一份表格原语同样不许通过'); + runSkill('init-project.mjs', [p, '--upgrade-css']); + const after = read(p); + assert.equal((after.match(/\.tbl \{[^}]*border-collapse/g) ?? []).length, 1, '重复的表格原语必须只剩一份'); + assert.equal((after.match(/>>> html2video:table/g) ?? []).length, 1); + }); + + test('完全缺失 → 文件尾追加受管区; 原有内容在前; 幂等', () => { + const p = mkproj(tmpdir()); + const r1 = runSkill('init-project.mjs', [p, '--upgrade-css']); + assert.equal(r1.status, 0, r1.stderr); + const once = read(p); + assert.ok(once.startsWith(':root { --accent: #111; }'), '原有内容必须在前'); + for (const k of KITS) assert.ok(findBlock(once, k.id), `缺 ${k.id} 受管块`); + assert.ok(findBlock(once, TOKENS_ID), '缺 tokens 受管区'); + assert.ok(once.indexOf(TOKENS_ID) > once.indexOf('--accent: #111'), '受管区必须排在原有内容之后(后写才压得住旧值)'); + const r2 = runSkill('init-project.mjs', [p, '--upgrade-css']); + assert.equal(r2.status, 0); + assert.equal(read(p), once, '第二次不得再追加'); + assert.ok(r2.stdout.includes('无需升级')); + }); + + test('旧版残留(整份旧副本) → 已落入层叠死区: 升级清掉副本、当前版进受管区、可重复升级', () => { + // 探针规则(.tbl 行高/.kv)保持原样、其余内容是旧版 —— 探针通过但逐字不含当前版, 即"旧版残留" + const oldTable = TABLE_CSS.replace(/\.rank td \{[^}]*\}/, m => m.replace('72px', '60px')); + const p = mkproj(tmpdir(), { tokens: oldTable }); + const r = runSkill('init-project.mjs', [p, '--upgrade-css']); + assert.equal(r.status, 0, r.stderr); + const css = read(p); + // 旧实现是"把当前版追加到旧规则之后靠后写覆盖": 一旦旧副本又被写回文件尾就又落后了。 + // 现在整份旧副本按 kind='copy' 直接清掉(原件在 .bak), 所以文件里只剩当前版一条。 + assert.equal((css.match(/\.rank td \{[^}]*\}/g) ?? []).length, 1, '旧副本必须被清掉, 当前版只剩一条'); + assert.ok(css.includes('72px'), '内容应是当前版(72px)'); + assert.ok(!/height: 60px/.test(css), '旧值(60px)不得残留'); + assert.ok(css.trimEnd().endsWith('<<< */'), '受管区必须落在文件末尾(层叠上后写才压得住前面的旧内容)'); + assert.equal(runSkill('init-project.mjs', [p, '--check-css']).status, 0, '升级后必须绿'); + assert.ok(r.stdout.includes('清理') || r.stdout.includes('受管区'), r.stdout); + }); +}); + +describe('受管块 · 整段受管化(2026-09-18 补: 四条静默路径的逐个回归)', () => { + test('主体陈旧 → tokens 区被整体就地替换, 区外的项目规则一字不动', () => { + const body = generateTokensCss().replace('--fs-h1: 64px', '--fs-h1: 60px'); // 模拟旧版主体 + assert.notEqual(body, generateTokensCss(), '前置: 造出了与当前不同的主体内容'); + assert.ok(!body.includes('--fs-h1: 64px'), '前置: 旧值确实换掉了'); + const css = `${wrapTokens(body, 'cafebabecafebabe')}\n.proj { color: blue }\n`; + const st = tokensStatus(css, EXPECT); + assert.equal(st.status, 'stale', JSON.stringify(st)); + const up = applyKitUpgrade(css, { tokensText: EXPECT.text, tokensRev: TOKENS_REV }).css; + assert.equal(findAllBlocks(up, TOKENS_ID).length, 1); + assert.equal(findAllBlocks(up, TOKENS_ID)[0].body.trim(), EXPECT.text.trim(), '整段应换成当前版'); + assert.ok(!up.includes('--fs-h1: 60px'), '旧主体的值不得残留'); + assert.ok(up.includes('.proj { color: blue }'), '区外的项目规则必须保留'); + assert.equal(allStatus(up).find(s => s.id === TOKENS_ID).status, 'ok'); + }); + + test('同一 id 两个块 → 报 duplicate, 升级归并为一(块外那份按层叠会赢)', () => { + const one = wrapKit('table', TABLE_CSS, 'a'.repeat(16)); + const two = wrapKit('table', TABLE_CSS.replace('72px', '60px'), 'b'.repeat(16)); + const css = `${one}\n${two}\n`; + const st = kitStatuses(css); + assert.equal(st[2].status, 'duplicate', JSON.stringify(st.map(s => s.id + ':' + s.status))); + const up = applyKitUpgrade(css, { tokensText: EXPECT.text, tokensRev: TOKENS_REV }).css; + assert.equal(findAllBlocks(up, 'table').length, 1, '归并为一'); + assert.equal(findAllBlocks(up, 'table')[0].body.trim(), TABLE_CSS.trim(), '留下的是当前版'); + assert.equal((up.match(/height: 60px/g) ?? []).length, 0, '块外那份旧副本必须清掉'); + }); + + test('开标记无配对收尾 → 报 broken(旧实现因字符串命中反而报 ok), 升级后不得叠加第二个开标记', () => { + const css = `${wrapKit('nofx', NOFX_CSS, 'a'.repeat(16))}\n/* >>> html2video:table rev=b4c5d6e7 >>> */\n.tbl { color: red }\n`; + const st = kitStatuses(css); + assert.equal(st[2].status, 'broken', JSON.stringify(st.map(s => s.id + ':' + s.status))); + const up = applyKitUpgrade(css, { tokensText: EXPECT.text, tokensRev: TOKENS_REV }).css; + const opens = up.match(/>>> html2video:table rev=/g) ?? []; + assert.equal(opens.length, findAllBlocks(up, 'table').length, '开标记数必须与配对块数一致'); + assert.equal(opens.length, 1); + assert.ok(up.includes('.tbl { color: red }'), '用户自己写的规则保留'); + }); + + test('受管区之外残留旧副本 → 报 legacy-outside; 升级清副本但保留用户单条覆写', () => { + const override = '.tbl tbody tr { height: 96px; }'; + // tokens 区里本来就含一份当前的 table 子块, 区外这份是旧副本(后出现 → 按层叠会赢) + const css = `${wrapTokens(generateTokensCss(), TOKENS_REV)}\n${TABLE_CSS}\n${override}\n`; + const st = kitStatuses(css, { expected: EXPECT }); + const table = st.find(s => s.id === 'table'); + assert.equal(table.status, 'legacy-outside', st.map(s => s.id + ':' + s.status).join(' ')); + assert.ok(table.detail.includes('受管区之外'), table.detail); + const up = applyKitUpgrade(css, { tokensText: EXPECT.text, tokensRev: TOKENS_REV }); + const actions = up.actions.map(a => a.action); + assert.ok(actions.includes('removed-legacy-outside'), actions.join(',')); + assert.ok(actions.includes('kept-legacy-overrides'), '紧邻旧副本的用户覆写必须被单独保留: ' + actions.join(',')); + assert.equal(findAllBlocks(up.css, 'table').length, 1); + assert.equal((up.css.match(/\.tbl tbody tr \{ height: 72px; \}/g) ?? []).length, 1, '受管区里的当前版只剩一条'); + assert.ok(up.css.includes(override), '用户单条覆写必须原样保留(区外规则一概不动)'); + assert.ok(allStatus(up.css).every(s => s.status === 'ok'), allStatus(up.css).map(s => s.id + ':' + s.status).join(' ')); + }); + + test('区外整份旧副本被改过几处(不止一处) → 整段清掉: 它是压在受管区上的错误产物', () => { + const modCopy = TABLE_CSS.replace('72px', '60px'); + const override = '.tbl tbody tr { height: 96px; }'; + const css = `${wrapTokens(generateTokensCss(), TOKENS_REV)}\n${modCopy}\n${override}\n`; + assert.equal(kitStatuses(css, { expected: EXPECT }).find(s => s.id === 'table').status, 'legacy-outside'); + const up = applyKitUpgrade(css, { tokensText: EXPECT.text, tokensRev: TOKENS_REV }).css; + assert.ok(!/height: 60px/.test(up), '被改过的整份旧副本必须清掉(否则它会按层叠压过受管区)'); + assert.ok(up.includes(override), '与之相邻的用户覆写仍然保留'); + assert.equal(allStatus(up).every(s => s.status === 'ok'), true, allStatus(up).map(s => s.id + ':' + s.status).join(' ')); + }); + + test('老格式裸文本 + 旧主体并存 → 生成物排在旧内容之后(层叠上后写才生效), 且幂等', () => { + // 顺序回归: 升级必须"先判是否已有当前版主体、再剥内嵌子块"。反过来的话裸文本主体里的工具箱原文 + // 会被当区外副本删掉 → indexOf 失配 → 插到文件末尾之外的位置 + 旧主体留在后面压过新版(实测踩到过)。 + const oldBody = generateTokensCss().replace('--fs-h1: 64px', '--fs-h1: 60px'); + const css = `/* 老项目 */\n${oldBody}\n.proj { color: blue }\n`; + const up = applyKitUpgrade(css, { tokensText: EXPECT.text, tokensRev: TOKENS_REV }).css; + assert.equal((up.match(/>>> html2video:tokens/g) ?? []).length, 1, '只能有一个受管区(不得多插一份)'); + assert.ok(up.indexOf('>>> html2video:tokens') > up.indexOf('--fs-h1: 60px'), '受管区必须在旧主体之后(否则旧值压过新版)'); + assert.ok(up.includes('.proj { color: blue }'), '项目规则保留'); + for (const k of KITS) assert.equal(findAllBlocks(up, k.id).length, 1, `${k.id} 恰好一个受管块`); + assert.equal(applyKitUpgrade(up, { tokensText: EXPECT.text, tokensRev: TOKENS_REV }).css, up, '幂等'); + // 逐字与当前版相同的旧主体规则会被清掉; 与当前版**不同**的那条留下并如实报出来 + // (工具分不出"旧残留"还是"你有意改的", 所以报出来由人决定 —— 复查 A7 定下的语义) + const st = allStatus(up); + const tokens = st.find(s => s.id === TOKENS_ID); + assert.equal(tokens.status, 'legacy-outside', st.map(s => s.id + ':' + s.status).join(' ')); + assert.match(tokens.detail, /与当前版不同|逐字相同/, tokens.detail); + assert.ok(st.filter(s => s.id !== TOKENS_ID).every(s => s.status === 'ok'), st.map(s => s.id + ':' + s.status).join(' ')); + // 关键的用户可见结论: 与当前版不同的那条旧规则虽然留着(工具分不出来是不是有意改的), + // 但它在受管区**之前**, 所以渲染用的是受管区里的当前值 —— 这就是"区域排在最后"的意义 + assert.equal(up.split('--fs-h1: 60px').length - 1, 1, '与当前版不同的那条只应留一条(其余逐字相同的旧规则都该被清掉)'); + assert.ok(up.includes('--fs-h1: 64px'), '受管区里应有当前值'); + assert.ok(up.indexOf('--fs-h1: 64px') > up.indexOf('--fs-h1: 60px'), '当前值必须出现在旧值之后(层叠上生效)'); + }); + + test('区外扫描的可见范围: outsideRegions 把受管区(含内部工具箱子块)整体挖掉', () => { + const css = `${wrapTokens(generateTokensCss(), TOKENS_REV)}\n.keepme { color: red }\n`; + const out = outsideRegions(css); + assert.ok(out.includes('.keepme'), '区外内容应保留在视野里(嵌套区间的掩码不许吃掉它)'); + assert.equal(out.length, css.length, '掩码与原文等长, 偏移才对得回原文'); + assert.ok(!out.includes('.no-fx'), 'tokens 区内的工具箱内容不得计入区外'); + assert.ok(!out.includes('.chart-bar'), 'tokens 区内的图表工具内容不得计入区外'); + }); +}); + +describe('--check-css CLI 语义', () => { + test('落后 → 退出 1 并给出修复命令; 升级后 → 退出 0', () => { + const p = mkproj(tmpdir(), { tokens: LEGACY_TOKENS }); + const bad = runSkill('init-project.mjs', [p, '--check-css']); + assert.equal(bad.status, 1); + assert.ok(bad.stderr.includes('--upgrade-css'), '要给出修复动作'); + runSkill('init-project.mjs', [p, '--upgrade-css']); + assert.equal(runSkill('init-project.mjs', [p, '--check-css']).status, 0); + }); +}); + +describe('受管块 · 行尾与病态输入(2026-09-18 三维审计补的回归)', () => { + test('CRLF 文件(git autocrlf 检出)不误报: check 绿; 升级后受管块统一为 LF 并如实说明', () => { + const p = initProj(); + const fresh = read(p); + write(p, fresh.replace(/\n/g, '\r\n')); + assert.equal(runSkill('init-project.mjs', [p, '--check-css']).status, 0, '只换了行尾, 不该判"被手工改过"'); + write(p, withStaleRev(read(p)).replace(/\n/g, '\r\n')); // 行尾 + 旧 rev 一起, 走真升级路径 + const r = runSkill('init-project.mjs', [p, '--upgrade-css']); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.stdout.includes('行尾已统一为 LF'), r.stdout); + assert.ok(!findBlock(read(p), 'chart').body.includes('\r'), '受管块内不得再残留 CRLF'); + assert.equal(runSkill('init-project.mjs', [p, '--check-css']).status, 0); + }); + + test('.bak 已存在 → 显式警告后再覆盖(旧备份无声丢是不对的)', () => { + const p = initProj(); + const stale = withStaleRev(read(p)); + write(p, stale); + fs.writeFileSync(tokensOf(p) + '.bak', 'OLD PRECIOUS BACKUP'); + const r = runSkill('init-project.mjs', [p, '--upgrade-css']); + assert.equal(r.status, 0, r.stderr); + assert.ok((r.stdout + r.stderr).includes('将被本次升级前的备份覆盖'), '必须警告'); + assert.ok(fs.readFileSync(tokensOf(p) + '.bak', 'utf8').includes('0000dead'), '.bak 应是升级前的内容'); + }); + + test('病态互踩: nofx 裸文本嵌在 chart 旧受管块内 → 一次升级全部到位(不再"上次成功这次又落后")', () => { + const hostile = `:root{--a:1}\n${wrapKit('chart', CHART_CSS + '\n' + NOFX_CSS, 'a'.repeat(64))}\n`; + const p = mkproj(tmpdir(), { tokens: hostile }); + const r = runSkill('init-project.mjs', [p, '--upgrade-css']); + assert.equal(r.status, 0, r.stderr); + assert.equal(runSkill('init-project.mjs', [p, '--check-css']).status, 0, '一次升级后三段必须全部就位'); + for (const k of KITS) assert.ok(findBlock(read(p), k.id), `缺 ${k.id} 受管块`); + }); + + test('超大 tokens.css → 拒绝扫描(构造输入会让受管块定位二次方变慢, 4MB 实测 39s)', () => { + const p = mkproj(tmpdir(), { tokens: '/* >>> html2video:nofx rev=aaaaaaaa >>> */\n' + '/* '.repeat(MAX_SCAN_BYTES / 3 + 10) }); + const r = runSkill('init-project.mjs', [p, '--check-css']); + assert.equal(r.status, 1); + assert.ok((r.stdout + r.stderr).includes('拒绝扫描'), '要说明是拒绝扫描而不是慢慢算'); + }); +}); + +describe('陈旧 CSS 闸门(2026-09-18 复查: 陈旧必须阻断, 不许静默出旧画面)', () => { + const slideHtml = ` +

标题

`; + const staleProj = () => { + const p = mkproj(tmpdir(), { tokens: LEGACY_TOKENS, slides: [{ id: '01', html: '01.html', audio: '01.mp3', clauses: [{ stage: 1, text: '一句。' }] }] }); + fs.writeFileSync(path.join(p, 'slides', '01.html'), slideHtml); + return p; + }; + + test('capture: 受管块落后 → 退出 1 并给修复命令; --allow-stale-css 显式放行', () => { + const p = staleProj(); + const r = runSkill('capture.mjs', [p]); + assert.equal(r.status, 1, '旧 CSS 会照常出图且全程不报错 —— 必须在入口停住'); + assert.ok(r.stderr.includes('受管块与技能当前版不一致'), r.stderr.slice(-300)); + assert.ok(r.stderr.includes('--upgrade-css'), '要把修复命令给到手上'); + assert.ok(r.stderr.includes(p), '命令里要有本项目路径(可直接粘)'); + const r2 = runSkill('capture.mjs', [p, '--allow-stale-css']); + assert.ok(r2.stderr.includes('已显式跳过闸门'), '显式跳过要留痕, 实际: ' + r2.stderr.slice(-200)); + }); + + test('build-video: 同一道闸门(成片比预览图更贵, 更不能静默用旧 CSS)', () => { + const p = staleProj(); + const r = runSkill('build-video.mjs', [p]); + assert.equal(r.status, 1); + assert.ok(r.stderr.includes('build-video') && r.stderr.includes('受管块与技能当前版不一致'), r.stderr.slice(-300)); + assert.ok(r.stderr.includes('--allow-stale-css')); + }); + + test('check-slides: 受管块落后从"提示"升为"错误"级(阻断截图), 升级后恢复绿', () => { + const p = staleProj(); + const r = runSkill('check-slides.mjs', [p]); + assert.equal(r.status, 1, '提示级不得再放行陈旧 CSS'); + assert.ok(r.stdout.includes('✗ tokens.css'), r.stdout.slice(-400)); + assert.ok(r.stdout.includes('--upgrade-css'), '报错里要给修复命令'); + assert.equal(runSkill('init-project.mjs', [p, '--upgrade-css']).status, 0); + assert.equal(runSkill('check-slides.mjs', [p]).status, 0, '升级后必须恢复'); + }); + + test('真项目形状(项目规则 + 当前受管区) → 三个入口都不拦, 且把"区外覆写"作为提示打出来', () => { + const p = mkproj(tmpdir(), { slides: [{ id: '01', html: '01.html', audio: '01.mp3', clauses: [{ stage: 1, text: '一句。' }] }] }); + fs.writeFileSync(path.join(p, 'slides', '01.html'), slideHtml); + fs.appendFileSync(path.join(p, 'slides', 'tokens.css'), '\n.tbl tbody tr { height: 96px; }\n'); + assert.notEqual(runSkill('check-slides.mjs', [p]).status, 1, '区外单条覆写不该让截图停摆'); + const r = runSkill('init-project.mjs', [p, '--check-css']); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.stdout.includes('覆写'), '要告诉用户区外有他自己的覆写: ' + r.stdout); + }); +}); + +describe('复查 A6/A7: "check 绿但画面是旧值"的最后两条路径(2026-09-18 第三轮 review 实测)', () => { + test('A7: 受管区之后粘着一段旧主体 → 必须报 legacy-outside(旧实现报"已是最新")', () => { + // 旧实现只扫"工具箱选择器"与"逐字重复的整份主体", 于是这种布局被判 ok, 而那段旧规则 + // 按层叠压过受管区 —— 实测画面用的是 --fs-h1: 60px, check 却报 ✓ 已是最新。 + const oldFragment = generateTokensCss().replace('--fs-h1: 64px', '--fs-h1: 60px').slice(0, 4000); + const css = `${wrapTokens(generateTokensCss(), TOKENS_REV)}\n\n/* 旧版残留(手粘) */\n${oldFragment}\n`; + const st = tokensStatus(css, EXPECT); + assert.equal(st.status, 'legacy-outside', JSON.stringify(st)); + assert.match(st.detail, /主体规则/, st.detail); + assert.match(st.detail, /逐字相同/, '要说清升级会清掉哪一部分'); + // 升级: 逐字命中当前版的旧规则被清掉; 改过的那条留着并再次如实报出来(工具分不出旧残留与有意覆写) + const up = applyKitUpgrade(css, { tokensText: EXPECT.text, tokensRev: TOKENS_REV }); + assert.equal(up.actions.filter(a => a.action === 'removed-legacy-outside').length, 1, up.actions.map(a => a.action).join(',')); + assert.equal(tokensStatus(up.css, EXPECT).status, 'legacy-outside', '改过的那条还在, 仍要报(由人决定)'); + assert.ok(up.actions.some(a => a.action === 'kept-legacy-overrides' && /与当前版不同/.test(a.reason)), up.actions.map(a => a.action).join(',')); + }); + + test('A6: 老格式 + 文件尾的项目覆写 → 升级后必须说清它被受管区覆盖了(旧文案谎称"后出现者优先")', () => { + const oldBody = generateTokensCss().replace('--fs-h1: 64px', '--fs-h1: 60px'); + const css = `/* 老项目 */\n${oldBody}\n:root { --accent: #D92B2B; }\n`; + const up = applyKitUpgrade(css, { tokensText: EXPECT.text, tokensRev: TOKENS_REV }); + const regionAt = up.css.indexOf('>>> html2video:tokens'); + const overrideAt = up.css.indexOf('--accent: #D92B2B'); + assert.ok(overrideAt > -1 && overrideAt < regionAt, '前置: 老格式升级会把覆写留在受管区之前'); + const st = tokensStatus(up.css, EXPECT); + assert.notEqual(st.status, 'ok', '这条改过的旧规则会一直被报出来, 由人决定: ' + JSON.stringify(st)); + assert.match(st.detail, /受管区\*\*之前\*\*/, '必须在状态里点名"会被受管区覆盖"(旧文案笼统写"后出现者优先", 与实际布局相反): ' + st.detail); + assert.ok(/挪到受管区之后/.test(st.detail), '并给出可执行的做法: ' + st.detail); + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/endpoint-allowlist.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/endpoint-allowlist.test.mjs new file mode 100644 index 00000000..742d7788 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/endpoint-allowlist.test.mjs @@ -0,0 +1,169 @@ +// 安全边界 · ASR 端点白名单(API Key 只发官方域) +// 对应评审意见 3: credential exfiltration via --base-url / MINIMAX_BASE_URL。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { assertAsrEndpoint, PolicyError } from '../scripts/url-policy.mjs'; +import { findTool } from '../scripts/tools.mjs'; +import { runSkill, runSkillAsync, tmpdir } from './helpers.mjs'; + +describe('assertAsrEndpoint(纯函数)', () => { + test('两个官方域放行(含尾斜杠归一)', () => { + assert.equal(assertAsrEndpoint('https://api.minimaxi.com'), 'https://api.minimaxi.com'); + assert.equal(assertAsrEndpoint('https://api.minimaxi.com/'), 'https://api.minimaxi.com'); + assert.equal(assertAsrEndpoint('https://api.minimax.io'), 'https://api.minimax.io'); + }); + test('官方域的 http 变体也拒绝', () => { + assert.throws(() => assertAsrEndpoint('http://api.minimaxi.com'), PolicyError); + }); + test('任意第三方域拒绝', () => { + for (const b of ['https://evil.example', 'https://api.minimaxi.com.evil.example', 'https://127.0.0.1:9456']) { + assert.throws(() => assertAsrEndpoint(b), PolicyError, b); + } + }); + test('allowAny 显式放行(自建网关/测试, 自担风险)', () => { + assert.equal(assertAsrEndpoint('http://127.0.0.1:9456', { allowAny: true }), 'http://127.0.0.1:9456'); + assert.throws(() => assertAsrEndpoint('ftp://x', { allowAny: true }), PolicyError); + }); +}); + +describe('asr.mjs 进程级行为', () => { + test('MINIMAX_BASE_URL=第三方域 + 假 Key → 退出 1, 不发起请求(与提供方无关: 端点闸门在提供方解析之前)', () => { + const r = runSkill('asr.mjs', ['--api-key', 'sk-test-not-real', '--base-url', 'https://evil.example', '--file', 'whatever.mp3']); + assert.notEqual(r.status, 0); + assert.ok((r.stderr + r.stdout).includes('端点'), '应说明端点被拒'); + }); + + test('--allow-any-endpoint 指向本地服务器 → 请求到达且带着 Bearer 假 Key(证明门在 fetch 之前且可显式放行)', async () => { + if (!findTool('ffprobe')) return; // 主 CI 无 ffmpeg: 只留纯函数/负向证据 + // 造一个 1s 静音 mp3 + const work = tmpdir(); + const mp3 = path.join(work, 'clip.mp3'); + const FFMPEG = findTool('ffmpeg'); + const g = spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', '-t', '1', '-c:a', 'libmp3lame', '-y', mp3], { windowsHide: true }); + if (g.status !== 0) return; // 造不出音频就跳过 + + let sawAuth = null; + const server = http.createServer((req, res) => { + sawAuth = req.headers.authorization ?? '(none)'; + // Connection:close 让 undici 不留 keep-alive 连接 —— 否则 server.close() 永远等不到 + // socket 全关, 测试进程挂死(本次实测踩过: 整个 node --test 卡到超时) + res.writeHead(200, { 'content-type': 'application/json', connection: 'close' }); + res.end(JSON.stringify({ text: '' })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + try { + // 必须 runSkillAsync: spawnSync 冻结事件循环, 本地服务器 accept 不了子进程请求 → 互等死锁 + // --provider api 显式定向: 本机装了 mmx ≥1.0.26 时自动解析会走 mmx, 绕开这台桩服务器 + const r = await runSkillAsync('asr.mjs', ['--provider', 'api', '--api-key', 'sk-test-not-real', '--allow-any-endpoint', + '--base-url', `http://127.0.0.1:${port}`, '--file', mp3]); + assert.equal(sawAuth, 'Bearer sk-test-not-real', '本地服务器应收到 Bearer 假 Key'); + // 脚本本身成功与否不强断言(响应是假 JSON); 关键证据是 sawAuth + assert.ok(r.status === 0 || (r.stdout + r.stderr).length > 0); + } finally { + server.closeAllConnections?.(); + server.close(); + server.unref(); + } + }); +}); + +describe('asr.mjs 提供方选择(mmx cli ≥1.0.26 缺省首选, REST 为显式后备)', () => { + test('--provider 非法值 → 退出 1 并点名只认 mmx|api', () => { + const r = runSkill('asr.mjs', ['--provider', 'whisper', '--file', 'x.mp3']); + assert.notEqual(r.status, 0); + assert.ok((r.stderr + r.stdout).includes('--provider'), '应点名 --provider 的合法值'); + }); + + test('--provider api 且无 Key → 退出 2, 引导同时给出两条路(装 mmx / 配 Key)', () => { + const r = runSkill('asr.mjs', ['--provider', 'api', '--file', 'x.mp3'], { env: { ...process.env, MINIMAX_API_KEY: '' } }); + assert.equal(r.status, 2); + const out = r.stderr + r.stdout; + assert.match(out, /mmx-cli@latest/, '引导应包含升级/安装 mmx'); + assert.match(out, /MINIMAX_API_KEY/, '引导应包含 REST 后备的 Key 配法'); + }); + + // 假 mmx: 记录收到的参数与"子进程是否看到 MINIMAX_API_KEY", 按 --out 指定的路径写回 JSON 文档。 + // POSIX 形态是带 shebang 的 node 脚本(chmod +x), Windows 形态是 mmx.cmd 包一层 node —— + // 与 asr.mjs 的 mmxRun 封装(Windows 经 cmd.exe /c)对得上。 + const makeShim = (bin, log, { fail } = {}) => { + const body = [ + "import fs from 'node:fs';", + `const a = process.argv.slice(2);`, + `fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify(a) + '\\n' + 'ENVKEY=' + (process.env.MINIMAX_API_KEY === undefined ? 'absent' : 'present') + '\\n');`, + `if (a.includes('--version')) { console.log('mmx 1.0.26'); process.exit(0); }`, + ...(fail ? [`if (a.includes('transcribe')) { console.error('boom-quota'); process.exit(3); }`] : []), + "const i = a.indexOf('--out');", + "if (i > -1) fs.writeFileSync(a[i + 1], JSON.stringify({ text: '你好世界二零二六', duration: 1.23, segments: [{ id: 0, start: 0.1, end: 0.4, text: '你好' }] }));", + "process.exit(0);", + ].join('\n'); + if (process.platform === 'win32') { + fs.writeFileSync(path.join(bin, 'mmx-shim.mjs'), body); + fs.writeFileSync(path.join(bin, 'mmx.cmd'), '@node "%~dp0mmx-shim.mjs" %*\r\n'); + } else { + const f = path.join(bin, 'mmx'); + fs.writeFileSync(f, '#!/usr/bin/env node\n' + body); + fs.chmodSync(f, 0o755); + } + }; + const runShimVersion = (bin) => { + const env = { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}` }; + delete env.MINIMAX_API_KEY; // 与 mmxRun 同纪律: 测试自己的探测也别贡献 ENVKEY=present(否则机器导出过 Key 就假红) + return process.platform === 'win32' + ? spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'mmx', '--version'], { encoding: 'utf8', windowsHide: true, env }) + : spawnSync('mmx', ['--version'], { encoding: 'utf8', windowsHide: true, env }); + }; + // 两个 shim 用例的公共前置; 不满足能力时 t.skip 并返回 null(调用方直接 return) + const setupShimCase = (t, { fail } = {}) => { + if (!findTool('ffprobe')) { t.skip('无 ffprobe: prepareForUpload 走不到提供方分支'); return null; } + const bin = tmpdir(); const log = path.join(bin, 'args.log'); + makeShim(bin, log, { fail }); + if (runShimVersion(bin).status !== 0) { t.skip('当前环境执行不了 mmx shim(可执行脚本受限)'); return null; } + // 项目路径故意带 & 和空格: Windows 上 mmxRun 经 cmd.exe 拼命令行, 引用必须保住元字符(1.9.1 修复的回归) + const proj = path.join(tmpdir(), 'a&b c'); + fs.mkdirSync(proj, { recursive: true }); + const mp3 = path.join(proj, 'clip.mp3'); + const g = spawnSync(findTool('ffmpeg'), ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', '-t', '1', '-c:a', 'libmp3lame', '-y', mp3], { windowsHide: true }); + if (g.status !== 0) { t.skip('造不出音频'); return null; } + const env = { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}`, MINIMAX_API_KEY: 'should-not-reach-child' }; + return { bin, log, proj, mp3, env }; + }; + + test('mmx shim: --provider mmx 调 speech transcribe(含 --timestamp-level 映射), 元字符路径不破, 输出经 os.tmpdir() 中转且 Key 不流向子进程', async (t) => { + const ctx = setupShimCase(t); + if (!ctx) return; + const before = new Set(fs.readdirSync(os.tmpdir())); + const r = await runSkillAsync('asr.mjs', [ctx.proj, '--file', ctx.mp3, '--provider', 'mmx', '--language', 'zh', '--format', 'verbose_json', '--timestamp', 'word'], { env: ctx.env }); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.ok((r.stdout || '').includes('你好世界二零二六'), '应打印 shim 文档里解析出的 text'); + const lines = fs.readFileSync(ctx.log, 'utf8').trim().split('\n'); + const tr = lines.filter(l => l.startsWith('[')).map(l => JSON.parse(l)).find(a => a.includes('transcribe')); + assert.ok(tr, '应调用 speech transcribe'); + for (const want of ['speech', 'transcribe', '--file', '--model', 'asr-1.0', '--response-format', 'verbose_json', '--timestamp-level', 'word', '--language', 'zh']) assert.ok(tr.includes(want), `参数缺 ${want}: ${JSON.stringify(tr)}`); + const outIdx = tr.indexOf('--out'); + assert.ok(tr[outIdx + 1].includes('asr-mmx-'), '--out 必须落在专属临时目录'); + // 不能只看第一条 ENVKEY(那是 setup 探测写的, 空过): 整份日志里任何一次 mmx 子进程调用 + // (--version 探测也算)都不许看到 Key —— 剥除回退到任何一处, 这里就红(1.9.2 修正断言空过)。 + const envLines = lines.filter(l => l.startsWith('ENVKEY=')); + assert.ok(envLines.length > 0 && envLines.every(l => l === 'ENVKEY=absent'), + `mmx 子进程(探测与 transcribe 全算)不得看到 MINIMAX_API_KEY: ${envLines.join(', ')}`); + // 临时目录必须清理干净(与转码临时目录同一条纪律) + assert.deepEqual(fs.readdirSync(os.tmpdir()).filter(f => f.startsWith('asr-mmx-') && !before.has(f)), [], 'os.tmpdir() 的 mmx 中转目录必须已清理'); + }); + + test('mmx shim 失败(退出 3) → asr 非零退出, 透出 mmx 的 stderr 与修法提示', async (t) => { + const ctx = setupShimCase(t, { fail: true }); + if (!ctx) return; + const r = await runSkillAsync('asr.mjs', [ctx.proj, '--file', ctx.mp3, '--provider', 'mmx'], { env: ctx.env }); + assert.notEqual(r.status, 0, 'mmx 失败不能被当成功'); + const out = r.stdout + r.stderr; + assert.match(out, /mmx speech transcribe 失败/, '应点名提供方与命令'); + assert.match(out, /boom-quota/, '应透出 mmx 的 stderr'); + assert.match(out, /mmx-cli@latest/, '应给出升级提示'); + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/fetch-policy.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/fetch-policy.test.mjs new file mode 100644 index 00000000..da705838 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/fetch-policy.test.mjs @@ -0,0 +1,164 @@ +// 安全边界 · 抓图 URL 策略(SSRF / 本地文件读 / 重定向 / 文件名) +// 对应评审意见 4: SSRF and local-file-read behavior in fetch-official-images.mjs。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isBlockedHost, assertFetchableUrl, assertRedirectTarget, sanitizeFilename, imageNameFromUrl, MAX_REDIRECTS, PolicyError, +} from '../scripts/url-policy.mjs'; + +describe('isBlockedHost(内网/本地/元数据/裸主机名)', () => { + const blocked = [ + '127.0.0.1', '127.8.9.10', // loopback + '169.254.169.254', '169.254.0.9', // 链路本地(含云元数据) + '10.0.0.5', '172.16.0.1', '172.31.9.9', '192.168.1.1', // 私网 + '100.64.0.1', // CGNAT + '0.0.0.0', + '::1', '::', 'fe80::1', 'fc00::1', 'fd12:3456::1', // IPv6 loopback/链路本地/ULA + '::ffff:127.0.0.1', '::ffff:10.0.0.1', // IPv4-mapped + 'localhost', 'intranet-app', // 无点主机名 + 'box.local', 'corp.internal', 'x.home.arpa', + '', // 空 + ]; + const allowed = [ + '8.8.8.8', '1.1.1.1', '172.32.0.1', '172.15.0.1', // 公网(含私网段边界外) + 'api.minimaxi.com', 'example.com', 'cdn.example.co.uk', + '::ffff:8.8.8.8', '2606:4700::1111', + ]; + for (const h of blocked) test(`拦 ${h}`, () => assert.equal(isBlockedHost(h), true, h)); + for (const h of allowed) test(`放 ${h}`, () => assert.equal(isBlockedHost(h), false, h)); +}); + +describe('assertFetchableUrl', () => { + test('公网 https/http 放行', () => { + assertFetchableUrl('https://example.com/a.png'); + assertFetchableUrl('http://example.com/a.png'); + }); + test('内网/元数据/裸名拒绝', () => { + for (const u of ['https://127.0.0.1/x', 'http://169.254.169.254/latest', 'https://192.168.0.1/', 'http://localhost/', 'http://intranet/x']) { + assert.throws(() => assertFetchableUrl(u), PolicyError, u); + } + }); + test('file:// 默认拒绝, --allow-file 放行', () => { + assert.throws(() => assertFetchableUrl('file:///C:/page.html'), PolicyError); + assertFetchableUrl('file:///C:/page.html', { allowFile: true }); + }); + test('带 userinfo 的 URL 拒绝(凭据会泄漏进日志)', () => { + assert.throws(() => assertFetchableUrl('https://user:pw@example.com/a.png'), PolicyError); + }); + test('非 http(s)/file 协议拒绝', () => { + for (const u of ['ftp://example.com/a', 'javascript:alert(1)', 'data:text/html,x']) { + assert.throws(() => assertFetchableUrl(u), PolicyError, u); + } + }); +}); + +describe('重定向目标逐跳校验', () => { + test('重定向到私网/元数据/file: 拒绝', () => { + for (const loc of ['http://10.0.0.5/x.png', 'http://169.254.169.254/a', 'file:///C:/Windows/win.ini', 'https://evil.example']) { + if (loc === 'https://evil.example') continue; // 公网第三方在抓图语境是允许的(offsite 仅标注) + assert.throws(() => assertRedirectTarget(loc), PolicyError, loc); + } + }); + test('重定向到公网放行; MAX_REDIRECTS 有上限', () => { + assertRedirectTarget('https://cdn.example.com/a.png'); + assert.equal(typeof MAX_REDIRECTS, 'number'); + assert.ok(MAX_REDIRECTS >= 1 && MAX_REDIRECTS <= 10, '重定向上限应在 1–10 跳'); + }); +}); + +describe('sanitizeFilename(页面控制的 alt → 落盘名)', () => { + test('路径分隔符与控制字符被替换', () => { + const n = sanitizeFilename('brand/logo\\main v2'); + assert.ok(!n.includes('/') && !n.includes('\\'), n); + }); + test('Windows 保留名被替换', () => { + for (const bad of ['CON', 'con', 'NUL', 'com1', 'LPT9']) { + assert.ok(sanitizeFilename(bad) !== bad, `保留名 ${bad} 必须被换掉`); + } + }); + test('开头点/空格被剥(不产生隐藏文件)', () => { + assert.ok(!sanitizeFilename(' .hidden').startsWith('.'), sanitizeFilename(' .hidden')); + }); + test('空/undefined → fallback', () => { + assert.equal(sanitizeFilename(''), 'official'); + assert.equal(sanitizeFilename(undefined), 'official'); + }); + test('长度有上限', () => { + assert.ok(sanitizeFilename('x'.repeat(500)).length <= 30); + }); +}); + +describe('imageNameFromUrl(零散图片 URL → 落盘文件名)', () => { + test('URL 自己的扩展名优先', () => { + assert.equal(imageNameFromUrl('https://cdn.x/img/hero-2026.png'), 'hero-2026.png'); + assert.equal(imageNameFromUrl('https://cdn.x/a/CEO%20photo.jpg?v=2'), 'CEO-photo.jpg'); + }); + test('没有扩展名 → 按 content-type 推', () => { + assert.equal(imageNameFromUrl('https://cdn.x/image', 'image/webp; charset=binary'), 'image.webp'); + assert.equal(imageNameFromUrl('https://cdn.x/image', 'image/svg+xml'), 'image.svg'); + assert.equal(imageNameFromUrl('https://cdn.x/image', 'text/html'), 'image.bin'); + }); + test('路径穿越 / 保留名 / 空路径都不会逃出 out-dir', () => { + assert.equal(imageNameFromUrl('https://cdn.x/../../etc/passwd'), 'passwd.bin'); + assert.equal(imageNameFromUrl('https://cdn.x/CON.png'), 'official.png'); + assert.equal(imageNameFromUrl('https://cdn.x/', 'image/png'), 'official.png'); + assert.ok(!imageNameFromUrl('https://cdn.x/%2e%2e%2fescape.png').includes('/')); + }); + test('长度有上限且不含路径分隔符', () => { + const n = imageNameFromUrl('https://cdn.x/' + 'a'.repeat(300) + '.png'); + assert.ok(n.length <= 34, n); + assert.ok(!/[\/:*?"<>|]/.test(n), n); + }); +}); + +describe('IPv4-mapped IPv6 的十六进制形式(经 new URL() 规范化后的真实到达形态)', () => { + // WHATWG URL 会把 http://[::ffff:127.0.0.1]/ 的 hostname 规范化成 ::ffff:7f00:1 再送检 —— + // 只测点分形式会漏掉这条真实穿透路径(2026-09-18 安全审计端到端打到了 loopback 与云元数据) + const blocked = [ + 'http://[::ffff:7f00:1]/x', // 127.0.0.1 loopback + 'http://[::ffff:a9fe:a9fe]/latest/meta-data/', // 169.254.169.254 云元数据 + 'http://[::ffff:c0a8:101]/router', // 192.168.1.1 + 'http://[::ffff:a00:5]/internal', // 10.0.0.5 + 'http://[0:0:0:0:0:ffff:a9fe:a9fe]/', // 长形式(解析器会压缩) + ]; + for (const u of blocked) { + test(`拦 ${u}`, () => assert.throws(() => assertFetchableUrl(u), PolicyError)); + } + test('放 公网映射形式 [::ffff:808:808](= 8.8.8.8): 换算回点分后判定, 不是一刀切', () => { + assert.doesNotThrow(() => assertFetchableUrl('http://[::ffff:808:808]/pub.png')); + }); +}); + +describe('2026-09-18 复查补全: 尾点 FQDN / NAT64 / 6to4 / 保留段', () => { + // 这一组是把"外部评审打穿的两例 + 同类载体"固化成负例: 尾点 FQDN 与 NAT64 当时都是实测放行 + const blocked = [ + 'localhost.', // 尾点绝对名, 与 localhost 同一主机 + 'LOCALHOST.', // 大小写 + '127.0.0.1.', // 字面量带尾点 + '10.0.0.1.', + 'printer.local.', + '240.0.0.1', // 240/4 保留 + '255.255.255.255', + '224.0.0.1', // 组播 + '198.18.0.1', // 基准测试段 + '192.0.0.1', // IETF 保留 + '192.0.2.5', // TEST-NET-1 + '198.51.100.7', // TEST-NET-2 + '203.0.113.9', // TEST-NET-3 + '[fec0::1]', // 站点本地(废弃) + '[2001:db8::1]', // 文档段 + '[64:ff9b::7f00:1]', // NAT64 内嵌 127.0.0.1(实测曾放行) + '[64:ff9b::a9fe:a9fe]', // NAT64 内嵌 169.254.169.254 + '[2002:7f00:0001::]', // 6to4 内嵌 127.0.0.1 + '[2002:c0a8:0101::]', // 6to4 内嵌 192.168.1.1 + ]; + for (const h of blocked) test(`拦 ${h}`, () => assert.equal(isBlockedHost(h), true, h)); + + const allowed = [ + '93.184.216.34', // 公网 IPv4 + 'cdn.example.com.', // 公网域名的尾点写法照常放行 + '[2001:4860:4860::8888]', // 公网 IPv6 (Google DNS) + '[2002:0808:0808::]', // 6to4 内嵌 8.8.8.8 —— 换算后是公网, 不该一刀切拦 + ]; + for (const h of allowed) test(`放 ${h}`, () => assert.equal(isBlockedHost(h), false, h)); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/helpers.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/helpers.mjs new file mode 100644 index 00000000..a486f6e8 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/helpers.mjs @@ -0,0 +1,69 @@ +// html2video-for-mcode · 测试共用件(文件名不匹配 *.test.mjs, 不会被 node --test 当作用例执行) +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { wrapTokens } from '../scripts/css-kit.mjs'; +import { generateTokensCss, TOKENS_REV } from '../scripts/tokens-template.mjs'; + +export const SCRIPTS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts'); +export const skill = name => path.join(SCRIPTS, name); + +// 跑一个技能脚本, 返回 { status, stdout, stderr } +export function run(cmd, args, opts = {}) { + const r = spawnSync(cmd, args, { encoding: 'utf8', windowsHide: true, ...opts }); + return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; +} +export const runSkill = (name, args, opts = {}) => run(process.execPath, [skill(name), ...args], opts); + +// 异步版: 子进程与父进程需要并发交互时必须用这个 —— spawnSync 会冻结事件循环, +// 父进程里的本地服务器 accept 不了子进程的连接, 互相等死(本次实测踩过) +import { spawn } from 'node:child_process'; +export function runSkillAsync(name, args, opts = {}) { + return new Promise(resolve => { + const c = spawn(process.execPath, [skill(name), ...args], { windowsHide: true, ...opts }); + let stdout = '', stderr = ''; + c.stdout.on('data', d => { stdout += d; }); + c.stderr.on('data', d => { stderr += d; }); + c.on('error', err => resolve({ status: -1, stdout, stderr: stderr + String(err) })); + c.on('close', status => resolve({ status, stdout, stderr })); + }); +} + +// 临时目录(进程退出时统一清理 —— 此前上万个 h2v-test-* 堆在 %TEMP%, 第八轮复查 F2) +const madeDirs = new Set(); +export const tmpdir = () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'h2v-test-')); + madeDirs.add(d); + return d; +}; +process.once('exit', () => { for (const d of madeDirs) fs.rmSync(d, { recursive: true, force: true }); }); + +// 在 subprocess 里调用 tools.mjs 的退出型助手(safeId/safeRel 会 process.exit, 只能这样测) +export function probeHelper(expr, argsJson) { + const code = ` + import { ${expr} } from ${JSON.stringify(pathToFileURL(skill('tools.mjs')).href)}; + const args = ${argsJson}; + const v = ${expr}(...args); + process.stdout.write('RETURN:' + String(v)); + `; + return run(process.execPath, ['--input-type=module', '-e', code]); +} + +// 最小可跑的项目骨架(check-slides 需要 tokens.css; 其余脚本按需补) +// tokens.css 默认写成**真项目的样子**: 项目自己的规则 + 技能当前的受管区。2026-09-18 起 +// check-slides / capture / build-video 把"受管区缺失或落后"当阻断级(旧 CSS 会静默出片), +// 所以默认骨架要是新鲜的 —— 要测"落后/无受管区"的场景, 显式传 tokens 或 LEGACY_TOKENS。 +export const FRESH_TOKENS = ':root { --accent: #111; }\n' + wrapTokens(generateTokensCss(), TOKENS_REV) + '\n'; +export const LEGACY_TOKENS = ':root { --accent: #111; }\n'; // 老项目形状: 没有受管区 +export function mkproj(dir, { slides = [], tokens = FRESH_TOKENS } = {}) { + fs.mkdirSync(path.join(dir, 'slides'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'audio'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'build'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'slides', 'tokens.css'), tokens); + fs.writeFileSync(path.join(dir, 'script.json'), JSON.stringify({ + topic: 't', fps: 30, width: 1920, height: 1080, slides, + }, null, 2)); + return dir; +} diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/no-clobber.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/no-clobber.test.mjs new file mode 100644 index 00000000..c62d3bfc --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/no-clobber.test.mjs @@ -0,0 +1,121 @@ +// 安全边界 · 覆盖拒绝(不再无条件覆写用户/agent 已有内容) +// 对应评审意见 2: destructive overwrite behavior。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runSkill, mkproj, tmpdir } from './helpers.mjs'; + +describe('init-project: 非空目录拒绝 / --force 只重置生成文件', () => { + test('空目录 → 正常生成', () => { + const proj = tmpdir(); + const r = runSkill('init-project.mjs', [proj, '--topic', 'T']); + assert.equal(r.status, 0, r.stderr); + assert.ok(fs.existsSync(path.join(proj, 'script.json'))); + }); + + test('非空目录(有无关文件) → 拒绝, 退出 1, 无关文件不动', () => { + const proj = tmpdir(); + fs.writeFileSync(path.join(proj, 'user-notes.txt'), 'mine'); + const r = runSkill('init-project.mjs', [proj, '--topic', 'T']); + assert.notEqual(r.status, 0); + assert.ok(r.stderr.includes('--force'), '应提示 --force'); + assert.equal(fs.readFileSync(path.join(proj, 'user-notes.txt'), 'utf8'), 'mine', '无关文件不得被动'); + }); + + test('已有 script.json 的项目 → 拒绝并列出将被覆写的文件', () => { + const proj = mkproj(tmpdir(), { slides: [{ id: '01', clauses: [{ stage: 1, text: '已填的口播' }] }] }); + const r = runSkill('init-project.mjs', [proj]); + assert.notEqual(r.status, 0); + assert.ok(r.stderr.includes('script.json'), '应点名 script.json'); + assert.equal(JSON.parse(fs.readFileSync(path.join(proj, 'script.json'), 'utf8')).slides[0].clauses[0].text, '已填的口播', '已填内容不得被清'); + }); + + test('--force → 重新生成, 无关文件保留', () => { + const proj = mkproj(tmpdir(), { slides: [{ id: '01', clauses: [{ stage: 1, text: '旧' }] }] }); + fs.writeFileSync(path.join(proj, 'user-notes.txt'), 'mine'); + const r = runSkill('init-project.mjs', [proj, '--force']); + assert.equal(r.status, 0, r.stderr); + assert.equal(fs.readFileSync(path.join(proj, 'user-notes.txt'), 'utf8'), 'mine', '--force 也不删无关文件'); + const script = JSON.parse(fs.readFileSync(path.join(proj, 'script.json'), 'utf8')); + assert.equal(script.slides[0].clauses[0].text, '', '--force 后 script.json 被重置'); + }); + + test('--topic 的 HTML 转义: