diff --git a/.github/workflows/html2video-for-mcode-smoke.yml b/.github/workflows/html2video-for-mcode-smoke.yml new file mode 100644 index 00000000..f9920f5c --- /dev/null +++ b/.github/workflows/html2video-for-mcode-smoke.yml @@ -0,0 +1,60 @@ +# 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)。 +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: + 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: 22 + + - 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 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..fa4d417b --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "html2video-for-mcode", + "version": "1.8.1", + "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/CHANGELOG.md b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md new file mode 100644 index 00000000..527c3bb1 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md @@ -0,0 +1,370 @@ +# Changelog + +## 1.8.1 — 2026-09-19 + +**Thirteenth-review residue (found by the independent sweep over the published `e845cbd8` tree): `SKILL.md` was the one install surface 1.8.0 left behind** + +- **The 1.8.0 channel fix did not reach the file the agent actually reads.** That change collapsed both READMEs' install sections to "copy the reviewed skill directory out of the hosted plugin — not from any other source", but `SKILL.md`'s "Installing into other Agent environments" block still carried a bare `cp -r` whose source was unstated, plus a generic skills-CLI route — which re-admits exactly the "install it from a repository of your choosing" path 1.8.0 removed. It matters more than a README here: `.claude-plugin/plugin.json` points `skills` at this file. Both routes are gone; the block now sources the copy from the hosted plugin directory, worded like the READMEs. +- No code or test changes (nothing asserts on this block); the version is bumped so published copies carry the corrected text. The removed routes now occur in the published trees only inside the changelog entries that describe their removal. + +## 1.8.0 — 2026-09-19 + +**Thirteenth review (both axes at the exact head): two documented-contract violations fixed, three duplications closed, the rest disclosed** + +- **The standalone repository is no longer documented as an install channel.** The host's `docs/architecture.md` is explicit — "the hosted directory is the publication unit … contributors do not create a second repository" — yet this plugin's `homepage` pointed at a second repository and both READMEs offered `npx skills add Wzdhehe/…` / bare `cp -r` as install routes, which routes users around the review gate that guards `main`. `homepage` now points at the hosted plugin directory, and both install sections route through that hosted plugin only ("copy the reviewed skill directory out of the hosted plugin — not from any other source"). The standalone repository remains as a read-only history mirror, no longer a channel. +- **The paid account is now disclosed as paid.** `CONTRIBUTING.md` requires disclosing "accounts, paid services"; the READMEs named `mmx-cli` / `MINIMAX_API_KEY` without saying they spend a **MiniMax account's paid, quota-metered balance** (billed per character/minute; each transcription call billed by audio duration). Both READMEs now say so, in both languages. +- **The two redirect-following loops are one function.** `fetch-official-images` had the hop/policy loop implemented twice — once for the `--url` direct-download path, once inline for `--get` — and the second copy lacked the ping-pong cycle guard and the zero-byte body guard. A single `followRedirects(src, get)` core (client injected as an adapter) now serves both paths, so `--get` gained both guards; a structural regression test pins "exactly one hop loop, both call sites go through it, both zero-byte guards present" (a behavioural cycle test cannot run locally: the SSRF policy refuses loopback by design, and public URLs are unreliable in CI). +- **ffprobe probing consolidated.** The verbatim `format=duration` probe existed three times and the `stream=width,height` probe twice (`asr`, `plan-timings`, `build-video`, `prep-image`); both now live once in `tools.mjs` (`probeDuration`, `probeSize`), behaviour-identical, with local thin wrappers at the call sites. +- The preview-page file-symlink canary now covers a **per-slide leaf** (`01.html`) in addition to `index.html` — three write sites, previously two of them only code-covered. +- Judgement calls from the same review, disclosed rather than changed: `review-round2`/`review-round3` are named by review provenance, not by topic (each `describe` block is topical; renaming would orphan the suite list documented in both READMEs); the subtitle fade constants in `capture.mjs` are deliberately re-derived in the Node self-check so drift between the page script and the check would be *caught* rather than hidden by a shared constant; the subtitle-band geometry (render-side bar vs check-side zone) uses intentionally different numbers whose unification would change gate behaviour mid-review; the css-kit region-range merging appears twice in different operations (locate vs remove); and `preview-page`'s `buildPlayPage` embeds the play page as one long template — a structural trade-off, not a defect. +- The PR body's evidence transcript now matches a fresh validator run (the host has merged unrelated plugins since the old transcript: 26 → 27) and names `publish/validate-plugin.mjs` as an authoring-side tool that is deliberately not part of the shipped plugin tree. +- Tests **243 → 244 in fourteen files**, all green in the development tree and both published trees. + +## 1.7.9 — 2026-09-19 + +**Docs-only: corrected the 1.7.8 entry's site-class arithmetic** (the per-class numbers did not sum to the headline because the classes overlap — the order-protected site is also a jailed one). The conclusion stands on its own without the tally: an exhaustive primitive-level inventory of every write/delete site across all twenty scripts found no exploitable unjailed write. No code or test changes; versions bumped so the published copies carry the corrected entry. + +## 1.7.8 — 2026-09-19 + +**Eleventh-round audit: the write-site inventory converges; one shipped-in-1.7.7 canary had an unsatisfiable assertion** + +- **The 1.7.7 transcode canary's "nothing appears in the input directory" assertion could never pass** on any platform where it actually runs: the test itself plants a symlink at the old temp path, and `existsSync` follows the link to its existing target — so the assertion compared against the test's own plant and would have failed on Linux CI (it never executed anywhere: file symlinks are uncreatable on the author's Windows, and CI runs sit at `action_required`). The assertions are rewritten to what the fix actually guarantees: a before/after diff of the input directory shows **no new entries**, the outside canary file is byte-unchanged, the planted path is still a symlink (not replaced by a regular file), and the `os.tmpdir()` transcode directory is removed by the time the run ends. +- **PR-body counts re-phrased as capability invariants.** "243 pass, 0 fail, 0 skip" had gone stale the same way twice before — "0 skip" is only true where file symlinks are creatable. The body now states what is invariant (**0 fail in every environment measured**) and describes skips as capability-dependent and always named, instead of carrying per-environment counts. +- The standards axis' primitive-exhaustive write-site inventory (method changed from file-intuition to primitive enumeration across all twenty scripts) found **no exploitable unjailed write**: jailed sites, CLI-given paths per the documented contract, internal temp files, and one order-protected site (the `build-video` `part-*` writes, disclosed in 1.7.7). +- Test count unchanged (243 in fourteen files): the canary's assertions were corrected, not added to. + +## 1.7.7 — 2026-09-19 + +**Tenth-round audit: the 1.7.6 "no other member of this class" claim was wrong — one more leaf existed** + +- **`asr.mjs` no longer writes its transcode temp next to the input file.** An independent sweep (the spec axis re-did the write-site inventory rather than trusting the changelog) found the one site the 1.7.6 sweep missed: oversized inputs are transcoded to `.asr-.16k.mp3` **beside the input**, so for an input inside the project a pre-planted file symlink at that derived name would be written through by `ffmpeg -y`. The temp now goes to a per-invocation `mkdtemp` directory under `os.tmpdir()`, removed whole when the transcription attempt ends. New canary drives a real 501-second input (past the 500 s limit → real transcode, which runs **before** any network request) with the old temp name planted as a file symlink next to the input: nothing appears in the input directory, the canary is untouched. Runs on Linux CI; names its skip on stock Windows. +- **Correction to 1.7.6's claim, stated plainly:** its changelog said a sweep found "no other member of this class". That was false — the `asr` transcode temp above was exactly such a member, missed by our own sweep and caught by an independent one. Lesson recorded: a sweep claim about absence is only as good as the inventory it ran on, and "we looked" is not evidence. +- Disclosed without change: `build-video`'s `part-*.mp3` writes are protected **by order** (the preceding `safeOut`-jailed delete of every old `part-*` rejects a pre-planted leaf symlink before the ffmpeg write) — the invariant is now a code comment so the ordering is not accidentally reordered. +- Tests **242 → 243 in fourteen files**, all green in the development tree and both published trees (the three file-symlink canaries name their skip where Windows lacks Developer Mode). + +## 1.7.6 — 2026-09-19 + +**Ninth-round audit: the last uncontained leaf, and a body sentence fixed to stop going stale** + +- **`grab-frames` frame writes are now leaf-contained.** Its output *directory* was jailed, but `build/introspect/frame--.png` was written with plain `path.join` (the comment argued the `safeId` whitelist rules out path injection — true for traversal, blind to a pre-planted **file symlink**, which `ffmpeg -y` would have written through). The leaf goes through `safeOut` now, with a canary (needs ffmpeg + file symlinks → runs on Linux CI, names its skip on stock Windows). A sweep over all remaining raw write/delete sites in `scripts/` found no other member of this class. +- **The PR body's tool-less-sandbox sentence no longer carries per-version numbers.** It named "23 / 28 skipped" — true when written (1.7.4), stale twice since, because every release adds tests and the skip count legitimately drifts with the environment. The body now states the invariant ("every test that needs a tool skips with its stated reason — nothing pretends to pass") and points at the CHANGELOG for per-version measurements. +- Hygiene: the ancestor-link canary file in `safe-paths` is removed after the test instead of lingering in the shared temp root. +- Tests **241 → 242 in fourteen files**, all green in the development tree and both published trees (the two file-symlink canaries name their skip where Windows lacks Developer Mode). + +## 1.7.5 — 2026-09-19 + +**Third maintainer review, current head: all four code findings fixed** + +- **`safeRel` no longer accepts a project-local link to the project's own ancestor.** The "resolved path may sit above the root" fallback — meant for a not-yet-existing project root — also matched a link inside an existing project pointing at its parent, so a read path under the link resolved *outside* the project (reproduced live: a write through `safeRel(root, 'link/new.txt')` landed in the parent directory). The fallback now applies **only while the root itself does not exist**; once it exists, the resolved path must be the root or beneath it. Regression case in `safe-paths` covers both the new-target (the hole) and existing-target shapes. +- **All three `preview-page` leaf writes are contained.** The output directory was jailed, but `01.html`, `01.nofx.html` and `index.html` inside it were written with plain `path.join` — a pre-planted file symlink redirected the write outside the project. All three go through `safeOut` now, with a file-symlink canary (file links need Windows Developer Mode, so the canary names its skip there and really runs on Linux CI). +- **`asr.mjs --out` is contained and refuses to clobber.** It previously wrote any path raw and silently overwrote. It now accepts only a project-relative path inside the project (absolute paths rejected with a named error), refuses an existing output without `--force`, and — so the refusals need no network and are testable on bare CI — the checks run *before* the transcription request. +- **`prep-image --crop` is now a declared exception, not an inconsistency.** Its input and output are given explicitly on the command line (the same trust level as running ffmpeg yourself), so both READMEs carve it out of the write-boundary promise explicitly (overwriting still needs `--force`); every *derived* path remains fully contained. +- The review also confirmed the earlier rounds' fixes on this head, and noted the fork's CI runs sit at `action_required` — they execute only after a maintainer approves them; the scoped workflow is ready to install ffmpeg + Chromium and run the full suite on approval. +- Tests **238 → 241 in fourteen files**, all green with 0 skips in the development tree and in both published trees. + +## 1.7.4 — 2026-09-18 + +**Eighth-round audit: no serious findings — one stale evidence line corrected, three small hardenings** + +- **The PR body's sandbox sentence had gone stale**: it still said both tool-less sandbox shapes report "0 fail and 23 skipped", which the 1.7.3 evidence itself supersedes (the links-not-creatable shape reports 28 skipped). The body now carries both shape numbers. +- **`capture` now validates `script.width`/`script.height`** the same way `preview-page` and `build-video` already do (integer, 16–16384) — previously a non-integer died with a raw Playwright stack deep in the browser launch, and an out-of-range value silently produced off-canvas frames. The gate sits **before** the Playwright load, so the new regression case runs on tool-less CI as well. +- Test-infra hygiene: the two remaining string-concatenated `file://` imports in the test helpers now go through `pathToFileURL` (a checkout path containing `#` or `%` used to break them loudly), and the suite's temp directories are removed on process exit instead of accumulating in `%TEMP%`. +- Disclosed, not changed: `preview-page`'s `?s=k` step-stepping silently disables itself when a hand-written slide has no `` for the script to attach to (generated slides always have one). +- Tests **237 → 238 in fourteen files**, all green with 0 skips in the development tree and in both published trees; both tool-less sandbox shapes 0 fail (23 and 28 skipped, reasons stated). + +## 1.7.3 — 2026-09-18 + +**Fifth-round audit: five containment tests could pass vacuously where links cannot be created** + +- Five tests that build a symlink/junction to exercise the path jail (`safeRel` escape, `safeRel` dangling link, unit-level `safeOut`, `init-project --upgrade-css` containment, `init-project` skeleton containment) ended with a bare early `return` when the link could not be created — green, but testing nothing, which contradicts the README's "nothing pretends to pass" promise (the seventh-round review caught it; two of the five were pointed out, a repo-wide grep found all five). They now skip **with a named reason**, and the `safeRel` escape test gained the junction fallback so it really runs on stock Windows instead of skipping there. +- Verified in both tool-less sandbox shapes: links creatable → 237 tests, 0 fail, 23 skipped; links not creatable → 209 pass, 0 fail, 28 skipped — the five formerly vacuous passes are now the five extra named skips, and no shape reports a pass it did not earn. +- Small dedups from the same pass: `build-video` reads `--transition` through the shared `flagValue` instead of an inline reimplementation; the e2e image-listing fixture builds its `file://` URL with `pathToFileURL` (temp paths with spaces/non-ASCII). Disclosed, not changed: the `--get` download loop re-walks redirects without self-loop detection — it is bounded by `MAX_REDIRECTS`, so this is redundancy, not a hole; the test-side Playwright probe runs one synchronous `npm root -g` at discovery. + +## 1.7.2 — 2026-09-18 + +**Final audit round: one silent feature regression, one environment-dependent test, and a corrected evidence claim** + +- **`--json` never printed JSON in its documented position.** The flag is boolean, but it was read through the value-taking reader, so in the documented trailing position (`fetch-official-images.mjs --json`) it resolved to `undefined` and the listing silently degraded to the human-readable form. It is now read by presence, and a new end-to-end case drives a local `file://` fixture page through the real listing path with the flag in **both** positions, asserting actual JSON array output (the pre-existing case only asserted that the run reached URL validation, so it could not see this). +- **The two symlink-canary cases could redden a tool-less CI.** They had no Playwright guard, and both `capture` and `preview-page` load Playwright *before* their containment check: on a runner where symlinks/junctions are creatable but Playwright is absent (an ubuntu CI), `capture` exits `未找到 playwright` and the case failed on its expected-message assertion — the same class of host-CI reddening fixed in 1.7.1, in a different environment shape. Both cases now skip with a stated reason when Playwright is missing. +- **Evidence correction to 1.7.1.** Its "clean sandbox: 0 fail, 22 skipped" was measured in a Windows sandbox where junction creation itself failed (the shell was unreachable on the stripped PATH), which masked exactly the case above. Corrected measurements, after the fixes, in two isolated tool-less sandbox shapes — symlinks creatable and not creatable — agree: **237 tests, 0 fail, 23 skipped**, every skip naming its reason. +- Tests **236 → 237 in fourteen files**, all green with 0 skips in the development tree and in both published trees. + +## 1.7.1 — 2026-09-18 + +**Fourth pass over the *published* PR: three claims that were not true of the published tree, and the one test that could redden the host CI** + +- **The 1.7.0 CI rewrite had never been pushed.** The PR branch still carried the old scoped workflow — explicit file enumeration (so `cover-transition`, `review-round2`, `review-round3` and `subtitles-invalidate` never ran in any CI), no `permissions:` block, floating `@v4` action tags — while the 1.7.0 entry above already described the corrected form. The corrected workflow (shell glob `tests/*.test.mjs`, `permissions: contents: read`, full-SHA pins with version comments) is what the branch carries now. +- **The host monorepo's own CI would have gone red.** `npm run check` = validate + a root-level `node --test`, which auto-discovers this plugin's tests — on runners that have neither ffmpeg nor Playwright. Exactly one case lacked the tool guard every other rendering case has (`asr.mjs` exits with `找不到 ffmpeg` at startup, before any request is made): the ASR-503 case now skips with its stated reason like the rest. Verified in a clean sandbox with neither tool resolvable: **236 tests, 0 fail, 22 skipped with stated reasons** — and the pipeline scripts themselves still fail loudly rather than pretend to run. +- **The 1.7.0 README sentence "the rendering suites fail rather than skip" was itself wrong** — it rested on an observation made where `ffmpeg-static` was silently discoverable through a workspace-level `node_modules`. Both READMEs now state what actually happens: tests that need a tool skip with their stated reason; the scripts exit with `找不到 ffmpeg` instead of running half a pipeline. +- **Plan B2's third manifest check is implemented.** `build-video` now also compares the subtitle manifest's `framesCover` with the current frame sequence's real duration (it previously checked only the slide duration and the clause indices) — a re-captured animation window no longer splices subtitle stills at stale offsets. New pixel-level case in `subtitles-invalidate`: a stale manifest is ignored with a named warning (measured luma difference 45 against the stale still — it is not in the picture), an aligned one splices it in (difference 0). +- Smaller findings from the same pass: `VALUE_FLAGS` no longer lists `--speed` / `--voice` (nothing parses them, and a stray `--speed` silently swallowed the next positional), the six per-script `flag()` helpers collapsed into the one `flagValue` in `tools.mjs`, and the internal `KIT_PROJECT_DIR` variable is now documented in both READMEs. +- Tests **235 → 236 in fourteen files**, all green with 0 skips in the development tree and in both published trees. + +## 1.7.0 — 2026-09-18 + +**Third review round: the CSS-propagation rewrite, stale-CSS blocking, and six silent-failure paths closed** + +**CSS propagation is now a single managed region (`tokens`), and staleness blocks rendering.** +The skill writes its generated CSS into the project's `slides/tokens.css`. The old mechanism delimited only the three toolkit blocks (no-fx / charts / tables) and decided "up to date" by looking at *the one block it recognised*, which left four paths that reported `ok` while the project still rendered old rules: ① the same toolkit present **twice** (the later copy wins the cascade and was never examined); ② a stale copy **outside** the managed blocks (written by an older, append-style upgrade, or pasted by hand); ③ a **mangled closing delimiter** (the regex no longer recognises the block, so the old existence probe reported `ok`, and the upgrade emitted a *second* opening marker); ④ drift in the **generated body itself** (theme tokens, `--fs-*` scale, fx keyframes/utilities, `.fx-stagger`) was not managed at all. Now the whole generated body is wrapped in one `tokens` region with the three toolkit blocks nested inside, and the status vocabulary is explicit: `ok` / `stale` / `duplicate` / `broken` / `legacy-outside` / `missing`. `--upgrade-css` replaces the region in place, merges duplicates, removes orphan markers, and cleans leftover full copies of a toolkit outside the region — **rule by rule**, so a single project override sitting next to a leftover copy survives (only the verbatim module rules are removed), with the original text in `tokens.css.bak`. A legacy file that is still the old "bare text" format but byte-identical to the current version reports `ok` and is wrapped in place on upgrade rather than duplicated. An adversarial re-review of the new mechanism found **two more silent paths inside it**: a current region **plus a stale copy of the generated body outside it** still reported `ok` (the outside scan only recognised toolkit selectors, not body-shaped rules — it now classifies those too and reports `legacy-outside`), and the `ok` note for a genuine project override did not say **where** the override sits, which is what decides whether it wins: before the region it loses the cascade, after it wins. The note now names the position and which of the two applies. **`check-slides`, `capture` and `build-video` now refuse to run while the region is stale** (exit 1 with the issue list and the `--upgrade-css` command). The gate at the two rendering entry points has an explicit escape — `--allow-stale-css` downgrades it to a warning — while `check-slides` is a static gate and stays blocking by design (it is the check you run *before* rendering; `SKILL.md` records that it has no such flag). The play page's fallback injection states plainly that a correct preview page does **not** mean the rendered video is current. Measured along the way: an ordering bug of my own — the leftover-copy cleanup ran *before* the region step — deleted the toolkit text out of a bare-text body, so the in-place match missed and the upgrade both duplicated the body and left the old rules **winning the cascade**; the regression test asserts the region lands *after* legacy content (later declaration wins) and that the render actually changes, pixel-level (a stale project's frame is measurably darker, and after the upgrade it returns to the skill's current picture within 0.05 luma). + +**A two-axis review of this very change set** (standards vs. spec, run against the published 1.6.0 tree) found a further batch, all closed here: `build-video` discovered its tools **before** validating the configuration, so on a machine without ffmpeg a bad `width`/`fps`/`bgm` was reported as "ffmpeg not found" (exit 2) and the real error was swallowed — tool discovery now happens after every config check; `requireFreshCss` was the one entry point that read `tokens.css` **without** the 2 MB scan cap the other three enforce (it now refuses an oversized file on the same rule); `init-project` contained only its `--upgrade-css` path — the **skeleton** writes (8 directories + 5 files) were still raw `path.join`, so a pre-existing `assets/` or `slides/` link pointing outside the project received the generated files (now contained, with the project root created first so the jail has a real root to compare against); `fetch-official-images` contained each downloaded **file** but created its output **directory** unchecked; `readTransition` now names the source it rejected (`--transition` vs `script.transition`); two `generateTokensCss()` re-assemblies collapsed into one shared `expectedTokens()` used by `check-slides` and the play page; and the dead `needsUpgrade` export was removed. Doc-vs-code drift fixed in the same pass: the READMEs still promised "three rev-stamped blocks … rules outside the blocks are untouched" (the pre-1.7.0 contract, and the opposite of what the leftover cleanup does), the README module list named six of eight modules and kept the old mechanism's name, the `init-project` and `check-slides` headers still described their pre-1.7.0 behaviour (`check-slides`' staleness item is error level, and it deliberately has no `--allow-stale-css`), `capture`'s usage line said `--dsf 1|2` and omitted two flags, and the README's "where those are missing they skip with a stated reason" was **false** for the rendering suites — without ffmpeg they fail, so the sentence now says so (only the cases with a declared reason skip). + +**The plugin validation entry point no longer validates a stale copy.** The host's `npm run check` scans `_official-plugins/plugins/**`, so validating while the staged copy was one release behind produced a **green result that proved nothing** — which is exactly how the 1024-character `description` limit (`validation.mjs:82`) was missed twice. New `publish/validate-plugin.mjs`: stages the plugin tree into the host checkout, **fingerprints every file** (sha256) and refuses to continue if the staged copy differs from the source tree, then runs the upstream validator and prints its exit code. The skill's `description` is now 977 characters, with the symptom list extracted to `references/symptoms.md` and a pointer to it from `SKILL.md`. + +**Second class of output escape closed (a directory inside the project can itself be a symlink).** `tools.mjs`'s path jail walks ancestors with `lstat` now (a **dangling** symlink is detected instead of being treated as "does not exist" and skipped) and **fails closed** when `realpath` cannot be resolved (it used to return the absolute path and let the write through). Output containment (`safeOut`) is applied to every remaining write site — `plan-timings`, `check-timing`, `build-video` (including leaf files `concat.txt`, `audio-timeline.wav`, `audio-mix.wav`, `final.mp4`, `asr/checklist.md`), `asr`, `capture`, `fetch-official-images`, `init-project` — with a unit test asserting "a junction parent pointing outside the project is refused". + +**Subtitle stills are invalidated together with the subtitles.** `capture` deletes `build/substills//` and its manifest before writing new ones, and `build-video` validates the manifest against the current slide (`duration`, `stills[].k < clauses.length`, `framesCover`) and ignores it with a named warning if it does not match — otherwise deleting clauses (or `--no-subs`) and rebuilding spliced the *old* subtitles back into the video. + +**SSRF policy completed.** Blocked ranges now cover `0/8`, `10/8`, `100.64/10`, `127/8`, `169.254/16`, `172.16/12`, `192.0.0/24`, `192.0.2/24`, `192.168/16`, `198.18/15`, `198.51.100/24`, `203.0.113/24`, `224/4`, `240/4`, plus `fec0::/10` and `2001:db8::/32` on the IPv6 side; a trailing-dot FQDN (`localhost.`) is normalised before the check, embedded IPv4 addresses inside `::ffff:` **NAT64 `64:ff9b::/96`** and **6to4 `2002::/16`** are decoded recursively, and a dead `localhost` branch was removed. + +**Seven functional defects (each one a documented way to get a wrong artefact or a misleading exit code).** +- `check-timing --calibrate` could write back a **mixture** of exact-punctuation-aligned and nearest-neighbour boundaries while reporting `method: "nearest"` (the nearest pass reused the array the failed exact pass had already partly filled). The two passes now allocate separately, `nearest` is only reported when **every** boundary was measured, `sparse` is display-only, and a run that calibrated nothing no longer rewrites the file at all. +- Positional-argument parsing: `init-project.mjs --topic 主题 ` treated the topic's **value** as the project directory and silently created a skeleton named after the topic in the current directory. Every script that takes a positional project directory now resolves it through `tools.positionalDir`, which skips the values of value-taking flags and shares one `VALUE_FLAGS` list with the rest of the module (`flagValue` is exported for callers that also need a flag's value, with the same list behind it). +- `fetch-official-images`: `--json` was listed as a value-taking flag, so `--json ` swallowed the URL and reported a usage error (position-independent now); `--min` accepts `WxH` (`800x600`, `x600`) instead of comparing width only; and a `--get` run where **every** download failed exits 1 instead of 0. +- Argument validation at the entry point: `capture --mode` accepts only `still|motion`, `--dsf` only the integers 1–4, `grab-frames --at` only values in `(0,1]` (a typo used to reach Playwright as `deviceScaleFactor: NaN` or be silently clamped to the first frame); `grab-frames` exits 1 when it captured zero frames, and a missing ffmpeg is exit 2 (the convention `tools.requireTool` already had). +- The subtitle-band gate matched `padding-bottom`/`margin-bottom` (no word boundary) and flagged elements that are not positioned at all. It now requires `position: absolute|fixed` in the same rule, handles both ` + + +
+ 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..3b851081 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/plan-timings.mjs @@ -0,0 +1,111 @@ +#!/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, validateScriptPaths } from './tools.mjs'; + +// 语种相关的计量基准。中文按"字", 英文按"字符"(含词间节奏, 与音节时长大致成正比)。 +// pacing = 每单位每秒的常见语速; subMax = 字幕单行建议上限; pace 区间用于语速异常预警。 +const LANG_CFG = { + zh: { unit: '字', pacing: 4.8, paceMin: 3, paceMax: 6.5, subMax: 18, word: null }, + yue: { unit: '字', pacing: 4.8, paceMin: 3, paceMax: 6.5, subMax: 18, word: null }, + en: { unit: '字符', pacing: 14, paceMin: 9, paceMax: 18, subMax: 42, word: 'words' }, +}; +const langCfg = code => LANG_CFG[code] ?? { unit: '字符', pacing: 14, paceMin: 8, paceMax: 20, subMax: 42, word: null }; + +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); } + + const total = s.clauses.reduce((n, c) => n + charCount(c.text), 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; + clauses.push({ stage: c.stage ?? null, start: r3(start), chars: charCount(c.text), text: c.text, ...(c.text2 ? { text2: c.text2 } : {}) }); + 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 += charCount(c.text); + } + 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 加大或精简口播`); + for (const c of clauses) { + const n = charCount(c.text); + if (n > CFG.subMax) warns.push(`${s.id} 第 ${clauses.indexOf(c) + 1} 句 ${n} ${CFG.unit} > ${CFG.subMax}, 字幕会换行 — 建议拆句`); + if (c.text2 && c.text2.length > 60) warns.push(`${s.id} 第 ${clauses.indexOf(c) + 1} 句双语第二行 ${c.text2.length} 字符 > 60 — 建议精简译文`); + } + + 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..8361a3ec --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/preview-page.mjs @@ -0,0 +1,637 @@ +#!/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 { fileURLToPath } from 'node:url'; +import { expectedTokens, 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 也加不上(实测: 原延迟仍在、没有画布宽、动效关不掉) +const ATTR_RE = name => new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i'); +const attrValue = (tag, name) => { + const m = ATTR_RE(name).exec(tag); + return m ? (m[2] ?? m[3] ?? m[4]) : null; +}; + +function firstTag(html, name) { + const m = html.match(new RegExp(`<${name}\\b[^>]*>`, 'i')); + return m ? { tag: m[0], index: m.index } : null; +} + +function setAttr(tag, name, value) { + if (attrValue(tag, name) !== null) return tag.replace(ATTR_RE(name), () => `${name}="${value}"`); + return tag.replace(/\s*\/?>$/, m => ` ${name}="${value}"${m.endsWith('/>') ? '/>' : '>'}`); +} + +function addStyleDecl(tag, decl) { + if (!decl) return tag; + const cur = attrValue(tag, 'style'); + if (cur === null) return setAttr(tag, 'style', decl); + return setAttr(tag, 'style', `${cur.trim().replace(/;?$/, ';')}${decl}`); +} + +// 把注入值挂在 上(等价于 capture 的 documentElement.style.setProperty, 优先级最高) +export function injectHtmlVars(html, decl) { + const t = firstTag(html, 'html'); + if (!t) return html; + return html.slice(0, t.index) + addStyleDecl(t.tag, decl) + html.slice(t.index + t.tag.length); +} + +export function addNoFx(html) { + const t = firstTag(html, 'html'); + if (!t) return html; + const cls = attrValue(t.tag, 'class'); + if (cls !== null && /\bno-fx\b/.test(cls)) return html; + const tag = setAttr(t.tag, 'class', cls === null ? 'no-fx' : `${cls.trim()} no-fx`); + return html.slice(0, t.index) + tag + html.slice(t.index + t.tag.length); +} + +// 必须排在所有相对 URL 之前(slides/ 内含 , 若排在后面会失效) +export function addBaseHref(html, href) { + const existing = html.match(/]*>/i); + if (existing) return html.replace(existing[0], ``); + const h = firstTag(html, 'head'); + if (!h) return html; + const at = h.index + h.tag.length; + return html.slice(0, at) + `\n` + html.slice(at); +} + +// 兜底: 项目 tokens.css 落后于技能当前版本时, 把缺的那段注入副本(排在 tokens.css 之后, 后写覆盖) +export function injectStyle(html, css, note = '本项目 tokens.css 缺这段规则') { + const block = `\n`; + const i = html.search(/<\/head>/i); + return i < 0 ? block + html : html.slice(0, i) + block + html.slice(i); +} + +// 动效开的**逐级步进**: 往副本 开头注入一段在 CSS 动画启动前执行的脚本 —— +// ?s=k 表示已揭示到第 k 个用到的 stage: 第 k 级带 &anim=1 时当场入场, 更早的级直接终态 +// (-60s 延迟 = 动画早已完成), 更晚的级保持隐藏(+60s 延迟 = 还在等, 等用户按"下一步")。 +// 父页只换 iframe src(与快照同一套机制), 不做任何跨文档访问 —— file:// 下 iframe 是独立源。 +export function addStepScript(html, stages = []) { + if (!stages.length) return html; + const script = ``; + const m = /]*>/i.exec(html); + if (!m) return script + html; + return html.slice(0, m.index + m[0].length) + '\n' + script + html.slice(m.index + m[0].length); +} + +// ─────────────────────────── 放映页 ─────────────────────────── + +export function buildPlayPage({ + topic = '', lang = 'zh', slides = [], cssNote = '', generatedAt = '', + narration = true, // 是否加载口播文案 UI(没有 clauses 或 --no-script 时为 false) + timing = false, // 是否有对时数据(只影响标题上的"(未对时)"标注) + fallbackNote = '', // "还没对时/等间隔预览"的如实说明(与口播 UI 无关, 画面上也要说清) + canvas = { w: 1920, h: 1080 }, // 画布尺寸(竖版 1080×1920): 舞台缩放与缩略图比例都按它算 +} = {}) { + // UI 双语(实测反馈: 英文项目的放映页整套中文按钮)。lang 来自 script.json(en* → 英文, 其余中文) + const T = String(lang).toLowerCase().startsWith('en') ? { + title: 'Play', fxOn: 'Motion on', fxOff: 'Motion off', narrOn: 'Narration on', narrOff: 'Narration off', + overview: 'Overview', panelH: 'Narration for this slide', untimed: '(untimed)', + fxTitle: 'Motion on / off (key X)', narrTitle: 'Narration on / off (key P)', + prevTitle: 'Previous level / slide', nextTitle: 'Next level / slide', + hintStep: 'reveal level / slide', hintFx: 'motion on / off', hintNarr: 'narration on / off', + hintOv: 'overview', hintFs: 'fullscreen', level: 'step', + cut: 'Cut', xfade: 'Dissolve', transTitle: 'Slide transition: cut / dissolve (compare, then set script.json transition)', hintTrans: 'slide transition', + gen: 'Snapshots generated', genNote: 'same source as the final video (no audio, no subtitles)', + noThumbA: 'No thumbnail (preview/', ovH: 'Overview · click any slide to jump (thumbnails from preview/*.png)', + } : { + title: '放映页', fxOn: '动效开', fxOff: '动效关', narrOn: '口播开', narrOff: '口播关', + overview: '总览', panelH: '本张口播文案', untimed: '(未对时)', + fxTitle: '动效开 / 动效关(快捷键 X)', narrTitle: '口播开 / 口播关(快捷键 P)', + prevTitle: '上一级 / 上一张', nextTitle: '下一级 / 下一张', + hintStep: '逐级入场 / 翻页', hintFx: '动效开 / 动效关', hintNarr: '口播开 / 口播关', + hintOv: '总览', hintFs: '全屏', level: '级', + cut: '硬切', xfade: '溶解', transTitle: '切页方式:硬切 / 溶解(现场对比后写进 script.json 的 transition)', hintTrans: '切页方式', + gen: '快照生成于', genNote: '画面与成片同源(无声、无字幕)', + noThumbA: '无缩略图(preview/', ovH: '总览 · 点任意一张跳转(缩略图来自 preview/*.png)', + }; + const model = slides.map(s => ({ + id: s.id, name: s.name, title: s.title ?? '', + src: s.copy, nofx: s.copyNofx, + steps: (s.steps && s.steps.length) ? s.steps : [1], // 动效开时逐级揭示的 stage 序列 + clauses: (s.clauses ?? []).map(c => ({ stage: c.stage ?? null, text: c.text ?? '', text2: c.text2 ?? '' })), + })); + const json = JSON.stringify(model).replace(/⚠ ${esc(cssNote)}

