diff --git a/.github/workflows/html2video-for-mcode-smoke.yml b/.github/workflows/html2video-for-mcode-smoke.yml new file mode 100644 index 0000000..ce25989 --- /dev/null +++ b/.github/workflows/html2video-for-mcode-smoke.yml @@ -0,0 +1,57 @@ +# 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/ 本文件除外)或本文件自身变更。 +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: + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + 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 + + - name: Security, policy & template tests (containment / overwrite refusal / endpoint allowlist / SSRF / play page / fx keyframes) + run: | + node --test \ + plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/safe-paths.test.mjs \ + plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/no-clobber.test.mjs \ + plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/endpoint-allowlist.test.mjs \ + plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/fetch-policy.test.mjs \ + plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/preview-page.test.mjs \ + plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/tokens-fx.test.mjs \ + plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/chart-kit.test.mjs \ + plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/table-kit.test.mjs \ + plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/css-kit.test.mjs \ + --test-timeout=60000 + + - name: Render smoke (init → timings → static gate → capture → build) + run: | + node --test \ + plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/render-smoke.test.mjs \ + --test-timeout=300000 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 0000000..ab3e0b1 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "html2video-for-mcode", + "version": "1.6.0", + "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 0000000..05ab398 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md @@ -0,0 +1,240 @@ +# Changelog + +## 1.6.0 — 2026-09-18 + +**Cover / slide transitions / speech-rate alignment (three field reports from real deliveries)** + +- **Cover and a non-black first frame.** Every segment carried `fade=t=in`, so frame 0 of the final video was pure black — the thumbnail people saw when the file was forwarded. Fix: `capture.mjs` now also writes `preview/cover.png` for the first slide (base animations finished, subtitles hidden — the fully revealed design), the first segment dissolves from that cover over 0.25 s (**frame 0 *is* the cover**, no added duration), `build-video.mjs` embeds it as `attached_pic` (main video stream still `-c:v copy`; note `-shortest` must be dropped when a cover is embedded) and exports `out/cover.png` at canvas size for platform uploads. +- **No more black frames between slides.** Each segment faded to black (0.3 s) and the next faded in from black (0.25 s) ≈ 0.55 s of black at every page change. Now: hard **cut** by default (only the last segment keeps its outro fade; the first gets the cover dissolve above), plus an optional **cross-dissolve** (`script.json` → `"transition": {"type":"xfade","duration":0.4}`, or `--transition xfade`). The dissolve never passes through black: each segment holds `duration` extra tail frames, which the xfade consumes, so the total duration still equals `timings.total`. The play page compares the two live (bottom-bar toggle / `T`). +- **Speech rate: default 1.1, and the audition now asks.** Default was `speed 1.0` (≈4.8 Chinese chars/sec) — field feedback: "the default pace is a bit slow". Default is now **1.1** (≈5.3 chars/sec; first/last slides 1.05) across SKILL.md, `tts-and-timing.md`, `authoring.md`, the project template and evals. More importantly the **audition step asks two questions at once**: the three candidate voices *and* the same probe line in the chosen voice at three speeds (1.05 / 1.15 / 1.25); the answer is written to `script.json` (`speed`), and `plan-timings.mjs` now **reads that field** (it used to be declarative only) and warns when the measured rate deviates >20% from `baseline × speed`, or when speed is outside 0.8–1.4. +- **Gate 4 now simulates the preview and asks two more questions.** After the HTML is done: ① slide transition (cut / dissolve / custom duration — comparable live in the play page) ② entrance layering (title + sub-info all at once, or staged as `data-stage` does today). `check-slides.mjs` prints the current transition value and each slide's level count so the agent asks with real numbers instead of impressions. To collapse staging, set the layers to the same stage and re-run `capture --mode motion` for that slide. +- **Self-check gained three visual gates** (all fail the run non-zero): cover embedded, first-frame luma above black level, and no black frame around any cut point. +- Tests **171 → 174** (new `cover-transition.test.mjs`: cover present + `attached_pic` stream + non-black first frame + no black at cuts in both cut and xfade modes + duration unchanged + a single-level slide still renders), all green with 0 skips. + +**Documentation is now English.** SKILL.md, all six references, CHANGELOG and THIRD-PARTY-NOTICES are written in English so the skill is usable outside Chinese-language teams; `README.zh-CN.md` stays Chinese. Source examples were internationalised at the same time: the announcement/media screenshot path and the source tiering now give **both** international (Reuters, AP, Bloomberg, FT, WSJ, BBC, CNBC) and Chinese (Xinhua, CCTV, The Paper, Caixin, Tencent News, Sina, NetEase) examples, ranked by primary-ness and editorial accountability rather than by country; the regulated-topic guide names jurisdictions (SEC/FTC/FDA, FCA/ESMA/EMA, CSRC/NMPA/SAMR) and states the up/down colour convention per market instead of assuming one. + +## 1.5.0 — 2026-09-18 + +**Round-2 review fixes: output containment, the network boundary at the real request point, the subtitle timeline (3 P1 + 4 P2)** + +- **[P1] Subtitle timeline (a finished-video defect reproduced by measurement)**: subtitles are "full-duration percentage animations", while the frame sequence only covers the animation window and the rest was frozen on the last frame by `tpad` — so **every subtitle change after the animation window never made it into the picture** (measured: SRT clause 4 sits at 5.32s, yet the frame at 6.0s still shows clause 2); on the `no-fx`/static path it is worse — `finish()` jumps the subtitle animation to `opacity:0`, so the whole video carries not one subtitle frame. capture now emits, per clause, a "final-state base + that clause's subtitle" still (`build/substills//`, with a manifest `build/substills/.json` recording `framesCover` and every clause's window), and build-video uses `concat` to stitch "frame-sequence segments + the subtitle segments" into the whole slide (the segment lengths must sum to the slide duration, backed by the existing self-check). The regression test uses a **pixel-level criterion**: inside clause 2's window the finished video must match clause 2's subtitle still (measured 0.93) and stay far from clause 1's (7.0); under the old behaviour those two numbers invert exactly (0.99 / 7.1) — the test goes red. +- **[P1] Output containment (the second class of escape)**: only input paths used to be contained; derived output (`preview/`, `build/frames//`) is "a fixed position inside the project + a validated id", but **a directory segment inside the project can itself be a symlink pointing outside it**, and `rmSync(recursive)` deletes straight through it while the command reports success. New `tools.assertContained/safeOut` (the realpath of every existing ancestor segment must still sit inside the root's realpath), wired into every write/delete site in capture (frame-directory recursive delete, preview writes), build-video (out/build/asr writes and cleanup), preview-page, plan-timings and grab-frames. The canary regression test (on Windows the link is made with a junction, no administrator needed) asserts "the command must refuse + the canary is intact". +- **[P1] The network boundary now sits at the real request point**: previously only "the one URL supplied at the start" was validated. Now `url-policy.assertResolvedHost` resolves DNS and then validates the IP for **every real request** (page navigation, the 30x redirects Chromium follows on its own, browser subresources, per-hop image downloads); on the browser side it hooks `context.route` with a per-hostname resolution cache. The end-to-end test asserts "a refused target must receive 0 requests", and an injectable fake resolver covers rebinding scenarios (no real DNS needed). +- **[P2] ASR request errors count as failures**: authentication / rate-limit / network failures used to land in `results` without entering the tally, and `1 通过 / 0 不通过 / 退出 0` slipped the gate through. They are now counted separately, excluded from the pass count, and the whole run exits non-zero naming the affected segments. +- **[P2] The play page's attribute parsing accepts three quoting styles**: `` (single quotes) and unquoted attributes were not recognised, so a second, duplicate set of attributes was injected — and the browser keeps **the group that appears first** → the measured delay, the canvas size and `no-fx` all failed. The attribute is now rewritten in place and no duplicates are produced. +- **[P2] ffmpeg discovery gained cwd and the project directory**: the README states that installing `ffmpeg-static`/`ffprobe-static` inside the video project is supported, but `tools.mjs` never looked at `process.cwd()`, and `prep-image.mjs`/`asr.mjs` never passed the project directory → "it is installed in the project, yet it reports not found". Both are now passed, with a source-level assertion against regressions. +- **[P2] `--out-dir` compares canonical paths**: on macOS `/var/...` and `/private/var/...` are the same directory, and a plain string comparison misread a legitimate in-project path as an escape (hit for real on the official CI's macOS environment); the check now takes the realpath of the deepest existing ancestor before comparing. + +**Audit fixes folded into the same batch (after 1.4.0, never released on their own)** + +- `preview-page`: a 2MB cap on tokens.css (the same gate as check-slides/init-project — a 6MB input once ran 129s in measurement), numeric validation of `script.width/height` (a string written by mistake kills the play page's script outright), no-ops at the first/last-slide boundaries (pressing → on the last slide no longer resets it to level 1 and replays it), falling back to whole-slide paging when the filename contains `?`/`#` (the stepping query string breaks), and slides in subdirectories are skipped with an explicit explanation. +- `build-video`: `bgm.volume/fadeIn/fadeOut` and `fps` are now all numerically validated (the same injection surface as `width/height` — previously only W/H was covered); validation moved ahead of encoding (a bad config fails at once instead of after minutes of rendering). +- `asr`: `String.replace` now uses a function replacement string (a `$&`/`$1` inside the transcript would corrupt the checklist). +- **Root fix in the template**: all nine `.fx-stagger` rules gained `:not([data-stage])` — the `nth-child` delay's specificity (0,2,0) is higher than the fx class's (0,1,0), and it silently overrode the entrance moment of `data-stage` children inside the container (measured: they popped up 3 seconds early). +- `check-slides` gained two gates: **5d** `.fx-stagger` and `data-stage` coexisting on one slide (a prompt to confirm the layout), and **5e** an absolutely positioned `bottom` falling inside the subtitle band (the most expensive class in measurement: invisible in the still preview, and in the finished video the subtitles sit on the figure caption). +- New **`grab-frames.mjs`** (the 12th CLI): computes each slide's absolute start from `timings.json` and pulls frames from `out/final.mp4` to check them — the final reconciliation tool for the class of problem "a still cannot show it, only the finished video reveals it". +- Tests **151 → 171** (new `review-round2.test.mjs` + two end-to-end subtitle-timeline cases + a grab-frames smoke test), all green with 0 skips. + +## 1.4.0 — 2026-09-18 + +**Play page interaction model reworked: animations on = manual level-by-level entrance** + +- Measured feedback: with animations on, ←/→ could only turn whole slides, and second-level sub-headings or small charts had to be waited for as they appeared on schedule — that does not match the feel. Now **with animations on, → reveals the next level first** (the next level animates in on the spot, and only once it is out does the page turn; ← steps back level by level, and stepping back does not replay animation but goes straight to the final state); with animations off it still turns whole slides. +- **How level stepping works**: a script injected into the snapshot copy's `` runs before the CSS animations start and, given `?s=k`, resets each level's `--tN` delay — passed levels at `-60s` (final state), the current level at `0ms` (enters on the spot), future levels at `+60s` (stay hidden until the key is pressed); the parent page only swaps the iframe src, the same mechanism as the snapshot, with no cross-document access (under file:// the iframe is a separate origin). The top bar's page number shows the current level (e.g. `1 / 8 · 级 2/3` — page 1 of 8, level 2 of 3). +- **Replay button and the `R` key removed** (under level stepping, replay has no meaning); the touch bar's forward/back arrows enlarged (76px wide, 24px type — measured feedback: too small). +- **Frame switching no longer flashes white** (measured feedback: a big slide change flashed a white screen): now double-buffered — two stacked iframes, the new address is fed to the hidden one first, and only after `load` plus two more rAF frames (the new document's first frame has painted) do they swap for display, so the old picture is always on screen; level stepping benefits from the same. +- **Bilingual UI** (measured feedback: an English project got a whole set of Chinese buttons): buttons / hints / panel titles / placeholder copy adapt between Chinese and English from `script.json`'s `lang` (`en*` → Motion on / Narration on / Overview…), while terminal output stays Chinese (the agent's view). +- **Toggles moved to the bottom bar, labelled with their state** (measured feedback: "动效/口播" gives no clue whether it is on or off, and the top-bar toggle is redundant): the bottom-bar buttons read "动效开/动效关" "口播开/口播关" (motion on / motion off, narration on / narration off — the off state lit in a warning colour), shown on desktop too, and the top-bar animation toggle is removed. +- Tests +5 (the toggle labels and the top-bar removal / the double-buffer structure and frame-wait order / **behaviour-level** cases: at `?s=1` level 2 must still be waiting, at `?s=2&anim=1` it enters on the spot, stepping back goes straight to the final state / an English page's copy contains no Chinese; a failed assertion also closes the browser in `finally` — otherwise the event loop keeps the runner alive forever, hit in measurement), **151 tests** in total. The play-page instructions in SKILL.md / render.md were kept in sync; also verified live through Playwright (in steady state the double buffer shows exactly one frame, the X/P toggles flip, the en page's copy). + +## 1.3.7 — 2026-09-18 + +**Kickoff alignment checklist reordered + Gate 3 gained the "pick assets / add assets" questions** + +- The must-ask kickoff checklist was reordered into 11 rows per the user's measured feedback: **new "Audience"** (domain professionals / general tech-savvy public / management briefing / client demo / internal training — it decides term density and tone of voice, and previously hid inside "Topic and domain" without a row of its own); **new "Sensitive content and disclaimer"** (the disclaimer question expanded from "regulated domains only" to any hit of financial opinions / medical / legal / policy / negative-event reporting / personal information, default yes); "Asset boundaries" renamed to "**Images and asset boundaries**" (ask whether images are wanted at all first, and for negative-event subjects add the announcement/news-screenshot question); "Voice" annotated "a rough direction is enough"; row titles completed with the "theme color" / "aspect ratio" wording. +- **Gate 3 became three questions** (once asset collection is done and both the facts and candidate images are in hand): ① pick "which ones to use" slide by slide; ② **"is there anything to add"** — links / screenshots / logos / pictures / data files / a reference voiceover script can all be handed to the agent, and user-provided assets have the highest priority (images pass `prep-image --check` and are registered in MANIFEST as "source: user-provided", links go to disk per paths A/B/D); ③ confirm sources and licensing. Previously Gate 3 had only a blanket confirmation, leaving the user no way in to pick slide by slide or to add assets. + +## 1.3.6 — 2026-09-18 + +**Kickoff alignment's "asset boundaries" gained the negative-event question** + +- Hit in measurement: for a negative-news video the agent asked about topic / charts / audience but never "do you want news or media-platform screenshots as illustrations" — path D (1.3.4) had been written into the image SOP, but was never wired into the must-ask kickoff checklist, so without the question nothing was left but pure typography. The "asset boundaries" row now carries a note: **for negative-event subjects (collapse / penalty / lawsuit / controversy) one more question is mandatory — "do you want announcement or news-report screenshots as illustrations"** (default suggestion: yes, 1–2; source tiering and screenshot discipline in image-sources.md path D). + +## 1.3.5 — 2026-09-18 + +**Iron rule 4 tightened: kickoff alignment must be "ask, then wait for the answer" — never a packaged announcement of defaults** + +- Hit in measurement: inside mcode the agent turned the must-ask checklist into "one message firing out a default block and then pressing on" — the message read 『默认项(不特别说的话就按这个):中文普通话 · 中文字幕 · 1920×1080 · 温润男声 · 10–12 张…』 ("Defaults (unless you say otherwise, these apply): Mandarin Chinese · Chinese subtitles · 1920×1080 · warm male voice · 10–12 slides…") — the user never got the chance to take a position on each item, which amounts to no alignment at all. The root cause was the old wording "(items the user does not answer may take the default, but you **must ask first**)", which left room to read it as "inform first; no objection means consent". +- The rule now reads: the first response = **ask each question, one by one, in question form, then stop and wait for the answers**; each item may carry a recommended default, but it must be "ask, then wait"; only items the user explicitly answered "go with the defaults" or left unanswered take the default, and before moving to the next step you restate "which defaults were finally adopted". The verbatim counter-example from the field was written into SKILL.md as a prohibited case. + +## 1.3.4 — 2026-09-18 + +**Image SOP gained "path D · announcement and authoritative-coverage screenshots" (negative-event subjects)** + +- For collapse / regulatory penalty / lawsuit / scandal subjects the company's official site can never supply the assets (paths A/B are guaranteed to fail) — and the SOP did not cover this real path. New path D: assets come from **the primary carrier of the event itself**, tiered as "exchange filings / regulatory documents > company official statements > media coverage"; **top-tier authorities (Xinhua / CCTV / The Paper) and mainstream portals (Tencent News / Sina / NetEase / Phoenix and the like) are all qualified sources**, and the only things excluded are self-media, aggregator accounts and content farms. +- Screenshot discipline: the frame must keep the outlet name and date (a screenshot with the source cropped away is unusable), close the cookie banner first, capture the article body only; save to `assets/` + register in MANIFEST with "source URL / publication date / reasonable reporting-based citation"; bring it into the frame with `.img-frame.contain` + `.img-cap` naming the source; negative financial subjects are equally bound by compliance.md. A minimal Playwright screenshot command is included (wait for lazy loading, then capture the article/main body area — the built-in browser's screenshot works the same way). +- SKILL.md's Phase 3 priority chain and compliance self-check were kept in sync; the subject index gained "collapse / regulatory penalty / lawsuit / scandal → path D". + +## 1.3.3 — 2026-09-18 + +**Workflow: Gate 1's presentation format gained the "table and animation" column** + +- Measured feedback: the table the user reviewed at Gate 1 previously held only "layout / frame / narration", and slides using table primitives or carrying entrance animations were left unexplained — the user had to imagine them, saw them move for the first time only at Gate 4, and dissatisfaction meant rework at the most expensive step before rendering. Gate 1's presentation format is now "# / layout / frame / **table and animation** / verbatim voiceover script", and **the table-and-animation column is mandatory**: table slides must state their shape (how many rows and columns, which primitive, which row/column is highlighted); animation slides must name the effect and the order (staggered fade-in item by item / a rolling number / bars growing one after another). + +## 1.3.2 — 2026-09-18 + +**Workflow: kickoff alignment gained the mandatory "data and charts" row** + +- Iron rule 4's must-ask kickoff checklist had no entry point for charts: the chart toolkit (merged during 1.1–1.2) hid only in the Phase 1/4 documents, so when the subject contained data the agent did not necessarily think to ask. A new row "**Data and charts**" was added: mandatory when the subject contains key numbers / comparisons / proportions / trends — ① is there any data worth a slide of its own as a visualisation; ② do you want chart slides, and roughly how many (default suggestion: hard data + a suitable layout → 1–2 slides); ③ style preference (horizontal bars / columns / donut / line / progress bar, or let the agent pick per the data shape); ④ numbers on the chart are equally bound by Gate 0 (≥2 independent sources, with scope and point in time). **Even if the user does not raise charts, data-dense subjects must be offered this option proactively**. +- Phase 1's content-volume hard rules gained "**for data-dense subjects, proactively make charts**": when the voiceover contains ≥2 comparable numbers / proportions / trends, prefer the `data-viz` layout + chart toolkit over listing the numbers as bullets; bar heights / bar lengths are computed from the values (the nine disciplines). + +## 1.3.1 — 2026-09-18 + +**Docs: which environment owns each research tool, and how open the toolset is** + +- The two-toolchain comparison table's **research row** was missing mcode's built-in browser (the mcode cell held only `web_search`/`web_fetch`, while the image row did list the built-in browser — the same table, two different standards): the mcode cell now adds "for SPA/JS-rendered pages, open them in the **built-in browser** to read the body text", and the other cell adds "for SPA pages, render the body text with the project's Playwright **headless browser**". The image row's "same as above" became an explicit statement too (this environment uses `fetch-official-images`, which ships its own Playwright headless rendering). +- Phase 0's "how to get the body text" now marks each environment's owner: the **mcode built-in browser** (a host capability of the mcode environment) vs the **Playwright headless browser** (any environment); research.md's search-tool table and SPA row were kept in sync. +- Made explicit that **the tools are open, the discipline is not**: if other search skills/plugins are installed on this machine, or any headless browser, use whichever one can pull the body text (try it once to confirm result quality before depending on it); source tiering, two-source cross-checking and scope labelling lose not one rule. research.md's tool table gained the row "any environment | other search skills/plugins installed on this machine". + +## 1.3.0 — 2026-09-18 + +**Three-axis audit fixes (doc alignment / security / process): one high-severity SSRF + the vertical play page + a batch of process breaks** + +- **Security · the hexadecimal IPv4-mapped IPv6 form slipped past the egress block (high severity, penetrated in measurement)**: the WHATWG URL parser normalises `http://[::ffff:127.0.0.1]/`'s hostname to `::ffff:7f00:1` before it reaches the check, while the old mapped-address handling only stripped the prefix and the remaining hex groups matched no block rule — loopback / private networks / cloud metadata (`::ffff:a9fe:a9fe`) could all slip past `fetch-official-images`' SSRF line this way (the end-to-end measurement reached a local server). The address is now converted back to dotted IPv4 before the check; the public mapped form (`::ffff:808:808` = 8.8.8.8) is still allowed through. `fetch-policy` gained negative cases that go through the `new URL()` normalisation path. +- **The play page's iframe forces relative resolution**: when a slide filename contains `:` (e.g. `javascript:alert(1).html`, legal on Linux/macOS), `iframe.src` was treated as a URL scheme and executed same-origin in the play page; a `./` prefix is now always added. +- **Vertical (1080×1920) play page fixed**: the stage `#fit/#frame` dimensions, the scale divisor and the overview thumbnail ratio were hard-coded to 1920×1080, so the vertical Gate 4 picture was cut off by nearly half; they are now all parameterised from `script.json`'s canvas. authoring.md gained the vertical subtitle-safe-area numbers (≈150–215px from the bottom, ≈787px wide centred, starting from `padding-bottom: 240px`). +- **A still re-capture silently swallowed the animations**: after capture invalidates that slide's frame directory on the still path (to prevent stale-frame contamination), build-video's fallback to a static image used to ship with no warning at all; it now names the slide with a ⚠ and gives the command to re-run (`capture --mode motion --ids `). render.md's "three-piece visual verification" and SKILL.md's Phase 5 were updated to state "after re-checking in still mode you must re-run motion for the same ids". +- **The managed-block mechanism hardened**: ① content comparison is normalised to LF uniformly (a CRLF file checked out by git autocrlf used to false-positive all three blocks as "hand-edited"); ② the upgrade runs in two passes (replace everything in place by rev first, then re-assess the result and fill the gaps) — under a pathological layout, "nofx bare text nested inside an old chart block" no longer produces the self-inconsistency of "reported success last time, reported stale again this time"; ③ an existing `.bak` now warns explicitly before it is overwritten; ④ tokens.css / slide HTML over 2MB is refused a scan (crafted input makes managed-block location quadratic — 4MB measured at 39 seconds); ⑤ `--upgrade-css` ends its output by naming the re-run list for "generated preview/frames/out still show the old CSS". +- **Process gaps filled (SKILL.md)**: the workflow gained the step "to resume an old project, run `--check-css` first" (including the Phase-detection markers and the post-upgrade re-run matrix); Phase 4's check sequence gained the conditional check-theme item (when tokens.css was changed, contrast problems no longer leak through to the most expensive Gate 5); Phase 6's delivery list gained `out/subs.srt` and `build/timings.json`; init-project's closing note had its Gate number corrected (final-state preview = Gate 4); Gate 2's acceptance artefacts no longer hard-code "8 audio segments"; check-slides 5c's `--check-css` pointer is scoped to the toolkit scenario. +- **Doc alignment**: the plugin package's top-level THIRD-PARTY-NOTICES.md was resynced with the copy inside the skill (it had been left behind as an old version); the README's verification section now gives commands that hold in both repository contexts (monorepo / standalone skill repo); authoring.md's chart-discipline heading changed from "six" to "nine" and the order of items 8/9 was corrected; evals had 4 fixes (id24's animation wording updated to `fx-sweep`/`fx-grow-w`, id0's Gate number, renumbering after a gap, the missing `files` field filled in); SKILL.md's script table gained the flags that actually exist (`--topic`/`--ids`/`--pacing`/`--min`/`--json`/`--dsf`). +- Tests **132 → 146** (fetch-policy +6, css-kit +4, preview-page +3, render-smoke +1), all green locally. + +## 1.2.0 — 2026-09-18 + +**CSS toolkit managed blocks: the root fix for "you changed the CSS but the project does not pick it up"** + +- The three toolkits (`nofx-css.mjs` / `chart-css.mjs` / `table-css.mjs`) become **managed blocks carrying a content rev** in the project's `tokens.css` (`/* >>> html2video: rev= >>> */ … /* <<< html2video: <<< */`), with the rev hashing the block's CSS content — change the content and the rev changes. +- `init-project --upgrade-css` was rewritten from "presence probe + append" to **replacement in place by rev**: an old / hand-edited block → swapped for the new one in its original position; undelimited but current content → wrapped in place (position unchanged); missing → appended at the end of the file with a note that the old rules will be overridden by what follows. Rules outside the managed blocks (including project-side overrides) are left untouched, and `tokens.css.bak` is backed up before writing. This removes two classes of defect from the old implementation: after one patch it always reported "no upgrade needed", and later source-module changes never propagated; and the append-style upgrade crushed the user's overrides while leaving a whole duplicate block behind. +- New `init-project --check-css` (inspect only, never modify: outdated / missing items reported one by one, ending with exit code 1); `check-slides` gained two info-level checks: tokens.css toolkit staleness (naming which block), and a `class` that exists in neither `tokens.css` nor the slide's own ` + + +
+ A · ${topicHtml || '主题名'} +
+