` : ''; + const notice = fallbackNote ? `
${esc(fallbackNote)}
` : ''; + + return ` + + + + + +${T.title} · ${esc(topic) || 'html2video'} · html2video-for-mcode + + + +
+
+ ${esc(topic) || T.title} + 1 / ${slides.length} + +
+ ${notice} +
+
+
+ ${narration ? `` : ''} +
+
+ + ${T.hintStep} + X ${T.hintFx} + ${narration ? `P ${T.hintNarr}` : ''} + O ${T.hintOv} + F ${T.hintFs} + T ${T.hintTrans} + + + + + ${narration ? `` : ''} + + + + + ${T.gen} ${esc(generatedAt)} · ${T.genNote} +
+

${T.ovH}

+ + + +`; +} + +// ─────────────────────────── 主流程 ─────────────────────────── + +function main() { + const argv = process.argv.slice(2); + const dir = positionalDir(argv); + const OPEN = argv.includes('--open'); + const NO_SCRIPT = argv.includes('--no-script'); // 不加载口播文案 UI(只想看 HTML 画面时) + process.env.KIT_PROJECT_DIR = dir; + + const scriptPath = path.join(dir, 'script.json'); + if (!fs.existsSync(scriptPath)) { console.error(`✗ 找不到 ${scriptPath}`); process.exit(1); } + const script = JSON.parse(fs.readFileSync(scriptPath, 'utf8')); + validateScriptPaths(script, dir); // id/html 派生路径收监(与 capture 同一道门) + + const timingsPath = path.join(dir, 'build', 'timings.json'); + const hasTimings = fs.existsSync(timingsPath); + const timings = hasTimings ? JSON.parse(fs.readFileSync(timingsPath, 'utf8')) : null; + + const slidesDir = path.join(dir, 'slides'); + const outDir = safeOut(dir, 'preview', 'play'); + fs.mkdirSync(outDir, { recursive: true }); + + // 项目 tokens.css 落后于技能当前版本(缺 no-fx / 图表 / 表格任一段)时给副本兜底注入当前版, + // 并在页面与终端如实说明 —— 否则"关动效对照"会把画面锁在入场前的透明态, 旧图表规则也会 + // 把新版画法(如 .chart-ticks)渲染坏。注入的 +

x

图:来源
`)]); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.ok(!/字幕带/.test(r.stdout), 'padding/margin-bottom 不该被当 bottom: ' + r.stdout.slice(-400)); + }); + test('流内元素写 bottom 不报(不产生位移), 绝对定位才报', () => { + const stat = runSkill('check-slides.mjs', [mk(` + +

x

图:来源
`)]); + assert.ok(!/字幕带/.test(stat.stdout), '没有 position:absolute 就不该报: ' + stat.stdout.slice(-300)); + const abs = runSkill('check-slides.mjs', [mk(` + +

x

图:来源
`)]); + assert.match(abs.stdout, /字幕带/, '绝对定位贴底要报'); + assert.equal(abs.status, 0, '这是提示级, 不阻塞流水线'); + // 行内 style 也要能认出来 + const inline = runSkill('check-slides.mjs', [mk(` +

x

图:来源
`)]); + assert.match(inline.stdout, /字幕带/, '行内 style 的 absolute+bottom 同样要报'); + }); +}); + +describe('D6 · transition 单一解析(闸门与渲染器结论一致)', () => { + test('readTransition 接受裸字符串与对象, 结论相同', () => { + assert.deepEqual(readTransition('xfade'), { type: 'xfade', dur: 0.4, label: '交叉溶解 xfade 0.4s' }); + assert.deepEqual(readTransition({ type: 'xfade' }), readTransition('xfade')); + assert.deepEqual(readTransition('cut'), { type: 'cut', dur: 0, label: '硬切 cut' }); + assert.deepEqual(readTransition(null), readTransition(undefined)); + assert.deepEqual(readTransition({ type: 'xfade', duration: 1.2 }).dur, 1.2); + for (const bad of ['fade', 42, { type: 'fade' }, { type: 'xfade', duration: 9 }, { type: 'xfade', duration: 0 }]) { + assert.throws(() => readTransition(bad), /非法/, `${JSON.stringify(bad)} 必须报错`); + } + }); + + test('check-slides 与 build-video 对 "xfade" 字符串给同一结论', { skip: !FFMPEG && '无 ffmpeg(用 check-slides 的标签确认)', }, () => { + const p = mkproj(tmpdir(), { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(p, 'slides', '01.html'), SLIDE); + // build-video 读 timings.json 在 --dry-run 之前, 所以最小骨架要补一份 + fs.mkdirSync(path.join(p, 'build'), { recursive: true }); + fs.writeFileSync(path.join(p, 'build', 'timings.json'), JSON.stringify({ + fps: 30, total: 3.0, tts: 3.0, slides: [{ id: '01', duration: 3.0, tts: 3.0, clauses: [] }], + }, null, 2)); + // build-video 的输入检查(帧序列 / preview/.png)在 --dry-run 之前也会做, 所以给一张占位静帧 + const png = path.join(p, 'preview', '01.png'); + fs.mkdirSync(path.dirname(png), { recursive: true }); + const g = spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=#123456:s=1920x1080', '-frames:v', '1', '-y', png], { windowsHide: true }); + assert.equal(g.status, 0, '应能造出占位静帧'); + // 音轨也要在(缺音频会在自检之前就退出, 看不到我们要断言的转场行) + const au = spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', '-t', '3', + '-c:a', 'libmp3lame', '-b:a', '64k', '-y', path.join(p, 'audio', '01.mp3')], { windowsHide: true }); + assert.equal(au.status, 0, '应能造出静音音轨'); + const setT = v => { + const s = JSON.parse(fs.readFileSync(path.join(p, 'script.json'), 'utf8')); + s.transition = v; + fs.writeFileSync(path.join(p, 'script.json'), JSON.stringify(s, null, 2)); + }; + setT('xfade'); + const str = runSkill('check-slides.mjs', [p]); + assert.match(str.stdout, /交叉溶解 xfade/, str.stdout.slice(-300)); + setT({ type: 'xfade' }); + const obj = runSkill('check-slides.mjs', [p]); + assert.equal(obj.stdout.match(/转场 = ([^;]*)/)?.[1], str.stdout.match(/转场 = ([^;]*)/)?.[1], + '裸字符串与对象在闸门里必须一样(旧实现只认对象里的 type, 字符串会显示成非法值)'); + setT('fade'); + const bad = runSkill('check-slides.mjs', [p]); + assert.match(bad.stdout, /✗/, '非法值要显式报出来'); + }); +}); + +describe('复查补充 · 取值型 flag 清单 + 落盘收监 + transition 两端一致', () => { + test('VALUE_FLAGS 不得包含布尔开关(否则它后面那个位置参数会被当值吃掉)', () => { + // 成因: 清单里混进 --json / --allow-stale-css 这类开关后, `capture --allow-stale-css <项目>` + // 会把 <项目> 当开关的值跳过 → 位置参数落到 cwd, 对着调用目录干活(2026-09-18 复查实测)。 + const DUAL = new Set(['--transition']); // 既能当开关又能带值(实测里有 argv.includes 也有取值) + const used = new Set(); + for (const f of fs.readdirSync(SCRIPTS)) { + if (!f.endsWith('.mjs')) continue; + const src = fs.readFileSync(path.join(SCRIPTS, f), 'utf8'); + for (const m of src.matchAll(/argv\.includes\(['"](--[a-z-]+)['"]\)/g)) used.add(m[1]); + } + const bad = [...used].filter(f => !DUAL.has(f) && VALUE_FLAGS.has(f)); + assert.deepEqual(bad, [], `这些开关被当成取值型 flag, 会把后面的位置参数吃掉: ${bad.join(', ')}`); + // 真实用法逐条: 开关后面的目录必须仍被认成项目目录 + for (const sw of ['--allow-stale-css', '--no-subs', '--json', '--both', '--force']) { + assert.equal(positionalDir([sw, './proj']), path.resolve('./proj'), `${sw} 后面跟目录时不得被当值吃掉`); + } + // 取值型照旧要跳过一个值 + for (const vf of ['--topic', '--ids', '--mode', '--dsf', '--at', '--transition']) { + assert.equal(positionalDir([vf, 'X', './proj']), path.resolve('./proj'), `${vf} 的值必须被跳过`); + } + assert.deepEqual(positionals(['--check', 'a.png', 'b.png']), ['a.png', 'b.png'], 'prep-image 的文件名是位置参数'); + }); + + test('init-project --upgrade-css 也要落盘收监: slides/ 是悬空链接时不得写到项目外', (t) => { + // 复查 B4: 计划里点名 init-project 与 fetch-official-images, 但两者此前都没有 safeOut —— + // CHANGELOG 却宣称"每个写入点都收监", 属于文档与代码不符(第三轮 review 抓到的)。 + const proj = mkproj(tmpdir(), { tokens: LEGACY_TOKENS }); + const outside = tmpdir(); + const slidesReal = path.join(proj, 'slides'); + fs.rmSync(slidesReal, { recursive: true, force: true }); + const made = (() => { + try { fs.symlinkSync(outside, slidesReal, 'dir'); return true; } catch { /* 试 junction */ } + const r = spawnSync('cmd', ['/c', 'mklink', '/J', slidesReal, outside], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && fs.existsSync(slidesReal); + })(); + if (!made) return t.skip('当前环境建不了符号链接/junction(收监拒绝无从观察)'); + // 让 slides 指向项目外(外面先放一份 tokens.css, 好走到"升级"分支) + fs.writeFileSync(path.join(outside, 'tokens.css'), LEGACY_TOKENS); + const r = runSkill('init-project.mjs', [proj, '--upgrade-css']); + assert.equal(r.status, 1, '项目内的目录段指向项目外时必须拒绝: ' + r.stdout + r.stderr); + assert.ok(/符号链接|越出|收监/.test(r.stderr + r.stdout), r.stderr + r.stdout); + assert.equal(fs.readFileSync(path.join(outside, 'tokens.css'), 'utf8'), LEGACY_TOKENS, '项目外的文件不得被改写'); + assert.equal(fs.existsSync(path.join(outside, 'tokens.css.bak')), false, '备份也不得写到项目外'); + }); + + test('transition: 渲染器(build-video)与闸门用同一解析 —— 裸字符串在两端同义', { skip: !FFMPEG && '无 ffmpeg' }, () => { + const p = mkproj(tmpdir(), { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(p, 'slides', '01.html'), SLIDE); + // build-video 在 --dry-run 之前就读 timings.json, 所以最小骨架要补一份 + fs.mkdirSync(path.join(p, 'build'), { recursive: true }); + fs.writeFileSync(path.join(p, 'build', 'timings.json'), JSON.stringify({ + fps: 30, total: 3.0, tts: 3.0, slides: [{ id: '01', duration: 3.0, tts: 3.0, clauses: [] }], + }, null, 2)); + const setT = v => { + const s = JSON.parse(fs.readFileSync(path.join(p, 'script.json'), 'utf8')); + if (v === null) delete s.transition; else s.transition = v; + fs.writeFileSync(path.join(p, 'script.json'), JSON.stringify(s, null, 2)); + }; + // --dry-run 会打印解析结果, 不需要真编码; 但 build-video 的输入检查(帧序列 / preview/.png / + // 音轨)在 dry-run 之前也会做, 所以占位静帧与静音音轨都得在 + const png = path.join(p, 'preview', '01.png'); + fs.mkdirSync(path.dirname(png), { recursive: true }); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=#123456:s=1920x1080', + '-frames:v', '1', '-y', png], { windowsHide: true }).status, 0, '应能造出占位静帧'); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', '-t', '3', + '-c:a', 'libmp3lame', '-b:a', '64k', '-y', path.join(p, 'audio', '01.mp3')], { windowsHide: true }).status, 0, '应能造出静音音轨'); + setT('xfade'); + const dry = runSkill('build-video.mjs', [p, '--dry-run']); + assert.equal(dry.status, 0, dry.stdout + dry.stderr); + assert.match(dry.stdout, /交叉溶解 0\.4s/, '裸字符串 xfade 必须被渲染器认成溶解: ' + dry.stdout); + setT({ type: 'xfade', duration: 1.2 }); + assert.match(runSkill('build-video.mjs', [p, '--dry-run']).stdout, /交叉溶解 1\.2s/); + setT(null); + assert.match(runSkill('build-video.mjs', [p, '--dry-run']).stdout, /转场: 硬切/, '缺省 = 硬切'); + setT('fade'); + const bad = runSkill('build-video.mjs', [p, '--dry-run']); + assert.equal(bad.status, 1, '非法值渲染器也要拒'); + assert.match(bad.stderr, /transition/, bad.stderr); + // 来源要点名: --transition 写错时不该说成 script.transition 写错(2026-09-18 复查抓到) + const badFlag = runSkill('build-video.mjs', [p, '--transition', 'bogus', '--dry-run']); + assert.equal(badFlag.status, 1); + assert.match(badFlag.stderr, /--transition/, `要点名 --transition: ${badFlag.stderr}`); + assert.ok(!/script\.transition/.test(badFlag.stderr), `不该甩锅给 script.json: ${badFlag.stderr}`); + }); + + test('init-project 骨架写点也收监: assets/ 指向项目外时 --force 重建不得写到项目外', (t) => { + // 与上一条同源(复查 B4): 收监是逐个写点做的, --upgrade-css 只是其中之一。骨架那次创建 + // 8 个目录 + 5 个文件, 这些路径此前都是裸 path.join —— 目标目录里预置一个指向项目外的 + // assets 链接(用户共享素材的常见做法), 生成物就会落到项目外。 + const proj = tmpdir(); + const outside = tmpdir(); + const assets = path.join(proj, 'assets'); + const made = (() => { + try { fs.symlinkSync(outside, assets, 'dir'); return true; } catch { /* 试 junction */ } + const r = spawnSync('cmd', ['/c', 'mklink', '/J', assets, outside], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && fs.existsSync(assets); + })(); + if (!made) return t.skip('当前环境建不了符号链接/junction(骨架写点收监无从观察)'); + const r = runSkill('init-project.mjs', [proj, '--force']); + assert.equal(r.status, 1, `项目内的目录段指向项目外时必须拒绝: ${r.stdout}${r.stderr}`); + assert.match(r.stderr + r.stdout, /越出|符号链接|收监/, r.stderr + r.stdout); + assert.deepEqual(fs.readdirSync(outside), [], '项目外目录里不得出现任何生成物'); + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/safe-paths.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/safe-paths.test.mjs new file mode 100644 index 00000000..b7b8e861 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/safe-paths.test.mjs @@ -0,0 +1,286 @@ +// 安全边界 · 路径收监(script.json 派生路径不得越出项目目录) +// 对应评审意见 1: Input-derived IDs and paths are not contained. +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 { inside, findTool } from '../scripts/tools.mjs'; +import { runSkill, probeHelper, mkproj, tmpdir } from './helpers.mjs'; + +describe('inside() 判定', () => { + test('根内相对路径 → true; 越出 → false', () => { + const root = path.resolve('/proj'); + assert.equal(inside(root, path.resolve('/proj/a/b.txt')), true); + assert.equal(inside(root, path.resolve('/proj-a/b.txt')), false, '前缀相似的兄弟目录不算在内'); + assert.equal(inside(root, path.resolve('/victim')), false); + assert.equal(inside(root, path.resolve('/proj')), false, '根本身不算 inside(要求严格子路径)'); + }); +}); + +describe('safeId / safeRel(退出型, subprocess 探测)', () => { + test('safeId: 合法 id 原样返回', () => { + for (const id of ['01', 'slide-2', 'A_b', 'x'.repeat(64)]) { + const r = probeHelper('safeId', `[${JSON.stringify(id)}]`); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.stdout.startsWith('RETURN:'), `safeId(${id}) 应返回原值`); + } + }); + test('safeId: 穿越/绝对/空/超长/换行 全部退出 1', () => { + for (const id of ['../../victim', '..\\..\\victim', '/abs', 'C:\\x', '', 'a/b', 'a\nb', 'x'.repeat(65), null, 42]) { + const r = probeHelper('safeId', `[${JSON.stringify(id)}]`); + assert.notEqual(r.status, 0, `safeId(${JSON.stringify(id)}) 必须拒绝`); + } + }); + test('safeRel: 根内相对路径返回绝对路径', () => { + const r = probeHelper('safeRel', `["/proj", "a/b.txt", {}]`); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.stdout.startsWith('RETURN:'), '根内路径应放行'); + }); + test('safeRel: 绝对路径 / ../ 穿越 / 空串 拒绝', () => { + for (const rel of ['/etc/passwd', '../../victim', '', 'a/../../..']) { + const r = probeHelper('safeRel', `["/proj", ${JSON.stringify(rel)}, {}]`); + assert.notEqual(r.status, 0, `safeRel(${JSON.stringify(rel)}) 必须拒绝`); + } + }); + test('safeRel: 符号链接逃逸拒绝(建不了链接则 skip)', (t) => { + const root = tmpdir(); + const outside = tmpdir(); + fs.writeFileSync(path.join(outside, 'secret.txt'), 'x'); + const link = path.join(root, 'assets-link'); + // symlink 需要开发者模式, junction(mklink /J)不需要 —— 两条路都试, 尽量真跑而不是跳过 + const made = (() => { + try { fs.symlinkSync(outside, link, 'dir'); return true; } catch { /* 退回 junction */ } + const r = spawnSync('cmd', ['/c', 'mklink', '/J', link, outside], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && fs.existsSync(link); + })(); + if (!made) return t.skip('当前环境建不了符号链接/junction'); + const r = probeHelper('safeRel', `["${path.resolve(root).replace(/\\/g, '/')}", "assets-link/secret.txt", {}]`); + assert.notEqual(r.status, 0, '指向项目外的符号链接必须拒绝'); + }); + + test('safeRel: 悬空链接(目标已删除)必须拒绝 —— existsSync 看不出它, 旧回溯会当"不存在"跳过', (t) => { + // 复查 B3: 旧实现用 existsSync 回溯祖先 —— 悬空链接的 existsSync 是 **false**, 于是它被跳过、 + // 链接本身从未被复核, 写进去就落到项目外。现在改用 lstat 停住 + realpath 解不开即 fail closed。 + const root = tmpdir(); + const target = tmpdir(); + const link = path.join(root, 'slides-link'); + const made = (() => { + try { fs.symlinkSync(target, link, 'dir'); return true; } catch { /* 退回 junction */ } + const r = spawnSync('cmd', ['/c', 'mklink', '/J', link, target], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && fs.existsSync(link); + })(); + if (!made) return t.skip('当前环境建不了符号链接/junction(悬空链接场景无从构造)'); + fs.rmSync(target, { recursive: true, force: true }); // 目标消失 → 链接悬空 + assert.equal(fs.existsSync(link), false, '前置: 悬空链接的 existsSync 必须是 false(旧实现的盲区正在这里)'); + assert.ok(fs.lstatSync(link).isSymbolicLink(), '前置: lstat 仍能看到这个 reparse point'); + const r = probeHelper('safeRel', `["${path.resolve(root).replace(/\\/g, '/')}", "slides-link/x.css", {}]`); + assert.notEqual(r.status, 0, '悬空链接必须拒绝(解不开就无法证明它落在项目内)'); + }); + + test('safeOut 单元级: 项目内的目录段是指向项目外的链接 → 直接调用即拒绝, 且外面没有留下任何文件', (t) => { + // 复查 B4 收尾: 收监此前只有"跑某个脚本"的集成用例。这里直接调 safeOut(不经任何流水线), + // 确认拒绝发生在这一层 —— 集成用例可能因为别的校验先退出, 从而给出假绿灯。 + const root = tmpdir(); + const outside = tmpdir(); + const linked = path.join(root, 'out'); + const made = (() => { + try { fs.symlinkSync(outside, linked, 'dir'); return true; } catch { /* 退回 junction */ } + const r = spawnSync('cmd', ['/c', 'mklink', '/J', linked, outside], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && fs.existsSync(linked); + })(); + if (!made) return t.skip('当前环境建不了符号链接/junction(safeOut 拒绝无从观察)'); + const r = probeHelper('safeOut', `["${path.resolve(root).replace(/\\/g, '/')}", "out", "x.txt"]`); + assert.notEqual(r.status, 0, 'safeOut 必须自己拒绝, 而不是把判断留给调用方'); + assert.match(r.stderr, /越出项目目录|符号链接/, `要点名是链接越界, 实际: ${r.stderr.slice(0, 200)}`); + assert.equal(fs.existsSync(path.join(outside, 'x.txt')), false, '拒绝必须发生在写入之前'); + }); + + test('safeRel: 项目内链接指向项目"父目录"时, 链接下的新路径必须拒绝(1.7.5 复查②, 实弹复现过的洞)', (t) => { + // 形状: link → 项目的父目录。目标**已存在**时游走能走到完整路径, 正确拒绝; + // 目标**尚不存在**时旧回退 inside(real, realRoot) 被满足(根在父目录之内)→ 放行 → 写穿到项目外。 + const root = tmpdir(); + const parent = path.dirname(root); // tmpdir() 每个都是独立目录, root 的父就是公共层 + fs.writeFileSync(path.join(parent, 'canary.txt'), 'KEEP'); + const link = path.join(root, 'link'); + const made = (() => { + try { fs.symlinkSync(parent, link, 'dir'); return true; } catch { /* 退回 junction */ } + const r = spawnSync('cmd', ['/c', 'mklink', '/J', link, parent], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && fs.existsSync(link); + })(); + if (!made) return t.skip('当前环境建不了符号链接/junction(祖先链接形状无从构造)'); + t.after(() => fs.rmSync(path.join(parent, 'canary.txt'), { force: true })); // canary 落在共享 %TEMP% 层, 测完即清 + const mk = rel => probeHelper('safeRel', `["${path.resolve(root).replace(/\\/g, '/')}", ${JSON.stringify(rel)}, {}]`); + const rNew = mk('link/new.txt'); + assert.notEqual(rNew.status, 0, `链接指向父目录 + 新目标必须拒绝, 实际: ${(rNew.stderr || rNew.stdout).slice(-200)}`); + const rOld = mk('link/canary.txt'); + assert.notEqual(rOld.status, 0, '已存在目标穿过链接同样必须拒绝'); + assert.equal(fs.existsSync(path.join(parent, 'new.txt')), false, '拒绝必须发生在写入之前'); + assert.equal(fs.readFileSync(path.join(parent, 'canary.txt'), 'utf8'), 'KEEP', 'canary 必须完好'); + }); +}); + +describe('消费者脚本: 恶意 script.json 必须在干坏事之前退出', () => { + test('capture: id=../../canary → 退出 1 且 canary 完好(递归删除被拦)', () => { + const proj = tmpdir(); + const canary = path.join(path.dirname(proj), 'canary-' + path.basename(proj)); + fs.mkdirSync(path.join(canary, 'deep', 'deeper'), { recursive: true }); + fs.writeFileSync(path.join(canary, 'deep', 'deeper', 'keep.txt'), 'do not delete'); + mkproj(proj, { slides: [{ id: '../../' + path.basename(canary), html: 'x.html', audio: 'a.mp3' }] }); + const r = runSkill('capture.mjs', [proj]); + assert.notEqual(r.status, 0, '恶意 id 必须被拒绝'); + assert.ok(fs.existsSync(path.join(canary, 'deep', 'deeper', 'keep.txt')), 'canary 必须完好'); + }); + + test('check-slides: html=../outside.html → 退出 1', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '../outside.html', audio: '01.mp3', clauses: [] }] }); + const r = runSkill('check-slides.mjs', [proj]); + assert.notEqual(r.status, 0); + assert.ok((r.stdout + r.stderr).includes('越出') || (r.stdout + r.stderr).includes('非法'), `应指明越界原因, 实际: ${r.stderr.slice(-300)}`); + }); + + test('check-slides: 越出项目目录 → 记为 error 并退出 1', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), + `
x
`); + const r = runSkill('check-slides.mjs', [proj]); + assert.notEqual(r.status, 0); + assert.ok(r.stdout.includes('越出项目目录'), `应报图片越界, 实际: ${r.stdout.slice(-300)}`); + }); + + test('plan-timings: audio=../../secret.mp3 → 退出 1(需 ffprobe)', { skip: !findTool('ffprobe') && '无 ffprobe, 跳过(CI 主工作流无 ffmpeg)' }, () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', audio: '../../secret.mp3', clauses: [{ stage: 1, text: 'x' }] }] }); + const r = runSkill('plan-timings.mjs', [proj]); + assert.notEqual(r.status, 0); + }); + + test('build-video: bgm.file 为绝对路径 → 退出 1(需 ffmpeg+ffprobe)', { skip: (!findTool('ffmpeg') || !findTool('ffprobe')) && '无 ffmpeg, 跳过' }, () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', audio: '01.mp3' }] }); + const scriptPath = path.join(proj, 'script.json'); + const script = JSON.parse(fs.readFileSync(scriptPath, 'utf8')); + script.bgm = { file: path.join(tmpdir(), 'evil-bgm.mp3') }; // 绝对路径直接逃逸 + fs.writeFileSync(scriptPath, JSON.stringify(script)); + const r = runSkill('build-video.mjs', [proj]); + assert.notEqual(r.status, 0); + assert.ok(r.stdout.includes('bgm') || r.stderr.includes('bgm'), '应点名 bgm.file'); + }); + + // ── 2026-09-18 三维审计发现的一致性缺口: 同一信任级的入参, 别处管了这里没管 ── + // 这些用例只喂 script.json + timings.json: 坏值必须在编码开始前就被拒(不依赖 ffmpeg), + // 否则就要等整条流水线跑完才报错 —— 原 width 用例没写 timings.json, 读文件就退出了, 属假绿 + const withTimings = (scriptPatch, timingsPatch = {}) => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'build', 'timings.json'), + JSON.stringify({ fps: 30, total: 1, slides: [{ id: '01', duration: 1 }], ...timingsPatch })); + const sp = path.join(proj, 'script.json'); + const script = JSON.parse(fs.readFileSync(sp, 'utf8')); + Object.assign(script, scriptPatch); + fs.writeFileSync(sp, JSON.stringify(script)); + return proj; + }; + + test('build-video: bgm.volume/fadeIn/fadeOut 非数值 → 退出 1(与 width 同一注入面, 此前漏管)', () => { + for (const bad of [ + { volume: "0.5,amovie='C:/x',volume=0.5" }, // 追加 filter 节点(ffmpeg filter 可读本地文件进输出音频) + { fadeIn: '1.5,volume=9' }, + { fadeOut: 'x' }, + { volume: 999 }, + ]) { + const proj = withTimings({ bgm: { file: 'assets/bgm.mp3', ...bad } }); + fs.mkdirSync(path.join(proj, 'assets'), { recursive: true }); + fs.writeFileSync(path.join(proj, 'assets', 'bgm.mp3'), 'x'); + const r = runSkill('build-video.mjs', [proj]); + assert.notEqual(r.status, 0, `bgm ${JSON.stringify(bad)} 必须被拒绝`); + assert.ok(/bgm\./.test(r.stdout + r.stderr), `要点名是 bgm 的哪个字段非法, 实际: ${(r.stdout + r.stderr).slice(-200)}`); + } + }); + + test('build-video: fps 非数值 → 退出 1(拼进 -r/-framerate; timings.fps 优先于 script.fps)', () => { + const r = runSkill('build-video.mjs', [withTimings({}, { fps: '30 -vf scale=1:1' })]); + assert.notEqual(r.status, 0); + assert.ok(/fps/.test(r.stdout + r.stderr), `要点名 fps, 实际: ${(r.stdout + r.stderr).slice(-200)}`); + }); + + test('build-video: width 非法字符串 → 退出 1(ffmpeg filter 注入面)', () => { + const r = runSkill('build-video.mjs', [withTimings({ width: '1920,drawtext=text=pwned' })]); + assert.notEqual(r.status, 0); + assert.ok(/width/.test(r.stdout + r.stderr), `要点名 width, 实际: ${(r.stdout + r.stderr).slice(-200)}`); + }); + + test('preview-page: script.width 非法字符串 → 退出 1(该值插进放映页 CSS/JS, 此前直接杀死整页脚本)', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), '

x

'); + const scriptPath = path.join(proj, 'script.json'); + for (const bad of ['1920px', '1920; } body{background:url(x)} /*']) { + const script = JSON.parse(fs.readFileSync(scriptPath, 'utf8')); + script.width = bad; + fs.writeFileSync(scriptPath, JSON.stringify(script)); + const r = runSkill('preview-page.mjs', [proj]); + assert.notEqual(r.status, 0, `width=${JSON.stringify(bad)} 必须被拒绝(与 build-video 的 clampDim 同一道门)`); + } + }); + + test('preview-page: 超大 tokens.css → 退出 1(与 check-slides/init-project 同一道门, 此前实测跑 129s)', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), '

x

'); + // 反复的开标记但无收标记 —— 受管块扫描的二次方放大输入 + fs.writeFileSync(path.join(proj, 'slides', 'tokens.css'), + ':root{--accent:#111}\n' + '/* >>> html2video:nofx rev=deadbeef >>> */\n'.repeat(60000) + 'x'.repeat(3_000_000)); + const t0 = Date.now(); + const r = runSkill('preview-page.mjs', [proj], { timeout: 90000 }); + const ms = Date.now() - t0; + assert.notEqual(r.status, 0, '超大 tokens.css 必须被上限拒绝'); + assert.ok(/tokens\.css/.test(r.stdout + r.stderr), '要点名 tokens.css'); + assert.ok(ms < 30000, `应在秒级拒绝, 实际 ${ms}ms`); + }); + + // ── 2026-09-18 实测踩坑(PITFALLS.md)固化成闸门 ── + test('check-slides 5e: 绝对定位落进字幕带(bottom < 168px@1080) → 点名警告', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), + ` +

x

图:某来源
`); + const r = runSkill('check-slides.mjs', [proj]); + assert.equal(r.status, 0, '提示级不得阻塞流水线'); + assert.match(r.stdout, /字幕带/, '要点名落在字幕带'); + assert.match(r.stdout, /bottom:96px/, '报出具体值'); + // 安全区之上的图注不该报 + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), + ` +

x

图:某来源
`); + const r2 = runSkill('check-slides.mjs', [proj]); + assert.ok(!/字幕带/.test(r2.stdout), `bottom:196px 在安全区之上, 不该报: ${r2.stdout.slice(-200)}`); + }); + + test('check-slides 5d: .fx-stagger 与 data-stage 同张 → 提示确认(PITFALLS #2)', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), + ` +
  • a
  • b
+

自己管入场

`); + const r = runSkill('check-slides.mjs', [proj]); + assert.equal(r.status, 0, '提示级不得阻塞流水线'); + assert.match(r.stdout, /fx-stagger/, '要点名 stagger 与 data-stage 共存'); + assert.match(r.stdout, /动画窗/, '要给出可自查的信号(动画窗变短)'); + }); + + test('check-slides 5: 带 data-stage 却没有 fx 类 → 警告(stagger 不再兜底)', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), + `
  • 以为 stagger 会管
`); + const r = runSkill('check-slides.mjs', [proj]); + assert.match(r.stdout, /没有 fx-\* 类/, '要警告元素会停在 opacity:0'); + assert.ok(!/可豁免/.test(r.stdout), '旧措辞"放进 .fx-stagger 容器可豁免"已不成立 —— stagger 规则带 :not([data-stage])'); + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/subtitles-invalidate.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/subtitles-invalidate.test.mjs new file mode 100644 index 00000000..2c515d00 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/subtitles-invalidate.test.mjs @@ -0,0 +1,159 @@ +// 字幕静帧必须随字幕一起作废(复查 B2): 成片是"帧序列 + 逐句字幕静帧"拼出来的, 静帧留在磁盘上; +// 删掉一句 clauses 或改用 --no-subs 重建时, 如果旧静帧没被清掉、旧清单没被核对, 成片会把**已经不存在 +// 的那句字幕**拼回来 —— 画面里出现一句没人说过的字幕, 而全链路零报错。 +// 这里用像素级判据: 末段实帧与"当前该显示的静帧"几乎一致, 与"旧的第 2 句静帧"明显不同。 +import { test } 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 } from '../scripts/tools.mjs'; +import { runSkill, tmpdir, FRESH_TOKENS } from './helpers.mjs'; + +const FFMPEG = findTool('ffmpeg'); +const FFPROBE = findTool('ffprobe'); + +test('字幕静帧随字幕作废: 删掉一句 / --no-subs 重建后, 成片不得再出现旧字幕(像素级)', async t => { + if (!FFMPEG || !FFPROBE) return t.skip('无 ffmpeg/ffprobe(主 CI 环境; 由 scoped smoke workflow 覆盖)'); + const playwright = await loadPackage('playwright'); + if (!playwright) return t.skip('无 playwright'); + try { const b = await playwright.chromium.launch({ headless: true }); await b.close(); } catch { + return t.skip('chromium 未安装(npx playwright install chromium)'); + } + + const proj = tmpdir(); + assert.equal(runSkill('init-project.mjs', [proj, '--topic', 'SubInvalidate']).status, 0); + const g = spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', '-t', '3', + '-c:a', 'libmp3lame', '-b:a', '64k', '-y', path.join(proj, 'audio', '01.mp3')], { windowsHide: true }); + assert.equal(g.status, 0, g.stderr ?? ''); + fs.writeFileSync(path.join(proj, 'slides', '01-title.html'), + ` +

字幕作废检查

`); + + const setClauses = list => { + const p = path.join(proj, 'script.json'); + const s = JSON.parse(fs.readFileSync(p, 'utf8')); + s.slides = s.slides.filter(x => x.id === '01'); + s.slides[0].clauses = list; + fs.writeFileSync(p, JSON.stringify(s, null, 2)); + }; + const man = () => JSON.parse(fs.readFileSync(path.join(proj, 'build', 'substills', '01.json'), 'utf8')); + const subFile = k => path.join(proj, 'build', 'substills', '01', `s${k}.png`); + // 字幕条: 模板里 .kit-sub 是 bottom: 7.78% × 1080 ≈ 84px、40px 字、居中 → 取正下方居中一块, + // 比整条字幕带敏感得多(整带 1920×260 会把字幕只占的一小块平均掉) + const box = 'crop=600:140:660:890'; + const YAVG = (a, b) => { + const o = spawnSync(FFMPEG, ['-hide_banner', '-loglevel', 'error', '-i', a, '-i', b, + '-lavfi', '[0:v][1:v]blend=all_mode=difference,signalstats,metadata=print:file=-', '-frames:v', '1', '-f', 'null', '-'], + { encoding: 'utf8', windowsHide: true }); + const m = /YAVG=([0-9.]+)/.exec((o.stdout || '') + (o.stderr || '')); + return m ? parseFloat(m[1]) : null; + }; + const crop = (src, out) => { spawnSync(FFMPEG, ['-y', '-hide_banner', '-loglevel', 'error', '-i', src, '-vf', box, out], { windowsHide: true }); return out; }; + const grab = (t0, out) => { spawnSync(FFMPEG, ['-y', '-hide_banner', '-loglevel', 'error', '-ss', String(t0), '-i', path.join(proj, 'out', 'final.mp4'), '-frames:v', '1', '-vf', box, out], { windowsHide: true }); return out; }; + const dur = () => parseFloat((spawnSync(FFPROBE, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path.join(proj, 'out', 'final.mp4')], { encoding: 'utf8' }).stdout || '').trim()); + const work = path.join(proj, 'build', 'subcheck'); + fs.mkdirSync(work, { recursive: true }); + + // ① 两句: 出片并留下证据(第 2 句的静帧 = 旧字幕长什么样) + setClauses([{ stage: 1, text: '第一句。' }, { stage: 2, text: '第二句展开内容。' }]); + assert.equal(runSkill('plan-timings.mjs', [proj]).status, 0); + assert.equal(runSkill('capture.mjs', [proj, '--mode', 'still']).status, 0); + assert.ok(fs.existsSync(subFile(1)), '前置: 第 2 句的静帧应已生成'); + assert.equal(man().stills.length, 2, '前置: 清单里应有 2 句'); + const oldS1 = path.join(work, 'old-s1.png'); + fs.copyFileSync(subFile(1), oldS1); + + // ② 删掉第 2 句: 重建后旧静帧必须消失, 且成片里不能再出现那句字幕 + setClauses([{ stage: 1, text: '第一句。' }]); + assert.equal(runSkill('plan-timings.mjs', [proj]).status, 0); + assert.equal(runSkill('capture.mjs', [proj, '--mode', 'still']).status, 0); + assert.equal(fs.existsSync(subFile(1)), false, '旧的第 2 句静帧必须被作废(否则会被拼回成片)'); + assert.equal(man().stills.length, 1, '清单必须只剩当前这一句'); + assert.equal(runSkill('build-video.mjs', [proj]).status, 0); + const lateFrame = grab(Math.max(0.3, dur() * 0.85), path.join(work, 'late.png')); + const dOldS1 = YAVG(lateFrame, crop(oldS1, path.join(work, 'old-s1-band.png'))); + const dCurS0 = YAVG(lateFrame, crop(subFile(0), path.join(work, 'cur-s0-band.png'))); + assert.ok(dOldS1 != null && dCurS0 != null, '应能算出字幕带差异'); + console.log(` 实测(字幕条差异, 越小越像): 当前字幕 ${dCurS0} · 被删掉的旧字幕 ${dOldS1}`); + assert.ok(dCurS0 < 2.5, `末段画面应贴合当前唯一那句字幕, 实际差异 ${dCurS0}`); + assert.ok(dOldS1 > 4.0, `末段画面不得还是被删掉的那句(与旧静帧差异只有 ${dOldS1})`); + + // ③ --no-subs 重建: 静帧与清单都要清掉, 成片不再有字幕 + const withSubs = path.join(work, 'with-subs-s0.png'); // 先留一份"有字幕"的参照(下面会把源文件清掉) + fs.copyFileSync(subFile(0), withSubs); + assert.equal(runSkill('capture.mjs', [proj, '--mode', 'still', '--no-subs']).status, 0); + assert.equal(fs.existsSync(path.join(proj, 'build', 'substills', '01.json')), false, '--no-subs 时清单必须清掉'); + assert.equal(fs.existsSync(path.join(proj, 'build', 'substills', '01')), false, '--no-subs 时静帧目录必须清掉'); + assert.equal(runSkill('build-video.mjs', [proj]).status, 0); + const lateNoSubs = grab(Math.max(0.3, dur() * 0.85), path.join(work, 'late-nosubs.png')); + const dWithSubs = YAVG(lateNoSubs, crop(withSubs, path.join(work, 'gone-s0-band.png'))); + assert.ok(dWithSubs != null, '应能算出差异'); + console.log(` 实测(--no-subs 后与"有字幕"参照的差异): ${dWithSubs}`); + assert.ok(dWithSubs > 4.0, `--no-subs 之后画面不该还有字幕(与有字幕静帧的差异只有 ${dWithSubs})`); +}); + +// 第三轮复查(1.7.1): 清单的 framesCover 必须与"当前帧序列的实际时长"对齐 —— B2 点名的第三项。 +// 时长与句序都对、但动画窗重截过(帧数变了)的清单, 会把字幕静帧从错误的时刻拼进成片。 +// 判据用整帧差异: 静帧是纯红、帧序列是纯灰, 拼没拼进画面一眼可辨(不需要字幕带裁剪)。 +test('清单帧覆盖与当前帧序列不一致 → 忽略清单(像素级: 陈旧静帧不得再进画面)', async t => { + if (!FFMPEG || !FFPROBE) return t.skip('无 ffmpeg/ffprobe(主 CI 环境; 由 scoped smoke workflow 覆盖)'); + + const proj = tmpdir(); + fs.mkdirSync(path.join(proj, 'slides'), { recursive: true }); + fs.writeFileSync(path.join(proj, 'slides', 'tokens.css'), FRESH_TOKENS); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), ''); + fs.writeFileSync(path.join(proj, 'script.json'), JSON.stringify({ + topic: 't', fps: 30, width: 1920, height: 1080, + slides: [{ id: '01', html: '01.html', audio: '01.mp3', clauses: [{ stage: 1, text: '唯一一句。' }] }], + }, null, 2)); + fs.mkdirSync(path.join(proj, 'audio'), { recursive: true }); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', '-t', '3', + '-c:a', 'libmp3lame', '-b:a', '64k', '-y', path.join(proj, 'audio', '01.mp3')], { windowsHide: true }).status, 0); + fs.mkdirSync(path.join(proj, 'build'), { recursive: true }); + fs.writeFileSync(path.join(proj, 'build', 'timings.json'), JSON.stringify({ + fps: 30, total: 3.0, tts: 3.0, slides: [{ id: '01', duration: 3.0, tts: 3.0, clauses: [{ stage: 1, text: '唯一一句。' }] }], + }, null, 2)); + // 手工帧序列: 30 帧(= 1.0s)纯灰 —— 清单却声称帧覆盖 2.0s(旧动画窗), 差 1s 必须被识破 + // (image2 输出序列默认从 f00001 起编号, 而 build-video 认 f00000 → 必须 -start_number 0) + const fdir = path.join(proj, 'build', 'frames', '01'); + fs.mkdirSync(fdir, { recursive: true }); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=0x808080:s=1920x1080:r=30', '-t', '1', + '-start_number', '0', '-y', path.join(fdir, 'f%05d.png')], { windowsHide: true }).status, 0, '应能造出 30 帧灰底序列'); + assert.ok(fs.existsSync(path.join(fdir, 'f00000.png')), '帧序列应从 f00000 开始(build-video 的输入模式)'); + const stills = path.join(proj, 'build', 'substills'); + fs.mkdirSync(path.join(stills, '01'), { recursive: true }); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=red:s=1920x1080', '-frames:v', '1', + '-y', path.join(stills, '01', 's0.png')], { windowsHide: true }).status, 0, '应能造出红色字幕静帧'); + fs.mkdirSync(path.join(proj, 'preview'), { recursive: true }); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=0x808080:s=1920x1080', '-frames:v', '1', + '-y', path.join(proj, 'preview', 'cover.png')], { windowsHide: true }).status, 0, '应能造出封面占位'); + + const diff = (a, b) => { + const o = spawnSync(FFMPEG, ['-hide_banner', '-loglevel', 'error', '-i', a, '-i', b, + '-lavfi', '[0:v][1:v]blend=all_mode=difference,signalstats,metadata=print:file=-', '-frames:v', '1', '-f', 'null', '-'], + { encoding: 'utf8', windowsHide: true }); + const m = /YAVG=([0-9.]+)/.exec((o.stdout || '') + (o.stderr || '')); + return m ? parseFloat(m[1]) : null; + }; + const grab = (t0, out) => { spawnSync(FFMPEG, ['-y', '-hide_banner', '-loglevel', 'error', '-ss', String(t0), + '-i', path.join(proj, 'out', 'final.mp4'), '-frames:v', '1', out], { windowsHide: true }); return out; }; + const manPath = path.join(stills, '01.json'); + + // ① 陈旧清单: 时长对、句数对, 但 framesCover=2 ≠ 当前 1.0 → 必须点名并忽略 + fs.writeFileSync(manPath, JSON.stringify({ fps: 30, duration: 3, framesCover: 2, stills: [{ start: 2, end: 3, k: 0 }] })); + let r = runSkill('build-video.mjs', [proj]); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout + r.stderr, /帧覆盖 2s ≠ 当前 1s.*忽略该清单/, '要点名帧覆盖不符并忽略: ' + r.stdout + r.stderr); + const dStale = diff(grab(2.55, path.join(proj, 'build', 'cov-stale.png')), path.join(stills, '01', 's0.png')); + console.log(` 实测(陈旧清单被忽略后, 末段与红色静帧的差异): ${dStale}`); + assert.ok(dStale != null && dStale > 4.0, `陈旧静帧不得再进画面(与红色静帧差异仅 ${dStale})`); + + // ② 对齐清单: framesCover=1 与当前帧序列一致 → 同一张静帧正常拼入(末段 = 红色) + fs.writeFileSync(manPath, JSON.stringify({ fps: 30, duration: 3, framesCover: 1, stills: [{ start: 1, end: 3, k: 0 }] })); + r = runSkill('build-video.mjs', [proj]); + assert.equal(r.status, 0, r.stdout + r.stderr); + const dOk = diff(grab(2.55, path.join(proj, 'build', 'cov-ok.png')), path.join(stills, '01', 's0.png')); + console.log(` 实测(清单对齐后, 末段与红色静帧的差异): ${dOk}`); + assert.ok(dOk != null && dOk < 2.5, `对齐清单的静帧应当拼入画面(与红色静帧差异 ${dOk})`); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/table-kit.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/table-kit.test.mjs new file mode 100644 index 00000000..d84a4947 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/table-kit.test.mjs @@ -0,0 +1,68 @@ +// 表格工具箱: 原语必须真在生成的 tokens.css 里, 且可读性硬指标不能被改回去。 +// 覆盖过的真问题: 表格文字用 caption(24px) 在手机上读不清; 行高压到 68px; 涨跌色自造色。 +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 { TABLE_CSS, hasTableKit } from '../scripts/table-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('四种形态的原语都在(.tbl 数据表 / .kv 规格表 / .matrix 对比矩阵 / .rank 排名表)', () => { + for (const sel of ['.tbl {', '.kv {', '.matrix {', '.rank {']) { + assert.ok(css.includes(sel), `缺 ${sel}`); + } + assert.match(css, /\.tbl tbody tr\s*\{[^}]*height:\s*72px/, '数据表行高必须 ≥72px'); + assert.match(css, /\.rank td\s*\{[^}]*height:\s*72px/, '排名表行高必须 ≥72px'); + // 高亮列必须连表头一起覆盖: 只写 td.hi 时 静默不生效(2026-09-18 排查抓到) + assert.match(css, /\.matrix th\.hi/, '矩阵高亮列要覆盖表头'); + }); + + test('主数据用正文号(视频在手机上要读得清), 表头才降到 caption', () => { + for (const sel of ['.tbl {', '.kv {', '.matrix {', '.rank {']) { + const i = css.indexOf(sel); + assert.match(css.slice(i, css.indexOf('}', i)), /font-size:\s*var\(--fs-body\)/, `${sel} 主数据必须是 --fs-body`); + } + assert.match(css, /\.tbl th\s*\{[^}]*font-size:\s*var\(--fs-caption\)/, '表头用 caption'); + }); + + test('涨跌走令牌, 高亮/合计各有语义类', () => { + assert.match(css, /\.tbl \.up\s*\{[^}]*var\(--up\)/); + assert.match(css, /\.tbl \.down\s*\{[^}]*var\(--down\)/); + assert.match(css, /\.tbl tr\.key td\s*\{/, '缺高亮行 .key'); + assert.match(css, /\.tbl tr\.sum td\s*\{/, '缺合计行 .sum'); + assert.match(css, /\.tbl \.num\s*\{[^}]*text-align:\s*right[^}]*tabular-nums/, '数值列必须右对齐 + 等宽数字'); + }); + + test('只画横线(不画竖线/斑马纹): 表格原语里不得出现 border-left/right 或 nth-child 斑马纹', () => { + const block = css.slice(css.indexOf('── 表格原语'), css.indexOf('依次入场容器')); + assert.ok(!/border-(left|right)\s*:/.test(block), '表格原语不该画竖线'); + assert.ok(!/nth-child\((odd|even)\)/.test(block), '表格原语不该有斑马纹'); + }); +}); + +describe('表格工具箱 · 老项目升级', () => { + test('--upgrade-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(hasTableKit(':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, /\.tbl tbody tr\s*\{/); + assert.match(after, /\.matrix th\s*\{/); + 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('TABLE_CSS 自身通过 hasTableKit 判定(防止判定与内容脱节)', () => { + assert.equal(hasTableKit(TABLE_CSS), true); + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/tokens-fx.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/tokens-fx.test.mjs new file mode 100644 index 00000000..8bc68be5 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/tokens-fx.test.mjs @@ -0,0 +1,114 @@ +// 模板自检: 生成出来的 tokens.css 里, 每个"入场类" fx 的关键帧都必须声明 opacity。 +// 背景: [data-stage] 的基础态是 opacity:0, 入场靠 animation 的 both 填充把 opacity 抬回 1; +// 关键帧若不碰 opacity, 该元素入场后永远不可见(2026-09-18 实测踩过 fx-grow-x/y 与 fx-spotlight)。 +// 这条规则以前只写在 check-slides 的逐张检查里 —— 只有用户刚好用到那个类才报; 这里对模板本身断言。 +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'; + +function parseFx(css) { + const classes = new Map(); // .fx-name → { anim, infinite } + for (const m of css.matchAll(/\.(fx-[a-z-]+)\s*\{([^}]*)\}/g)) { + const decl = m[2]; + const a = /animation\s*:\s*([^;}]+)/.exec(decl); + if (!a) continue; // 容器类(如 .fx-stagger)不算 + classes.set(m[1], { anim: a[1].trim().split(/\s+/)[0], infinite: /\binfinite\b/.test(a[1]) }); + } + // 关键帧体要按括号配对取(单行写法 @keyframes x { from {…} to {…} } 不能靠"行尾 }"切) + const kf = new Map(); // 关键帧名 → 是否声明了 opacity + const re = /@keyframes\s+([\w-]+)\s*\{/g; + let m; + while ((m = re.exec(css))) { + let depth = 0, end = -1; + for (let j = m.index + m[0].length - 1; j < css.length; j++) { + if (css[j] === '{') depth++; + else if (css[j] === '}' && --depth === 0) { end = j; break; } + } + if (end < 0) continue; + kf.set(m[1], /(^|[;{\s])opacity\s*:/.test(css.slice(m.index + m[0].length, end))); + re.lastIndex = end; + } + return { classes, kf }; +} + +describe('模板 tokens.css: 入场类必须能从 opacity:0 抬回来', () => { + 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'); + const { classes, kf } = parseFx(css); + + test('解析到了 fx 类与关键帧(防止正则失效后测试假绿)', () => { + assert.ok(classes.size >= 10, `只解析到 ${classes.size} 个 fx 类`); + assert.ok(kf.size >= 8, `只解析到 ${kf.size} 组关键帧`); + }); + + test('非无限(入场)动画的关键帧都声明了 opacity', () => { + const bad = []; + for (const [cls, { anim, infinite }] of classes) { + if (infinite) continue; // 氛围类不承载入场, 不受基础态影响 + if (!kf.has(anim)) { bad.push(`${cls} → 找不到关键帧 ${anim}`); continue; } + if (!kf.get(anim)) bad.push(`${cls} → @keyframes ${anim} 没有 opacity(该元素会永远隐形)`); + } + assert.deepEqual(bad, []); + }); + + test('no-fx 规则含 opacity 重置(只关动画不管基础态 = 整片空白)', () => { + assert.match(css, /\.no-fx\s+\[data-stage\][^{]*\{[^}]*opacity\s*:\s*1\s*!important/); + }); + + test('.fx-stagger 入场规则全部排除 [data-stage](2026-09-18 实测: nth-child 延迟特异度更高, 会覆盖子元素自己的时刻)', () => { + // 根因: .fx-stagger > *:nth-child(n) 的特异度(0,2,0) > .fx-up 等(0,1,0), 且写在文件末尾 → + // 容器内带 data-stage 的子元素会被 stagger 的 --stagger-base 时刻接管(实测提前 3 秒冒头)。 + // 注意排除 .no-fx 前缀的重置规则 —— 它们本就该作用于所有子元素(含带 data-stage 的, 那是"关动画") + // 看整行选择器: no-fx 重置写成多选择器(.no-fx [data-stage], .no-fx .fx-stagger > * {), + // 从 .fx-stagger 处起匹配拿不到前缀, 所以按行判定 + const staggerRules = [...css.matchAll(/\.fx-stagger\s*>\s*([^{]+)\{/g)] + .filter(m => { + const lineStart = css.lastIndexOf('\n', m.index) + 1; + return !css.slice(lineStart, m.index + m[0].length).includes('.no-fx'); + }) + .map(m => m[1].trim()); + assert.ok(staggerRules.length >= 9, `应解析到 9 条入场 stagger 规则, 实际 ${staggerRules.length}: ${staggerRules.join(' | ')}`); + const bad = staggerRules.filter(sel => !sel.includes(':not([data-stage])')); + assert.deepEqual(bad, [], '每条入场 stagger 选择器都必须带 :not([data-stage])'); + }); +}); + +describe('init-project --upgrade-css: 老项目补 no-fx 规则', () => { + const stale = () => { + const proj = tmpdir(); + fs.mkdirSync(path.join(proj, 'slides'), { recursive: true }); + fs.writeFileSync(path.join(proj, 'slides', 'tokens.css'), ':root { --accent: #111; }\n'); + fs.writeFileSync(path.join(proj, 'script.json'), '{"slides":[]}'); + return proj; + }; + + test('缺规则 → 追加, 且不碰其他文件', () => { + const proj = stale(); + const r = runSkill('init-project.mjs', [proj, '--upgrade-css']); + assert.equal(r.status, 0, r.stderr); + const css = fs.readFileSync(path.join(proj, 'slides', 'tokens.css'), 'utf8'); + assert.match(css, /\.no-fx\s+\[data-stage\][^{]*\{[^}]*opacity\s*:\s*1\s*!important/); + assert.ok(css.startsWith(':root { --accent: #111; }'), '原有内容必须在前'); + assert.equal(fs.readFileSync(path.join(proj, 'script.json'), 'utf8'), '{"slides":[]}', 'script.json 不得被动'); + assert.ok(!fs.existsSync(path.join(proj, 'slides', '_template.html')), '不得生成新文件'); + }); + + test('幂等: 再跑一次不重复追加', () => { + const proj = stale(); + runSkill('init-project.mjs', [proj, '--upgrade-css']); + const once = fs.readFileSync(path.join(proj, 'slides', 'tokens.css'), 'utf8'); + const r = runSkill('init-project.mjs', [proj, '--upgrade-css']); + assert.equal(r.status, 0); + assert.equal(fs.readFileSync(path.join(proj, 'slides', 'tokens.css'), 'utf8'), once); + assert.ok(r.stdout.includes('无需升级')); + }); + + test('没有 tokens.css → 退出 1', () => { + const r = runSkill('init-project.mjs', [tmpdir(), '--upgrade-css']); + assert.equal(r.status, 1); + }); +});