SECTION · 小节名

+

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

+

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

+
+ +
+
9亿
+
周活跃用户
+
+
+
+ 02 / 08 +
+ + +`; + +const scriptJson = { + _readme: 'html2video-for-mcode 脚本契约。clauses 每个元素=一句口播; stage=这句开口时该入场的视觉层(1/2/3); 可选 text2=双语字幕第二行。tail=收尾留白秒数(默认0.8)。改口播必须过 Gate 1, 之后从 Phase 2 重跑。可选顶层 bgm: "assets/bgm.mp3" 或 {file,volume:0.12,fadeIn:1.5,fadeOut:2.5}。width/height 决定画布: 1920×1080 横屏 / 1080×1920 竖屏。lang 决定口播语种与音色: zh(默认)/en/yue/其他 BCP-47 — 语种必须与音色匹配, 且影响语速基准与字幕行宽校验。speed=试听时与用户定的语速(Gate 4 可复核); transition=切页方式: cut 硬切(默认, 段间不经过黑场) 或 {type:"xfade",duration:0.4} 交叉溶解。', + topic, + lang: "zh", + voice: 'Chinese (Mandarin)_Gentleman', + speed: { default: 1.1, first: 1.05, last: 1.05 }, + transition: { type: 'cut' }, + fps: 30, width: 1920, height: 1080, + slides: [ + { id: '01', layout: 'title-hero', html: '01-title.html', audio: '01.mp3', title: '', clauses: [{ stage: 1, text: '' }] }, + { id: '02', layout: 'statement', html: '02-statement.html', audio: '02.mp3', title: '', clauses: [{ stage: 1, text: '' }, { stage: 2, text: '' }] }, + { id: '03', layout: 'bullets', html: '03-bullets.html', audio: '03.mp3', title: '', clauses: [{ stage: 1, text: '' }, { stage: 2, text: '' }] }, + { id: '04', layout: 'compare', html: '04-compare.html', audio: '04.mp3', title: '', clauses: [{ stage: 1, text: '' }, { stage: 2, text: '' }] }, + { id: '05', layout: 'data-viz', html: '05-data.html', audio: '05.mp3', title: '', clauses: [{ stage: 1, text: '' }, { stage: 2, text: '' }] }, + { id: '06', layout: 'code', html: '06-code.html', audio: '06.mp3', title: '', clauses: [{ stage: 1, text: '' }, { stage: 2, text: '' }] }, + { id: '07', layout: 'quote', html: '07-quote.html', audio: '07.mp3', title: '', clauses: [{ stage: 1, text: '' }, { stage: 2, text: '' }] }, + { id: '08', layout: 'closing', html: '08-closing.html', audio: '08.mp3', title: '', clauses: [{ stage: 1, text: '' }] }, + ], +}; + +// 覆盖保护(必须在建目录之前检查, 否则会把自建的空子目录当成"非空"): 目标目录已存在且 +// 非空 → 拒绝。init 会重置 script.json / notes.md 等 5 个文件, 误跑到已开工的项目上会毁掉 +// 全部进度, 所以必须显式 --force。 +const FORCE = argv.includes('--force'); +const GENERATED = ['script.json', 'slides/tokens.css', 'slides/_template.html', 'assets/MANIFEST.md', 'research/notes.md']; + +// --upgrade-css / --check-css: 只管 tokens.css 里三个工具箱受管块的新旧, 不生成/不重置任何文件。 +// 老项目缺 no-fx 规则时 会静默失效(只关动画不把 opacity 抬回来, 页面反而空白); +// 缺图表/表格工具箱时新配方会静默半死(条形不生长 / 环形不扫出 / 数字不滚动)。 +// 判定按内容 rev(见 css-kit.mjs), 不再是"文件里出现过标记字符串就算有" —— 那种判定在项目补过 +// 一次后就永远报"无需升级", 源头后续改动再也传不下去(2026-09-18 排查定案的根因)。 +if (argv.includes('--upgrade-css') || argv.includes('--check-css')) { + const cssPath = path.join(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 bad = kitStatuses(css).filter(s => s.status !== 'ok'); + if (!bad.length) { console.log(`✓ tokens.css 工具箱已是最新(kit rev ${KIT_REV.slice(0, 8)})`); process.exit(0); } + for (const s of bad) console.error(`✗ ${s.label}: ${s.status === 'stale' ? s.detail : `缺这段(${s.detail})`}`); + console.error(' → node scripts/init-project.mjs <项目目录> --upgrade-css(原地替换受管块, 不动你的其他规则)'); + process.exit(1); + } + + const { css: next, actions, normalizedLineEndings } = applyKitUpgrade(css); + if (!actions.length) { console.log(`无需升级: ${cssPath} 工具箱已是最新(kit rev ${KIT_REV.slice(0, 8)})`); process.exit(0); } + if (fs.existsSync(cssPath + '.bak')) console.warn('⚠ 已存在 tokens.css.bak, 将被本次升级前的备份覆盖(旧备份会丢, 需要留就先改名)'); + fs.writeFileSync(cssPath + '.bak', 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(' · ')}`); +} + +for (const d of ['research', 'assets', 'slides', 'audio', 'preview', 'out', 'build', 'asr']) { + fs.mkdirSync(path.join(dir, d), { recursive: true }); +} +fs.writeFileSync(path.join(dir, 'slides', 'tokens.css'), TOKENS_CSS); +fs.writeFileSync(path.join(dir, 'slides', '_template.html'), TEMPLATE_HTML); +fs.writeFileSync(path.join(dir, 'script.json'), JSON.stringify(scriptJson, null, 2) + '\n'); +fs.writeFileSync(path.join(dir, 'assets', 'MANIFEST.md'), + `# 素材清单\n\n| 文件 | 内容 | 来源 | 许可 |\n|---|---|---|---|\n\n\n`); +fs.writeFileSync(path.join(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/nofx-css.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/nofx-css.mjs new file mode 100644 index 0000000..b6808a4 --- /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 0000000..8f54f24 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/plan-timings.mjs @@ -0,0 +1,116 @@ +#!/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 { requireTool, safeId, safeRel, safeOut, 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 = path.resolve(argv.find(a => !a.startsWith('--')) ?? '.'); +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; +})(); + +function probeDur(file) { + const r = spawnSync(FFPROBE, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { encoding: 'utf8', windowsHide: true }); + if (r.status !== 0 || !r.stdout) return null; + const d = parseFloat(r.stdout.trim().split('\n')[0]); + return Number.isFinite(d) ? d : null; +} +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(path.join(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 0000000..55487a8 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/prep-image.mjs @@ -0,0 +1,98 @@ +#!/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%、输出严格目标比例。 +// 优先靠换图/换版式解决, 本工具只是最后的兜底手段。 +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { requireTool } from './tools.mjs'; + +const argv = process.argv.slice(2); +const VALUE_FLAGS = new Set(['--ratio', '--anchor']); // 这些 flag 后面跟一个值, 值不算位置参数 +const positional = []; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { if (VALUE_FLAGS.has(a)) i++; continue; } + positional.push(a); +} +const flag = (name, dflt) => { const i = argv.indexOf(name); return i > -1 ? argv[i + 1] : dflt; }; + +// 按当前工作目录找项目内的 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 r = spawnSync(FFPROBE, ['-v', 'error', '-select_streams', 'v:0', + '-show_entries', 'stream=width,height', '-of', 'csv=p=0', file], win); + const m = (r.stdout || '').trim().match(/(\d+),(\d+)/); + return m ? { w: +m[1], h: +m[2] } : 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] = (flag('--ratio', '16:9')).split(':').map(Number); + const anchor = flag('--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 0000000..5d9e60f --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/preview-page.mjs @@ -0,0 +1,622 @@ +#!/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 { safeId, safeRel, safeOut, validateScriptPaths } from './tools.mjs'; +import { NOFX_CSS } from './nofx-css.mjs'; +import { KITS, kitStatuses, MAX_SCAN_BYTES } from './css-kit.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 = path.resolve(argv.find(a => !a.startsWith('--')) ?? '.'); + 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

图:某来源
`); + 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/table-kit.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/table-kit.test.mjs new file mode 100644 index 0000000..d84a494 --- /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 0000000..8bc68be --- /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); + }); +});