From 41045725aced09c1d8f838c452fee0a2e17c4fd6 Mon Sep 17 00:00:00 2001 From: Hahaha Date: Thu, 17 Sep 2026 23:23:20 +0800 Subject: [PATCH 01/24] Add Plugin: html2video-for-mcode Narrated-video pipeline: HTML slides with staged entrance animations, TTS voiceover measured with ffprobe, burned-in subtitles, deterministic frame-stepping capture, ffmpeg assembly, and ASR verification. - 11 Node scripts, no build step; works with mcode connectors or mmx-cli - 13 themes, 17 layout recipes, image framing primitives - Subtitles confined to their own windows (no overlap); 16:9 and 9:16 canvases - Research phase documented: source grading, cross-verification rules, notes template - Discloses dependencies, accounts, network destinations and data handling (bilingual README) - Validation: node scripts/validate.mjs -> OK (exit 0) --- .../.claude-plugin/plugin.json | 8 + .../Wzdhehe/html2video-for-mcode/CHANGELOG.md | 46 ++ plugins/Wzdhehe/html2video-for-mcode/LICENSE | 21 + .../Wzdhehe/html2video-for-mcode/README.md | 154 ++++++ .../html2video-for-mcode/README.zh-CN.md | 127 +++++ .../THIRD-PARTY-NOTICES.md | 61 +++ .../Wzdhehe/html2video-for-mcode/plugin.json | 23 + .../skills/html2video-for-mcode/SKILL.md | 249 +++++++++ .../html2video-for-mcode/evals/evals.json | 148 ++++++ .../references/authoring.md | 307 +++++++++++ .../references/image-sources.md | 105 ++++ .../html2video-for-mcode/references/render.md | 130 +++++ .../references/research.md | 80 +++ .../references/tts-and-timing.md | 159 ++++++ .../html2video-for-mcode/scripts/asr.mjs | 226 +++++++++ .../scripts/build-video.mjs | 205 ++++++++ .../html2video-for-mcode/scripts/capture.mjs | 250 +++++++++ .../scripts/check-slides.mjs | 117 +++++ .../scripts/check-theme.mjs | 93 ++++ .../scripts/check-timing.mjs | 125 +++++ .../scripts/fetch-official-images.mjs | 132 +++++ .../scripts/init-project.mjs | 480 ++++++++++++++++++ .../scripts/plan-timings.mjs | 102 ++++ .../scripts/prep-image.mjs | 95 ++++ .../html2video-for-mcode/scripts/tools.mjs | 99 ++++ 25 files changed, 3542 insertions(+) create mode 100644 plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json create mode 100644 plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md create mode 100644 plugins/Wzdhehe/html2video-for-mcode/LICENSE create mode 100644 plugins/Wzdhehe/html2video-for-mcode/README.md create mode 100644 plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md create mode 100644 plugins/Wzdhehe/html2video-for-mcode/THIRD-PARTY-NOTICES.md create mode 100644 plugins/Wzdhehe/html2video-for-mcode/plugin.json create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/image-sources.md create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/render.md create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/research.md create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/tts-and-timing.md create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/asr.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/build-video.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/capture.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-slides.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-theme.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-timing.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/fetch-official-images.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/init-project.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/plan-timings.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/prep-image.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/tools.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..7298ffd1 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "html2video-for-mcode", + "version": "1.0.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 00000000..732f2ca9 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +## 1.0.0 — 2026-09-17 + +首个公开版本。 + +**流水线**:开工对齐(语言/风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 → HTML 分步入场 → 逐帧渲染 → ASR 反向校验,共 7 阶段 6 个确认闸门。 + +**脚本(11 个,纯 Node,无构建步骤)** + +| 脚本 | 作用 | +|---|---| +| `init-project.mjs` | 生成项目骨架(目录 + tokens.css + slide 模板 + script.json 契约) | +| `plan-timings.mjs` | ffprobe 实测每段 TTS → 时长、每层入场时刻、每句开口时刻 | +| `check-timing.mjs` | 静音检测实测每句真实开口,与估算对比并可校准 | +| `check-theme.mjs` | 全部主题的 WCAG 对比度闸门(正文/次级/字幕/强调色) | +| `check-slides.mjs` | 渲染前静态检查(未定义变量/图片缺失/外链/data-stage 未配动画/硬编码颜色) | +| `capture.mjs` | 终态截图或逐帧步进捕获,字幕默认烧录 | +| `build-video.mjs` | 编码 → 拼接 → 音轨对位 → BGM 混音 → 合成 → 自检 + SRT | +| `asr.mjs` | ASR 转写与脚本比对(直调 REST),支持字级时间戳核对开口时刻 | +| `prep-image.mjs` | 配图检查与受限裁切(裁掉面积上限 20%) | +| `fetch-official-images.mjs` | 从官方站点列取并下载候选素材图 | +| `tools.mjs` | ffmpeg/ffprobe 与 Node 包的多锚点探测 | + +**设计系统**:13 套主题、17 种版式、图片框原语(`.img-frame`)、14 个入场/氛围动画、分步入场与错峰容器。 + +**语言**:中文普通话 / 英语 / 粤语,字数与语速基准、字幕行宽、ASR 语言校验随语言切换。 + +**画布**:1920×1080 横屏与 1080×1920 竖版(字幕几何随画布比例自适应)。 + +**字幕**:单语或双语烧录,另出 `out/subs.srt`。 + +**声音**:BGM 垫底(可选,自动循环与淡入淡出)、ASR 反向校验(音色语种、数字与专名一致性)。 + +**资料搜集**:`references/research.md` —— 来源四级分级、四条硬规则(多源交叉验证/一手优先/标注口径日期/不确定不进脚本)、query 设计、多源矛盾处理、notes 模板、各环境搜索工具差异(含 mmx search 10 条上限)。 + +**两套工具链**:mcode 沙箱用 platform connectors;其他 Agent 环境用 mmx-cli(配 TTS)与本仓 `asr.mjs`(配 ASR)。 + +**已修复的静默故障(均加了闸门)** + +- 入场延迟被 `animation` 简写覆盖 → 元素在 0 秒入场(改用变量槽传递延迟) +- 未定义 CSS 变量 + 透明文字填充 → 文字完全隐形(check-slides 静态拦截) +- 图片加载失败 → 只出 broken 图标而无报错(capture 运行期点名) +- 缺少 clauses → 静默出无字幕片(capture 告警) +- 字幕窗口重叠 → 相邻两句同时可见, 看起来像"重影/错字"(淡出改为在窗口内归零, 并加窗口自检) +- 主题覆盖被忽略、同名选择器只取首个块(check-theme 合并语义修正) diff --git a/plugins/Wzdhehe/html2video-for-mcode/LICENSE b/plugins/Wzdhehe/html2video-for-mcode/LICENSE new file mode 100644 index 00000000..73c5531e --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Wzdhehe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/Wzdhehe/html2video-for-mcode/README.md b/plugins/Wzdhehe/html2video-for-mcode/README.md new file mode 100644 index 00000000..8bd94f36 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/README.md @@ -0,0 +1,154 @@ +**English** | [中文](README.zh-CN.md) + +# HTML 2 Video for mcode + +Turn a topic, an outline, or a finished script into a **narrated MP4**: HTML slides with staged +entrance animations, a TTS voiceover, burned-in subtitles, and an ASR pass that verifies the +voiceover actually says what the script says. + +Built for MiniMax Code (mcode), and runnable in any agent host through `mmx-cli`. + +## What the user gets + +Ask in plain language, get a publishable video: + +> 帮我把这份大纲做成一条 60 秒的中文口播视频:三张关键数字、结尾一句行动号召,用深色科技主题,加中文字幕。 + +Result: + +``` +my-video/ +├── slides/ 8 HTML slides + tokens.css (13 themes, 17 layout recipes) +├── audio/ 8 TTS clips +├── build/timings.json measured durations + per-layer entrance times +├── preview/*.png terminal-state frames +└── out/ + ├── final.mp4 ★ the deliverable (1920×1080 or 1080×1920, H.264 + AAC) + ├── subs.srt subtitles for platform upload + └── slide-*.mp4 per-slide segments +``` + +## Pipeline + +One Skill drives an 11-script pipeline (`skills/html2video-for-mcode/scripts/`): + +| Stage | What happens | +|---|---| +| 1. Kickoff alignment | Ask about language (Chinese / English / Cantonese), style & brand color, subtitles (none / single / bilingual), canvas (16:9 or 9:16), duration, voice, asset boundaries | +| 2. Research | Fact-check the topic and record sources before writing | +| 3. Script | Per-slide narration split into clauses; every slide must have a title layer and a detail layer | +| 4. TTS | Voice synthesis (mcode connector, or `mmx-cli` elsewhere), then measure every clip with ffprobe | +| 5. Timing | Derive each slide's duration and each animation's entrance time from the **measured** audio — nothing is hand-written | +| 6. Assets | Official sources first, image framing primitives, compliance manifest | +| 7. HTML | Staged entrance animations bound to the measured timings; layout recipes for 17 slide types | +| 8. Render | Deterministic frame-stepping capture (animations land in the video), ffmpeg assembly with subtitles, optional background music | +| 9. Verify | ASR transcription compared against the script; contrast, theme, and static-slide gates | + +## Design decisions worth knowing + +- **Nothing about timing is hand-written.** Every slide's duration and every animation's entrance + time comes from the measured TTS audio, so "the voiceover finished but the picture is still + waiting" cannot happen by construction. +- **Every slide has a title layer and a detail layer** on separate animation stages, so a slide is + never just a big title with nothing to look at. +- **Animations land in the video.** Capture steps frames deterministically instead of + screen-recording, so entrance animations are actually rendered rather than frozen. +- **Rendering is gated.** A static check refuses to render slides with undefined CSS variables, + missing images, external resources, or entrance animations without an animation class — the + failure modes that otherwise ship a video that looks broken while every script reports success. + +## Install + +**As a plugin (MiniMax Code):** add `plugins/Wzdhehe/html2video-for-mcode` from this repository, or +install it from the community catalog once merged. + +**As a standalone skill (any AgentSkills host):** + +```bash +cp -r html2video-for-mcode ~/.claude/skills/ # or ~/.openclaw/skills/ +# project-level +cp -r html2video-for-mcode /.claude/skills/ +# or from GitHub +npx skills add Wzdhehe/html2video-for-mcode +``` + +Then install the two dependencies **in your video project** (not in the skill directory): + +```bash +cd +npm i playwright && npx playwright install chromium +# ffmpeg: winget install Gyan.FFmpeg / brew install ffmpeg / apt install ffmpeg +# or: npm i ffmpeg-static ffprobe-static +``` + +## Requirements + +- **Node.js 18+** (plain ESM, no build step). +- **ffmpeg / ffprobe** on `PATH`, or `ffmpeg-static` / `ffprobe-static` in the project. Scripts + probe `PATH → project node_modules → skill parents → common install locations`. +- **Playwright Chromium** for capture. Install it in the video project — scripts resolve it from + the project directory, the working directory, or the global npm root. +- **Voice synthesis**, one of: mcode platform connectors, `mmx-cli` + (`npm i -g mmx-cli && mmx auth login --api-key sk-...`), or your own TTS written to + `audio/.mp3`. +- Optional: `MINIMAX_API_KEY` for `scripts/asr.mjs`, which transcribes the voiceover and compares + it against the script (numbers, proper nouns, and spoken language). + +## Quick start + +```bash +node /scripts/init-project.mjs ./my-video --topic "My topic" +# fill research/notes.md and script.json (clauses = one line of narration each) +# synthesize audio/01.mp3 … audio/08.mp3 +node /scripts/plan-timings.mjs ./my-video # measure audio → timings.json +node /scripts/check-slides.mjs ./my-video # static gate before rendering +node /scripts/capture.mjs ./my-video --mode motion +node /scripts/build-video.mjs ./my-video --asr +``` + +`SKILL.md` carries the full workflow (7 phases, 6 confirmation gates); `references/` holds the +authoring rules, asset-sourcing SOP, TTS/timing notes, and rendering internals. + +## Supported platforms + +Windows, macOS, and Linux. All scripts are Node ESM and shell-agnostic. On Windows prefer Git +Bash / WSL over PowerShell (complex argument lists with non-ASCII paths can misbehave there); +ffmpeg and Chromium paths are auto-detected. + +## Network access + +Nothing is contacted unless you invoke the step that needs it: + +- `scripts/asr.mjs` — HTTPS `POST` to `https://api.minimaxi.com/v1/speech_to_text` + (or `https://api.minimax.io` when `MINIMAX_REGION=global`). Only when you run it. +- `scripts/fetch-official-images.mjs` — opens the URL **you** pass (an official site or a local + `file://` page) to list and download candidate images. +- Voice synthesis happens through mcode connectors or `mmx-cli`, which contact MiniMax. +- Everything else (timing, static checks, capture, encoding, theme validation) is fully offline. + +No telemetry, no analytics, no hidden endpoints, no installers, no native binaries. + +## Data use + +- The narration text you write is sent to the speech service you chose; the produced audio is sent + to the ASR service only if you run `scripts/asr.mjs`. +- Assets you fetch are downloaded into your project's `assets/` directory and must be recorded in + `assets/MANIFEST.md` with source and license. +- Everything else stays on disk inside your project directory. The Skill writes only inside the + project directory you pass to it. +- No credentials are stored or embedded: the ASR script reads a key from `MINIMAX_API_KEY` or + `--api-key` at runtime and never writes it anywhere. + +## Troubleshooting + +`SKILL.md` ends with a symptom → cause → fix table covering the failures this pipeline has actually +hit: silent tails after the voiceover, slides with nothing on them, elements entering at 0 seconds, +invisible text from undefined CSS variables, broken images, subtitles washed out on dark themes, +wrong voice language, and mismatched concat durations. + +## License + +MIT — see `LICENSE`. Portions of the design system (10 themes, image-frame primitives, several +entrance animations) are adapted from +[html-ppt-skill](https://github.com/lewislulu/html-ppt-skill) (MIT, Copyright (c) 2026 lewis); the +full notice and the upstream MIT text are in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md). diff --git a/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md b/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md new file mode 100644 index 00000000..8e8cd0ad --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md @@ -0,0 +1,127 @@ +[English](README.md) | **中文** + +# HTML 2 Video for mcode + +把一句主题、一份大纲或一篇定稿脚本,变成**带口播的成片 MP4**:HTML 幻灯片(分步入场动画)+ TTS 配音 + 烧录字幕,并用 ASR 反向校验"配音是否真的念的是脚本里的话"。 + +为 MiniMax Code(mcode)定制,也能在其他 Agent 环境里通过 `mmx-cli` 运行。 + +## 用户能得到什么 + +用大白话提需求,拿到一条可直接发布的视频: + +> 帮我把这份大纲做成一条 60 秒的中文口播视频:三张关键数字、结尾一句行动号召,用深色科技主题,加中文字幕。 + +产物: + +``` +my-video/ +├── slides/ 8 张 HTML + tokens.css(13 套主题、17 种版式配方) +├── audio/ 8 段 TTS 音频 +├── build/timings.json 实测时长 + 每个视觉层的入场时刻 +├── preview/*.png 终态预览帧 +└── out/ + ├── final.mp4 ★ 交付成片(1920×1080 或 1080×1920,H.264 + AAC) + ├── subs.srt 供平台上传的字幕 + └── slide-*.mp4 逐张分段 +``` + +## 流水线 + +一个技能驱动 11 个脚本(`skills/html2video-for-mcode/scripts/`): + +| 阶段 | 做什么 | +|---|---| +| 1. 开工对齐 | 询问语言(中文/英语/粤语)、风格与品牌色、字幕(不要/单语/双语)、画布(16:9 或 9:16)、时长、音色、素材边界 | +| 2. 信息搜集 | 事实性题材先核查并记录来源,再动笔 | +| 3. 脚本 | 每张 slide 的口播拆成逐句 clauses;每张必须有"标题层 + 展开层" | +| 4. TTS | 配音合成(mcode connector,其他环境用 `mmx-cli`),随后用 ffprobe 实测每段时长 | +| 5. 对时 | 每张时长、每层入场时刻**全部由实测音频推出**,不手写任何秒数 | +| 6. 配图 | 官方素材优先、图片框原语、合规清单登记 | +| 7. HTML | 分步入场动画绑定实测时刻;17 种版式配方 | +| 8. 渲染 | 确定性逐帧步进捕获(动画真正进视频)、ffmpeg 合成与字幕、可选 BGM | +| 9. 校验 | ASR 转写与脚本比对;对比度、主题、渲染前静态闸门 | + +## 几个关键设计 + +- **时序不靠手写**:每张时长与每个动画的入场时刻都来自实测音频,所以"配音念完了画面还在等"在结构上就不可能发生。 +- **每张都有标题层与展开层**,分属不同动画 stage,不会出现"只有一行大字"的页面。 +- **动画真的进视频**:捕获用逐帧步进而不是录屏,入场动画是渲染出来的,不是冻结在终态。 +- **渲染有闸门**:静态检查会拒绝未定义 CSS 变量、图片缺失、外链资源、入场动画缺动画类的页面 —— 这些正是"视频看着坏了但每个脚本都报成功"的元凶。 + +## 安装 + +**作为插件(MiniMax Code)**:使用本仓库的 `plugins/Wzdhehe/html2video-for-mcode`,合并后也可从社区目录安装。 + +**作为独立技能(任意 AgentSkills 宿主)**: + +```bash +cp -r html2video-for-mcode ~/.claude/skills/ # 或 ~/.openclaw/skills/ +# 项目级安装 +cp -r html2video-for-mcode <你的项目>/.claude/skills/ +# 或直接从 GitHub 安装 +npx skills add Wzdhehe/html2video-for-mcode +``` + +然后把两个依赖装到**你的视频项目里**(不是技能目录里): + +```bash +cd <你的视频项目> +npm i playwright && npx playwright install chromium +# ffmpeg: winget install Gyan.FFmpeg / brew install ffmpeg / apt install ffmpeg +# 或: npm i ffmpeg-static ffprobe-static +``` + +## 依赖要求 + +- **Node.js 18+**(纯 ESM,无构建步骤)。 +- **ffmpeg / ffprobe**:在 `PATH` 上,或项目里装 `ffmpeg-static` / `ffprobe-static`。脚本按 `PATH → 项目 node_modules → 技能上两级 → 常见安装位置` 探测。 +- **Playwright Chromium**:截图用。装在视频项目里即可 —— 脚本会从项目目录、工作目录、npm 全局逐个解析。 +- **配音**:三选一 —— mcode 平台 connector、`mmx-cli`(`npm i -g mmx-cli && mmx auth login --api-key sk-...`)、或你自己的 TTS(把音频写到 `audio/.mp3`)。 +- 可选:`MINIMAX_API_KEY`,用于 `scripts/asr.mjs` 把配音转写回来与脚本比对(数字、专名、语种)。 + +## 快速开始 + +```bash +node /scripts/init-project.mjs ./my-video --topic "我的主题" +# 填 research/notes.md 与 script.json(clauses = 每句口播一行) +# 合成 audio/01.mp3 … audio/08.mp3 +node /scripts/plan-timings.mjs ./my-video # 实测音频 → timings.json +node /scripts/check-slides.mjs ./my-video # 渲染前静态闸门 +node /scripts/capture.mjs ./my-video --mode motion +node /scripts/build-video.mjs ./my-video --asr +``` + +工作流全文(7 阶段、6 个确认闸门)在 `SKILL.md`;`references/` 放着编写规范、配图 SOP、TTS/对时说明与渲染内幕。 + +## 支持平台 + +Windows / macOS / Linux。脚本全部是 Node ESM,不依赖特定 shell。Windows 上建议用 Git Bash 或 WSL 而非 PowerShell(非 ASCII 路径 + 复杂参数组合容易出问题);ffmpeg 与 Chromium 路径自动探测。 + +## 网络访问 + +**只有你主动执行的那一步才会联网**: + +- `scripts/asr.mjs` —— HTTPS `POST` 到 `https://api.minimaxi.com/v1/speech_to_text`(海外套餐设 `MINIMAX_REGION=global` 时走 `https://api.minimax.io`)。仅在你运行时。 +- `scripts/fetch-official-images.mjs` —— 打开**你传入的**网址(官方网站或本地 `file://` 页面)以列出并下载候选配图。 +- 配音合成经由 mcode connector 或 `mmx-cli`,它们会访问 MiniMax。 +- 其余全部离线:对时、静态检查、截图、编码、主题对比度校验。 + +无遥测、无埋点、无隐藏端点、无安装器、无原生二进制。 + +## 数据使用 + +- 你写的口播文本会送到你选择的语音服务;产物音频只在你运行 `scripts/asr.mjs` 时才送到 ASR 服务。 +- 抓取的素材下载到项目的 `assets/` 目录,并需在 `assets/MANIFEST.md` 登记来源与许可。 +- 其余数据全部留在你传入的项目目录内 —— 技能只往这个目录里写东西。 +- **不存储、不内嵌任何凭据**:ASR 脚本运行时从 `MINIMAX_API_KEY` 或 `--api-key` 读取,且从不写出到任何文件。 + +## 排错 + +`SKILL.md` 末尾有一张"症状 → 原因 → 处置"表,覆盖这条流水线真实踩过的坑:配音念完画面还在等、页面只有标题、元素在 0 秒就入场、未定义 CSS 变量导致文字隐形、图片 broken、深色主题字幕糊底、音色语种不对、拼接后时长不符。 + +## 许可 + +MIT —— 见 `LICENSE`。设计系统的一部分(10 套主题、图片框原语、若干入场动画)改编自 +[html-ppt-skill](https://github.com/lewislulu/html-ppt-skill)(MIT,Copyright (c) 2026 lewis); +完整声明与上游 MIT 原文见 [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md)。 diff --git a/plugins/Wzdhehe/html2video-for-mcode/THIRD-PARTY-NOTICES.md b/plugins/Wzdhehe/html2video-for-mcode/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..db353ffd --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/THIRD-PARTY-NOTICES.md @@ -0,0 +1,61 @@ +# 第三方组件与许可声明 + +本技能包含改编自第三方开源项目的内容。按 MIT 许可要求,原版权声明与许可原文如下。 + +--- + +## html-ppt-skill + +- **来源**:https://github.com/lewislulu/html-ppt-skill +- **版权**:Copyright (c) 2026 lewis +- **许可**:MIT +- **引入日期**:2026-09-17 +- **改编范围**(均经改写以适配本技能的单页静态渲染与音画同步体系): + - `scripts/init-project.mjs` 生成的 `slides/tokens.css`: + - 10 套命名主题(`minimal-white` / `swiss-grid` / `corporate-clean` / `editorial-serif` / + `magazine-bold` / `tokyo-night` / `catppuccin-mocha` / `nord` / `xiaohongshu-white` / `soft-pastel`) + 的配色令牌 —— 变量名已映射为本技能的命名(`--surface`→`--panel`、`--text-1/2/3`→`--fg/--muted/--fg-3`、 + `--border`→`--line` 等),外链字体(Playfair Display / Space Grotesk 等)已替换为本地字体栈 + - 图片框原语 `.img-frame` / `.contain` / `.fill` / `.img-scrim` / `.img-cap` / `.img-tag` 及其 + `--img-ratio` / `--img-pos` 变量 + - 部分入场动画的 keyframes(`fx-blur` `fx-rise` `fx-pop` `fx-spotlight` `fx-ripple` `fx-glitch` + `fx-shimmer` `fx-kenburns` `fx-stagger`)—— 触发机制已由"切页时 JS 重新触发"改为 + "`animation-delay: var(--tN)` 绝对时间",以配合逐帧步进渲染 + - 排版角色与容器类 `.kicker` / `.eyebrow` / `.lede` / `.card`(含三变体)/ `.pill` / `.divider-accent` + - `references/authoring.md` 的部分版式骨架改写自其 `templates/single-page/` 下的同名版本 + - `references/image-sources.md` 的图片框使用规范参照其 `.img-frame` 设计意图 + +**未引入的部分**(与其设计不兼容或有额外依赖):`runtime.js`(键盘导航/演讲者模式/概览)、 +`assets/animations/fx/` 下的 20 个 canvas 特效(rAF 驱动,无法被逐帧 seek)、 +`fonts.css` 与全部 Google Fonts 外链、Chart.js / highlight.js CDN 依赖、`@media print` 分页逻辑。 + +### MIT 许可原文 + +``` +MIT License + +Copyright (c) 2026 lewis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +--- + +**商用说明**:MIT 许可允许商业使用、修改、再分发与再许可,唯一义务是保留上述版权与许可声明。 +本技能生成的最终产物(视频、字幕、封面等)属于使用者自己的创作,不继承上述许可义务。 diff --git a/plugins/Wzdhehe/html2video-for-mcode/plugin.json b/plugins/Wzdhehe/html2video-for-mcode/plugin.json new file mode 100644 index 00000000..7af3f7f6 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/plugin.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "html2video-for-mcode", + "version": "1.0.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.", + "author": { + "name": "Wzdhehe", + "url": "https://github.com/Wzdhehe" + }, + "homepage": "https://github.com/Wzdhehe/html2video-for-mcode", + "repository": "https://github.com/MiniMax-AI/MiniMax-Code-Plugins", + "license": "MIT", + "keywords": [ + "video", + "html-to-video", + "slides", + "tts", + "voiceover", + "subtitles", + "ffmpeg", + "playwright" + ] +} diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md new file mode 100644 index 00000000..6c963410 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md @@ -0,0 +1,249 @@ +--- +name: html2video-for-mcode +description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻灯片 + TTS + ffmpeg 渲染),为 mcode 环境定制、也可在其他 Agent 环境用 mmx-cli 跑通。当用户想把内容做成视频、html 转 mp4、幻灯片口播视频、slides video、narrated video、一分钟介绍视频、抖音/视频号竖版视频时触发;也当症状出现时触发——TTS 念完留白过长、画面只有标题没有细节展开、部分元素 0 秒就入场、入场动画没渲染进视频、音画不同步、图片主体被裁到画面外、字幕糊在深色背景上、拼接后总时长不对、字体闪烁或方框。覆盖全流程:开工对齐(风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 SOP → HTML 分步入场 → 逐帧渲染 → ASR 反向校验。工具分两套:mcode 沙箱用 connector__matrix__*(TTS/ASR/音乐),其他环境用 mmx-cli(注意:mmx-cli 无 ASR 与音乐生成,见文末环境对照表)。 +--- + +# HTML 2 Video for mcode:脚本 → 口播成片 + +把一个主题/大纲变成一条可发布的 MP4(默认 1920×1080,可切 1080×1920 竖版):HTML 幻灯片(分步入场动画)+ 中文 TTS 口播 + ffmpeg 组装 + ASR 反向校验。 + +## 四条铁律(违反任何一条,产出必然返工) + +1. **所有时长只来自 ffprobe 实测,永远不手写。** 每张 slide 的时长 = 该段 TTS 实测时长 + 尾部留白(默认 0.8s)。不估、不凑整、不写死。 +2. **TTS 先于 HTML。** 先出音频、实测时长、算好每个视觉层的入场时刻,再写 HTML。动画延迟全部通过 CSS 变量 `--t1/--t2/--t3` 注入,HTML 里不写死秒数。这是消灭"念完留白过长"和"音画不同步"的根本手段。 +3. **每个 Gate 等用户确认,不跳步。** Gate 清单见下文工作流。 +4. **开工先对齐,不要闷头开跑。** 第一次响应就把下面这批问题一次问清(用户没答的项可用默认值,但**必须先问**),答案即后续所有 Gate 的验收基线: + +| 要问的 | 选项 / 默认 | +|---|---| +| 主题与受众 | 题材 + 给谁看(决定版式与信息密度) | +| **语言(必问)** | 口播说什么语言:**中文普通话 `zh`**(默认)/ **英语 `en`** / 粤语 `yue` / 其他 BCP-47。这项决定 4 件事:① 口播稿用哪种语言写;② **音色必须匹配语种**(中文用 `Chinese (Mandarin)_*`、英语用 `English_*`,错配会出怪腔调,写完必须 ASR 验语种);③ 字数/语速基准(中文字/秒 vs 英文词/秒,plan-timings 自动切换);④ ASR 识别语言头(zh 强制普通话,能识破粤语) | +| **风格与配色** | 先按受众给 2–3 个候选主题让用户挑(商务 `minimal-white`/`swiss-grid`/`corporate-clean`;编辑杂志 `editorial-serif`/`magazine-bold`;科技深色 `tokyo-night`/`catppuccin-mocha`/`nord`;消费生活 `xiaohongshu-white`/`soft-pastel`)。**再问一句配色偏好**:直接用主题自带主色,还是有品牌色要指定(给了就按 authoring.md 的"自定义主色"覆写 `--accent` 并跑 check-theme 验对比度) | +| **字幕** | 不要字幕 / 单语(**与口播同语言**,默认)/ **双语**(主行=口播语言,次行 `text2` = 另一语言,如中文口播配英文字幕)——决定要不要写 text2、要不要 `--no-subs` | +| **画布与平台** | 横屏 1920×1080(默认,适合 B站/官网)或竖版 1080×1920(抖音/视频号/小红书);竖版要换堆叠版式 | +| 时长 | 默认 ~60s(6–10s/张 × 8 张);短视频平台可压到 30s | +| 音色 | 温润男声 / 干练女声 / 其他;给出 3 个候选试听(见 tts-and-timing.md) | +| 素材边界 | 允许网络取官方图 / 只用官方素材 / 纯排版不出图 | + +## 目录与工具 + +技能自带四个脚本(直接以本技能目录为路径调用,项目目录作为参数,无需复制): + +| 脚本 | 作用 | +|---|---| +| `scripts/init-project.mjs <项目目录>` | 生成项目骨架:目录 + tokens.css + slide 模板 + script.json 契约 | +| `scripts/plan-timings.mjs <项目目录>` | ffprobe 实测每段 TTS → 每张时长、各 stage 入场时刻、**每句 clauses 时刻** → `build/timings.json` | +| `scripts/check-timing.mjs <项目目录> [--calibrate]` | 静音检测实测每句真实开口, 与估算对比;`--calibrate` 按实测校准 timings 后重渲染 | +| `scripts/check-theme.mjs <项目目录>` | 校验全部主题的 WCAG 对比度(正文/次级/字幕/accent-ink), 不达标退出码 1;新增主题必须过闸 | +| `scripts/prep-image.mjs --check <图...>` / `--crop [--ratio 16:9] [--anchor ...]` | 配图 SOP 的执行辅助:查尺寸与裁切风险;按锚点裁切(强制"裁掉 ≤20%、不放大补边") | +| `scripts/capture.mjs <项目目录> [--mode still\|motion] [--no-subs]` | Playwright 截图。still=终态单帧;motion=逐帧步进入场动画。**字幕默认烧录**(内容取自 clauses),`--no-subs` 关闭 | +| `scripts/build-video.mjs <项目目录> [--asr]` | 编码每张 → 拼接 → 音轨对位 → 合成 → 自检 + 出 `out/subs.srt`;`--asr` **按句**切分音频 + 校验清单 | + +环境要求:Node 18+(脚本用 fileURLToPath 保兼容, 不依赖 Node 20.11 的 import.meta.dirname)、`npm i playwright && npx playwright install chromium`(项目目录内)。ffmpeg/ffprobe 自动探测:PATH → node_modules(ffmpeg-static/ffprobe-static)→ 常见安装位置,找不到会给逐条诊断而不是莫名报错。 + +## 安装到其他 Agent 环境 + +本技能就是「一个文件夹 + 根目录 SKILL.md」的标准形态(frontmatter 的 `name` / `description` 已按规范写好),放到对应技能目录即可被识别: + +```bash +# Claude Code(个人级)/ OpenClaw / 其他兼容 AgentSkills 的工具 +cp -r html2video-for-mcode ~/.claude/skills/ # 或 ~/.openclaw/skills/ +# 项目级安装 +cp -r html2video-for-mcode <你的项目>/.claude/skills/ +# 或者把它推到一个 git 仓库后走 skills CLI +npx skills add --skill html2video-for-mcode +``` + +装完只需再补两件**依赖**(装在你的视频项目里,不是技能目录里): + +```bash +cd <你的视频项目目录> +npm i playwright && npx playwright install chromium # 截图用 +# ffmpeg 任选其一: winget install Gyan.FFmpeg / brew install ffmpeg / apt install ffmpeg +# 或: npm i ffmpeg-static ffprobe-static +``` + +**为什么技能装在别处也能跑**:`scripts/tools.mjs` 按「技能自身位置 → 项目目录 → 调用时的工作目录 → npm 全局」逐个锚点解析 `playwright`;ffmpeg/ffprobe 也是四级探测(PATH → 项目 node_modules → 技能上两级 → 常见安装位置)。所以"技能在 `~/.claude/skills/`、依赖装在项目里"是受支持的用法(已实测:技能放到项目树之外仍能出图)。 + +## 运行环境:两套工具链(同一套脚本,只换工具源) + +脚本层(截图 / 渲染 / 合成 / 校验 / 配图)完全环境无关;**只有 TTS、音乐、ASR 三件事依赖平台能力**。 + +| 能力 | mcode 沙箱(首选) | 其他 Agent 环境(Claude Code / OpenClaw / Cursor 等) | +|---|---|---| +| TTS 合成 | `mcode-tools connector call connector__matrix__batch_text_to_audio --args '{...}'`(≤10 条/批,主用);单条试音用 `connector__matrix__synthesize_speech` | `mmx speech synthesize --text "第一句口播。" --voice --speed 1.0 --out audio/01.mp3`;音色列表 `mmx speech voices` | +| 结果落盘 | `get_asset_url ` → 下载到 `audio/.mp3` | `--out` 直接写盘 | +| BGM 音乐 | `connector__matrix__batch_text_to_music`(≤5 条/批) | ⚠ **mmx-cli 无音乐生成** → 让用户提供音乐文件(确认授权后登记 MANIFEST),或跳过 BGM | +| ASR 反向校验 | `mcode-tools upload_temp_url` + `connector__matrix__listen_audio` | **`node scripts/asr.mjs <项目目录>`** —— 用同一把 API Key 直调 REST(`/v1/speech_to_text`),不依赖 mcode、也不用装 whisper;**会自动与 checklist 的预期文本比对并回填,数字/繁体字(粤语)不符直接判 ✗**。想用字级时间戳实测句开口:`--verify-timing` | +| 素材配图 | 内置浏览器 inspect 官网 DOM(首选)/ 官方 brand kit | 同上;抽象配图可用 `mmx image generate --prompt "..." --aspect-ratio 16:9 --n 3`(**仅限抽象概念图,禁止生成 logo / 截图 / 真人头像**),再按 `image-sources.md` 登记 | +| 调研 | `web_search` / `web_fetch` | `mmx search "关键词"` / `mmx text chat` | + +**mmx-cli 首次配置**(非 mcode 环境):`npm install -g mmx-cli` → `mmx auth login --api-key sk-xxx` → `mmx quota` 验证。401 多半是 region 不匹配:`mmx config set --key region --value cn|global`。脚本侧用 `MINIMAX_API_KEY`(必给)与 `MINIMAX_REGION=cn|global`(可选)对齐同一套身份。 + +**纪律不因环境而变**:时长仍由 ffprobe 实测、字幕仍来自 `clauses[]`、音色仍要试听并验语种(走上面的 asr.mjs)、Gate 一个都不跳。 + +## 工作流(7 阶段 · 6 Gate) + +``` +开工对齐(铁律 4, 不设 Gate 但必须先做) +Phase 0 信息搜集 → Gate 0 事实清单 +Phase 1 脚本设计 → Gate 1 逐张口播稿 +Phase 2 TTS + 对时 → Gate 2 试听 + 时长表 +Phase 3 素材收集 → Gate 3 素材清单 + 预览 + 合规确认 +Phase 4 HTML → Gate 4 终态截图 +Phase 5 渲染 + ASR → Gate 5 成片 +Phase 6 交付 +``` + +**顺序是脚手架,不是建议。** 口播稿定了才做 TTS;TTS 时长实测了才动素材和 HTML;素材清单过了 Gate 3 才写进页面;HTML 过了终态截图才渲染。改了口播稿 = 从 Phase 2 重跑(TTS 便宜,重做不贵;带着旧时长硬改才是灾难)。每个 Gate 向用户呈现"验收物清单"里明确的东西,没收到 OK 绝不前进——即使看起来显然,也要确认。 + +### Phase 0 · 信息搜集(条件执行) + +**完整方法见 `references/research.md`**(来源分级、query 设计、矛盾处理、notes 模板)。要点: + +- 判断标准很简单:成片里会出现具体**数字、日期、名称、引语或归属关系** → 必须搜集。通用/抒情/创意题材可跳过,但"看起来像事实"的句子仍要核实。 +- **四条硬规则**:① 关键数字**至少 2 个独立来源**(只有一个就用限定措辞或降级为约数);② **一手优先**(官方公告/财报/技术报告/政府统计),二手转述要回溯原文;③ 标注**口径与日期**(年化还是单季?周活还是月活?币种?);④ **查不到出处或无法判定的,进"不确定项",绝不进口播稿**。 +- 输出 `research/notes.md`:每条含 来源 URL + 口径日期 + 等级 + 第二来源;另列"不确定项"与"不该进脚本的内容"。 +- **Gate 0**:事实清单给用户过 —— 重点让用户确认**数字、名称与口径**。 + +### Phase 1 · 脚本设计(内容量在这里控制) + +在 `script.json` 里逐张填写(契约文件,后续所有脚本都读它): + +```json +{ + "topic": "OpenAI 一分钟", "voice": "Chinese (Mandarin)_Gentleman", + "speed": {"default": 1.0, "first": 0.95, "last": 0.95}, + "fps": 30, "width": 1920, "height": 1080, + "bgm": "assets/bgm.mp3", + "slides": [ + {"id": "03", "layout": "bullets", "html": "03-lines.html", "audio": "03.mp3", + "title": "三大产品线", + "clauses": [ + {"stage": 1, "text": "三大产品线。"}, + {"stage": 2, "text": "ChatGPT 对话,GPT 系列模型,Sora 视频生成。", + "text2": "Chat, models, video generation."} + ]} + ] +} +``` + +可选字段:`clauses[].text2` = 双语字幕第二行(不写则纯中文字幕);顶层 `bgm` = `"assets/bgm.mp3"` 或 `{file, volume:0.12, fadeIn:1.5, fadeOut:2.5}`(写了由 build-video 自动循环+淡入淡出垫底)。 + +**内容量硬规则**(详细版见 `references/authoring.md`): + +- 中文口播 ≈ 4.8 字/秒。每张目标 6–10 秒 → 口播 25–48 字;首尾张 12–20 字。单张硬上限 60 字,超了拆两张。 +- **每张(除首尾)必须同时有"标题层 + 展开层",映射到不同 stage。只有标题、没有展开 = 违规,打回。** 口播也一样:每张至少两句——引入句(配标题层)+ 展开句(配细节层)。 +- `clauses` 的每个元素是一句口播,`stage` 声明"这句开口时,哪个视觉层该出现"。stage 数 ≈ clause 数,一一对应。 +- 版式共 17 种(原 8 + 补充 9:kpi-grid / stat-highlight / table / timeline / roadmap / comparison / flow-diagram / terminal / big-quote),每种画面必含项与字数区间见 `references/authoring.md` 的版式表。 +- 开工对齐时若用户要**双语字幕**,这里就要给每句写 `text2`(同句翻译,不重排语序,≤60 字符)。 + +**Gate 1**:逐张口播稿 + 版式分配给用户过。**未逐张 OK 不进 Phase 2。** + +### Phase 2 · TTS + 实测对时 + +TTS 走哪套工具见上文"运行环境"对照表(mcode 用 `connector__matrix__*`,其他环境用 `mmx speech synthesize`);命令模板与重试纪律见 `references/tts-and-timing.md`。要点:批量 ≤10 条,部分失败 sleep 10–30s 后**只重试失败项**;首尾张 speed 0.95(但 ≤10 字的短句保持 1.0,见该文件实测坑)。产物落 `audio/.mp3`。 + +然后对时: + +```bash +node <技能目录>/scripts/plan-timings.mjs <项目目录> +``` + +它会:ffprobe 每段实测时长 → 每张时长 = 实测 + 尾部留白(默认 0.8s,可在 slide 的 `tail` 字段调:紧凑 0.4 / 舒缓 1.2)→ 每个 stage 的入场时刻 = 该句口播按字数占比估算的开口时刻 − 0.2s(视觉略提前于语音,观感同步)→ 每句的开口时刻/时长写入 `clauses[]`(字幕、ASR 按句切分、对时校准共用)→ 写 `build/timings.json`,并输出警告(语速异常、超 15s、末 stage 离收尾太近、单句超 18 字字幕会换行)。 + +可选但推荐(尤其用户反馈过"音画不同步"时):`node <技能目录>/scripts/check-timing.mjs <项目目录>` 用静音检测实测每句真实开口,输出"估算 vs 实测"对比表;偏差大就 `--calibrate` 校准后删 `build/frames/` 重渲染。注意"体感不同步"也常是设计错位——大数字/主体图必须挂在**提到它的那句**的 stage(强同步原则,见 authoring.md)。 + +**Gate 2**:8 段音频试听 + plan-timings 的时长表给用户过。 + +### Phase 3 · 素材收集(合规在这里把关) + +按 `references/image-sources.md` 的配图 SOP 执行(核心原则:**主体不全/半截/比例差的图,先重搜全貌图,别硬裁硬用**),要点: + +1. 列需求清单(哪几张要图、要什么)。 +2. **优先级:官方渠道(brand kit / simple-icons / 官方文档截图)→ 内置浏览器或 Playwright 打开官网 inspect DOM 取官方资源(实测成功率最高)→ Wikipedia CC → 纯排版降级(数字卡 + 大字 + 引言,不用图)。** 旧 image-downloader(Bing)默认不用:中文冷门题材实测 5/5 返回无关图。 +3. 搜图加正向词(全景/全貌/正面/远景),下载前用缩略图筛:主体居中、比例接近、无水印无无关 logo、≥1200px;一张不合适就换,不凑合。 +4. 拿到图先跑 `node <技能>/scripts/prep-image.mjs --check <图>` 看尺寸与裁切风险;主体贴边就换图。 +5. 每张素材查合规:无水印、商标仅限合理引用语境、照片须可授权来源、截图引用官方文档并注明。 +6. 落盘 `assets/`,每个素材一行登记 `assets/MANIFEST.md`(内容/来源/许可)。 +7. **禁止凭空生成 logo、截图、头像、二维码;禁止带水印图直接入素材;禁止裸放 ``(必须套 `.img-frame`)。** + +**Gate 3**:素材清单表 + 每张素材的预览(缩略图/说明)给用户过,明确问一句"素材来源与授权没问题吗"。**用户过完才写 HTML。** + +### Phase 4 · HTML(分步入场在这里实现) + +每张一个文件,放 `slides/`,文件名与 script.json 的 `html` 字段一致。约定: + +- 根元素 `` 选主题(初版 `a|b|c`,另有 `minimal-white / swiss-grid / corporate-clean / editorial-serif / magazine-bold / tokyo-night / catppuccin-mocha / nord / xiaohongshu-white / soft-pastel`;选主题速查与对比度校验见 authoring.md);引 `tokens.css`;画面容器 `.stage`,含 `.brand` 角标与 `.slide-num` 页码。 +- **每个要入场的块加 `data-stage="1|2|3"` + 一个 fx 工具类**(`fx-up/fx-fade/fx-grow/fx-blur/fx-rise/fx-pop/fx-spotlight/fx-ripple/fx-glitch/fx-draw`;氛围类 `fx-pulse/fx-shimmer/fx-kenburns` 不加 data-stage);延迟不用写——管线按 `timings.json` 注入 `--t1/--t2/--t3`。同层错峰用容器 `.fx-stagger`(基准 `style="--stagger-base:var(--t3)"`)或内联 `style="animation-delay:calc(var(--t2) + 150ms)"`。 +- ⚠ **`data-stage` 必须与 fx 类同时用**(只有属性没有动画类会永远停在 opacity:0);延迟实现见 authoring.md 的"stage 延迟的实现原理",改动画时不要手写 `animation-delay: var(--tN)`。 +- 图片一律套 `.img-frame`(`.contain` 给截图/图表;`--img-ratio` 定比例;`--img-pos` 保主体;图注 `.img-cap` 写在框外)。 +- 氛围动画(无限循环的呼吸/漂浮)允许,但不能承载信息、不加 `data-stage`。 +- 禁用 transition 做入场(截图管线 seek 不到),只用 `@keyframes`。禁外部 Google Fonts(离线不稳),用系统字体栈(tokens.css 已配 CJK fallback)。 +- 素材只用 Gate 3 已确认的 `assets/` 清单,不新增未审素材。 +- ⚠ **写完 8 张后先跑静态检查再截图**:`node <技能>/scripts/check-slides.mjs <项目目录>` —— 抓未定义 CSS 变量(会导致文字隐形)、图片缺失/外链资源、data-stage 没配 fx 类、硬编码颜色。有 ✗ 就别截图,画面对但"看不见"是最难查的。 + +写完终态预览(最快路径,给 Gate 4 看): + +```bash +node <技能目录>/scripts/capture.mjs <项目目录> --mode still +``` + +**Gate 4**:`preview/.png` 逐张给用户过(看版式、字压、素材,不看动画时序——时序由 timings 保证)。 + +### Phase 5 · 渲染 + ASR 校验 + +```bash +node <技能目录>/scripts/capture.mjs <项目目录> --mode motion # 入场动画逐帧进视频 +node <技能目录>/scripts/build-video.mjs <项目目录> --asr +``` + +- `--mode motion`:逐帧步进(暂停全部动画 → 逐帧 seek → 截图 → 编码),动画窗口逐帧渲染、静止段自动补尾帧,时长精确。**字幕默认烧录**(内容取自 clauses、显示窗=该句开口到下句开口,画面底部居中,still 预览里不显示、成片里才有;`--no-subs` 关闭)。成本约 50–200ms/帧,8 张 × 30fps 约 3–6 分钟,预算进超时。赶时间可用 still 模式出片(动画不进视频,只有淡入淡出)。 +- build-video 自动:每张编码(统一参数)→ concat 拼接(时长漂移自动回退重编码)→ 音轨按每张实测时长 `apad` 对位 → **BGM 垫底(配了 bgm 才走:循环补满、淡入淡出、人声优先;混音失败自动退回纯人声)** → mux → ffprobe 时长校验 + 全量解码自检,不过关退出码非 0;同时输出 `out/subs.srt`(与烧录字幕同源同窗,中英双语按 clauses 的 text2 自动两行,供平台上传)。 +- `--asr`:**按句**切出 `asr/part--.mp3` + 生成 `asr/checklist.md`。转写与预期文本比对:数字、年份、产品名必须一致;同音字可容忍。**若某段转写混入上一句的开头,说明那句实际开口比估算晚——跑 check-timing 校准。** + - mcode:`mcode-tools upload_temp_url` 上传后交 `connector__matrix__listen_audio`。 + - **其他环境**:`MINIMAX_API_KEY=sk-xxx node scripts/asr.mjs <项目目录>` —— 直调 REST(同一把 Key),自动比对并回填 checklist;失败项(数字不符/繁体字)会以非 0 退出码报出。想拿更准的开口时刻:`--verify-timing`。 + - 不过关的 slide:改口播或重做该段 TTS → 重跑 plan-timings → 该张重渲染(帧目录删掉对应张即可)。 + +**Gate 5**:成片 `out/final.mp4` + ASR 校验表给用户过,含字幕可读性检查(静音播放一遍,字幕能否撑起理解)与 BGM 电平(人声是否始终清晰)。 + +### Phase 6 · 交付 + +``` +out/final.mp4 # 主交付 +out/slide-*.mp4 # 单段(可单独发布) +build/audio-timeline.wav # 对位后音轨 +preview/*.png slides/*.html slides/tokens.css +audio/*.mp3 assets/(含 MANIFEST.md) research/notes.md asr/(校验记录) +``` + +## 参考文件(按需读,别全读) + +- `references/authoring.md` — 17 种版式规范(每种画面必含项)+ 内容量表 + 入场系统用法(含 stage 延迟实现原理)+ 主题速查 + 竖版说明 + 可抄的 HTML 片段 +- `references/research.md` — 资料搜集(来源分级、交叉验证硬规则、query 设计、矛盾处理、notes 模板) +- `references/image-sources.md` — 配图与素材 SOP(三条取图路径、query 正/反词、两级筛选、图片框用法、裁切硬限制、视觉验证三件套、常见题材索引) +- `references/tts-and-timing.md` — mcode TTS connector 命令、重试纪律、对时算法、留白与语速调校、实测音色表 +- `references/render.md` — 渲染原理(为什么逐帧步进、字体怎么等)、字幕系统、BGM 混音、ffmpeg 手工命令、排错表 +- `THIRD-PARTY-NOTICES.md` — 第三方组件许可声明(10 套主题与部分 CSS 原语改编自 html-ppt-skill,MIT) + +## 常见症状 → 一句话诊断 + +| 症状 | 根因 | 动作 | +|---|---|---| +| TTS 念完画面还停很久 | 时长是估的不是实测,或 tail 过大 | 重跑 plan-timings;调该张 `tail` | +| 画面只有标题没有展开 | 违反内容量硬规则 | 补展开层 + 对应 clause,回 Phase 1 | +| 动画没进视频 | 用了 still 模式 | motion 模式重出 | +| 音画不同步(体感) | 三选一:手写了动画延迟 / 估算偏差 / 视觉锚点挂错句(强同步原则) | 先删手写延迟;跑 check-timing 拿实测数据,偏差大就 --calibrate;锚点错句则调 stage 映射 | +| 音色语种不对(粤语/繁体) | 平台 voice 标签错位,名称不可信 | Phase 1 试听必须带 ASR 验音(见 tts-and-timing.md 实测音色表) | +| 成片没字幕 | 用了 --no-subs 或 clauses 缺失 | capture 默认烧录;确认 timings.json 有 clauses | +| 要双语字幕 | — | clause 加 `text2`,画面两行 + SRT 双行自动出;第二行建议 ≤60 字符 | +| 想要背景音乐 | — | 顶层 `bgm` 配置,build-video 自动循环+淡入淡出垫底(默认音量 0.12);ASR 校验仍走纯人声轨 | +| 拼接后总时长不对 | 混用不同编码器参数的段 | 全部段由 build-video 统一编码;已自动回退重编码 | +| 中文方框 | 系统无 CJK 字体 | Linux 装 fonts-noto-cjk;或改用已装字体 | +| 报"找不到 ffprobe/ffmpeg" | 二进制不在 PATH | 脚本已自动探测 PATH→node_modules→常见位置;装 ffmpeg-static 或 winget install Gyan.FFmpeg | +| **一部分元素 0 秒就入场、一部分按时序** | 旧版 tokens.css 的延迟被 `.fx-*` 简写覆盖 | 换用新版 tokens.css(延迟走 `--fx-delay`);原理见 authoring.md | +| 图片主体被裁到画面外 / 图片撑破版式 | 裸放 ``,或 cover 配错比例 | 套 `.img-frame` + `--img-pos` 保主体;截图类改 `.contain`;主体贴边按 SOP 重搜图 | +| 字幕在深色主题下糊在背景里 | 主题没覆写字幕钩子 | 该主题加 `--sub-bg`(更深)+ `--sub-ring: 1px solid rgba(255,255,255,.16)`;跑 check-theme 验 | +| 要出竖版(抖音/视频号) | — | `script.json` 设 `width:1080, height:1920`,版式改堆叠(见 authoring.md 竖版章节) | +| 数字/文字明明写了却看不见 | 未定义 CSS 变量 + `-webkit-text-fill-color: transparent`,整条 background 失效 | 跑 `check-slides.mjs` 定位,补定义或写 `var(--x, 默认值)` | +| 图片显示 broken 图标 | 文件缺失,或 SVG 本身有问题(XML 错/依赖外部资源/缺尺寸) | `check-slides.mjs` 查路径;SVG 改 inline 进 HTML;capture 也会在渲染时点名哪张没加载 | diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json new file mode 100644 index 00000000..fb49c529 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json @@ -0,0 +1,148 @@ +{ + "skill_name": "html2video-for-mcode", + "evals": [ + { + "id": 0, + "name": "full-run-from-outline", + "prompt": "帮我把这份大纲做成一条一分钟的中文口播视频,1920×1080:介绍 OpenAI——十年时间、三大产品线、九亿周活、25B 年化营收、92% 财富500强渗透、一句 Altman 引语收尾。", + "expected_output": "走完整工作流且顺序正确: 事实性题材先搜集过 Gate 0 → 逐张填 script.json(内容量硬规则: 每张标题层+展开层, 口播 25-48 字)过 Gate 1 → TTS 落 audio/ → plan-timings 实测时长过 Gate 2 → 按 authoring.md 写 8 张 HTML(data-stage+fx-*, 不写死延迟) → capture still 过 Gate 3 → capture motion + build-video --asr → ASR 逐段比对过 Gate 4。全程不手写任何时长数字。", + "files": [] + }, + { + "id": 1, + "name": "long-tail-silence", + "prompt": "之前用别的方法做的幻灯片视频有个毛病:每页 TTS 念完以后画面还要呆等三四秒才切页,很拖。你这套能解决吗?", + "expected_output": "解释根因是时长靠估算而非实测。给出本流水线的解法: TTS 先行 + ffprobe 实测 + slide 时长=实测+tail(默认0.8s), tail 可按紧凑/舒缓调 0.4/1.2; 并说明 stage 入场时刻按字数占比从实测时长推出, 音画同源。", + "files": [] + }, + { + "id": 2, + "name": "title-only-slide", + "prompt": "我看你出的第 3 张只有一个大标题,口播念了一整句,画面什么都没有,能不能这页先出标题,然后跟着口播出几条细节?", + "expected_output": "判定为违反内容量硬规则(画面视觉块≥2 且分属不同 stage)。修正: 补展开层(如 bullets 三条)挂 data-stage=2 + fx-up, 口播拆成引入句(clause stage=1)+展开句(clause stage=2), 入场时刻由 plan-timings 按展开句开口时间自动注入, 不手写延迟。", + "files": [] + }, + { + "id": 3, + "name": "tts-partial-failure", + "prompt": "batch_text_to_audio 返回 8 条里成功 5 条失败 3 条, 报 rate limit, 怎么重试?", + "expected_output": "sleep 10-30s 后只把失败的 3 条重新组一个小批重试, 绝不重发整批; 全部落盘 audio/ 后重跑 plan-timings 再继续。", + "files": [] + }, + { + "id": 4, + "name": "asr-mismatch", + "prompt": "ASR 校验发现第 5 张转写里 '92%' 听成了 '九二', 数字对不上, 怎么处理?", + "expected_output": "改口播写法(如 '九成二')→ 只重做该段 TTS → 重跑 plan-timings → 删 build/frames/05 与 out/slide-05.mp4 → 该张重 capture + build-video, 不整片重做。若确认是同音字级微小差异可接受, 数字/产品名不一致必须返工。", + "files": [] + }, + { + "id": 5, + "name": "asset-gate-browser-route", + "prompt": "做一条介绍某中国 AI 公司的视频, 需要他们的 logo、CEO 照片和产品截图。素材这块你怎么弄?", + "expected_output": "按优先级执行: 官方渠道(brand kit/simple-icons/官方文档截图)优先; 缺的用内置浏览器或 Playwright 打开官网 inspect DOM 提取官方 CDN 资源(2026-09 实测中国 AI 公司 6/6 相关); image-downloader(Bing)默认不用(中文题材实测系统性返回无关图); 每个素材查合规(水印/商标语境/照片授权/截图出处), 落盘 assets/ 并逐条登记 MANIFEST.md; 素材清单+预览过 Gate 3, 明确问用户来源与授权是否 OK, 过了才写 HTML。禁止凭空造图; 无合适素材降级纯排版。", + "files": [] + }, + { + "id": 6, + "name": "subtitle-burn-and-srt", + "prompt": "成片需要有字幕(很多人在静音场景看), 还要能传抖音/B站。字幕怎么加?", + "expected_output": "不需要在 HTML 里写字幕: capture 默认把 timings.json 的 clauses 逐句烧进画面(底部居中胶囊, 显示窗=该句开口到下句开口, --no-subs 可关), build-video 同时输出 out/subs.srt 供平台上传。单句建议 ≤18 字(plan-timings 超限会警告)。Gate 5 用静音播放验收字幕可读性。", + "files": [] + }, + { + "id": 7, + "name": "voice-language-verification", + "prompt": "音色列表里选了个'新闻女声 Chinese (Mandarin)_News_Anchor', 标着普通话, 直接用可以吗?", + "expected_output": "不可以, 标签不可信(该音色 2026-09 实测输出粤语)。Phase 1 试听必须带 ASR 验音: 3 个候选各单条 TTS 同一句探针文案(含数字专名) → listen_audio 转写 → 检查简体/繁体、数字念对; 把实测语种标进试听清单给用户三选一。tts-and-timing.md 附实测音色表可作起点。", + "files": [] + }, + { + "id": 8, + "name": "bilingual-subtitles", + "prompt": "成片要中英双语字幕, 给海外同事看。怎么加?", + "expected_output": "不用改 HTML: script.json 每个 clause 加 text2(同一句的翻译, 不重排语序, ≤60 字符), capture 自动把字幕渲染成两行(中文主行+次行小字号), build-video 的 out/subs.srt 同步双行。plan-timings 对超长 text2 会预警。只做部分句子也可以。", + "files": [] + }, + { + "id": 9, + "name": "bgm-mix", + "prompt": "想给成片加个背景音乐, 别盖过人声。", + "expected_output": "用 connector__matrix__batch_text_to_music 生成无人声、可循环的 BGM 放 assets/bgm.mp3(或用户自备并确认授权), script.json 顶层加 bgm 配置; build-video 自动循环补满+淡入淡出+按 volume(默认 0.12)垫在人声下, 混音失败退回纯人声; ASR 校验仍走纯人声轨不受干扰。Gate 5 验人声清晰度。", + "files": [] + }, + { + "id": 10, + "name": "image-sourcing-sop", + "prompt": "配一张学校大门的实景图, 搜到的图主体都在画面边缘或者只有半截, 比例也不对。怎么办?", + "expected_output": "按 references/image-sources.md 的 SOP: 核心原则是'先重搜全貌图, 别硬裁硬用'。query 加正向词(全景/全貌/正面/远景)排除词(特写/近景/一角/半截); 下载前缩略图筛(主体居中/比例接近/无水印无无关logo/≥1200px), 不合适就换; 拿到图跑 prep-image.mjs --check; 图片一律套 .img-frame, 用 --img-pos 控制主体可见区或 .contain 留白, 不要裸放 img 或硬裁(裁切硬限: 裁掉≤20%、不放大补边); 都不行就换题材(数字卡/大字/引言)。", + "files": [] + }, + { + "id": 11, + "name": "vertical-canvas", + "prompt": "同一条内容还要出一版竖屏的, 发给视频号。要改多少东西?", + "expected_output": "只改 script.json 的 width:1080 / height:1920 —— 渲染管线自动注入 --stage-w/--stage-h, 字幕几何(位置/宽度/字号)跟随画布自动收窄, 不用改代码。但版式要重排: 横屏左右分栏改上下堆叠, 内容边距 ~90px, 标题降到 --fs-h2 量级, 每屏信息量减一档(3条→2条), 图片框用 3/4 或 1/1。竖版注意事项见 authoring.md。", + "files": [] + }, + { + "id": 12, + "name": "stage-delay-covered-by-shorthand", + "prompt": "成片里有些元素一开页就入场了, 另一些却按口播节奏入场, 时序很乱。是 plan-timings 估错了吗?", + "expected_output": "不像估算问题(估算偏差通常 <0.3s)。典型根因是 tokens.css 里入场延迟被覆盖: [data-stage=N] 的 animation-delay 与 .fx-* 的 animation 简写同为 (0,1,0) 优先级, 简写更靠后会把延迟重置为 0 —— 于是只写了内联 calc(var(--t2)+…) 的元素时序正确, 纯靠属性声明的元素 0 秒入场。修法: 用新版 tokens.css(延迟走 shorthand 的变量槽 --fx-delay), 不要把延迟写成独立的 animation-delay。先抽成片帧确认, 再决定要不要动算法。", + "files": [] + }, + { + "id": 13, + "name": "non-mcode-environment-via-mmx", + "prompt": "我在 Claude Code 里, 没有 mcode 的 connector。这个技能还能跑吗? TTS 和 ASR 怎么办?", + "expected_output": "能跑 —— 脚本层(截图/渲染/合成/校验/配图)完全环境无关, 只有 TTS/音乐/ASR 依赖平台。TTS 用 mmx-cli: mmx speech synthesize --text ... --voice --speed 1.0 --out audio/01.mp3(音色 mmx speech voices); ASR 用技能自带 node scripts/asr.mjs <项目>(同一把 API Key 直调 /v1/speech_to_text, 自动比对 checklist, 数字/繁体字不符判 ✗); 音乐生成 mmx-cli 没有 → 用户自备或跳过; 配图可用 mmx image(仅限抽象图, 禁生成 logo/截图/真人头像)。首次配置 npm i -g mmx-cli + mmx auth login, 401 时设 region。纪律不变: 时长实测、字幕来自 clauses、音色要试听验语种、Gate 不跳。", + "files": [] + }, + { + "id": 14, + "name": "asr-rest-and-timing-verify", + "prompt": "ASR 校验怎么跑? 我想顺便确认动画到底有没有对上口播。", + "expected_output": "build-video --asr 先按句切出 asr/part--.mp3; 然后 MINIMAX_API_KEY=sk-xxx node scripts/asr.mjs <项目> 直调 REST 转写, 自动与 checklist 预期文本比对并回填(数字/繁体字不符 → ✗ 非 0 退出; 超 500s/50MB 自动转码)。要确认对轴: node scripts/asr.mjs <项目> --verify-timing 用接口的字级时间戳(verbose_json + timestamp_level=word)实测每句开口, 与 timings.json 的 clauses[].start 对比; 偏差大就校准后重渲染。也可先用 check-timing.mjs(静音法, 离线免费)。", + "files": [] + }, + { + "id": 15, + "name": "english-narration", + "prompt": "这条视频要发给海外同事, 口播改成英语。要改哪些地方?", + "expected_output": "开工对齐时就该问清语言(铁律 4 的语言行: 中文/英语/粤语/其他)。改动四处: ① script.json 设 lang:'en'; ② 口播稿改写英语并按英文基准控量 —— plan-timings 自动切换(约 14 字符/秒、常见 9–18、字幕单行 42 字符, 每张约 14–26 词); ③ 音色换成 English_* 前缀(Chinese (Mandarin)_* 配英文会出怪腔调), 做完用 asr.mjs --language en 验语种; ④ 若开双语, 主行=英语口播、text2 放中文。排版、脚本、渲染流程都不用动; 数字写法反过来(口播 nine hundred million, 画面 900M)。", + "files": [] + }, + { + "id": 16, + "name": "subtitle-readability-verification", + "prompt": "字幕到底怎么验收? 我怕深色主题下看不清。", + "expected_output": "三件套缺一不可: ① 数值关 —— node scripts/check-theme.mjs <项目> 算\"字幕文字 vs 胶囊合成到背景后\"的对比度, <4.5:1 直接不通过(深色主题若不足, 覆写 --sub-bg 更深 + --sub-ring 分隔); ② 帧关 —— 字幕只在成片出现(still 会 finish 掉), 抽 motion 帧 crop 底部条带看: 是否出现/是否被裁/是否与页码重叠(capture 在缺 clauses 时会告警, 不会静默出无字幕片); ③ 静音关(Gate 5)—— 关声看整片, 只靠字幕能否看懂。" + }, + { + "id": 17, + "name": "custom-brand-accent", + "prompt": "客户给了品牌色 #C2410C, 能用上吗?", + "expected_output": "能: 主题主色是令牌。在项目自己的 slides/tokens.css 末尾追加 [data-theme=\"...\"] 覆写 --accent/--accent-ink/--accent-2/--accent-3/--grad(改项目副本不动技能模板), 然后必须跑 check-theme.mjs 验对比度 —— accent-ink 对 accent ≥3:1 才可接受(实测 #C2410C+白字 5.18:1 通过; 浅色如 #FFB84D+白字只有 1.72:1 会被判 ✗ 并退出码 1)。深色主题另看 --sub-bg 字幕胶囊是否仍分得开。" + }, + { + "id": 18, + "name": "undefined-css-variable-invisible-text", + "prompt": "成片里那个大数字 42% 完全看不见, 但 HTML 里明明写了, 也不报错。为什么?", + "expected_output": "未定义 CSS 变量: background:linear-gradient(...,var(--coral-a),var(--coral-b)) 里有个变量没定义 → 整条声明计算为 none; 该元素又用了 -webkit-text-fill-color:transparent(渐变数字的标准写法) → 文字彻底隐形且无任何报错。常见诱因是从外部单页 HTML 抄配色, 变量名不在 tokens.css 里。修法: 项目 tokens.css 补定义, 或写 var(--coral-a, #ED3366) 带默认值; 之后跑 node scripts/check-slides.mjs <项目> 复验(它会静态抓出所有未定义且无 fallback 的变量)。" + }, + { + "id": 19, + "name": "broken-image-and-svg-under-file", + "prompt": "架构图的 SVG 在截图里显示成 broken image, 是 file:// 的兼容性问题吗?", + "expected_output": "不是协议问题 —— 实测 file:// 下 正常加载(400px), PNG 同样正常。broken 的真因通常是 SVG 文件自身: XML 有误、依赖外部资源或 Web 字体(inline 到 img 时被禁)、缺 width/height、或下载失败存成了 HTML。建议一律 inline 进 HTML(彻底绕开)。防线有两道: check-slides.mjs 静态查图片路径缺失/外链并提示 SVG 建议 inline; capture 渲染时会点名哪张图没加载出来(以前是静默出 broken 图标)。" + }, + { + "id": 21, + "name": "research-hard-rules", + "prompt": "这条片子里有个数字\"九亿周活\", 我直接写进口播稿行不行?", + "expected_output": "先过 references/research.md 的四条硬规则: ① 关键数字至少 2 个独立来源(只有一个来源就改限定措辞\"官方口径为…\"或降级为约数); ② 优先一手(官方公告/财报/技术报告/政府统计), 二手转述要回溯原文; ③ 标注口径与日期(周活≠月活, 年化≠单季, 币种要写清); ④ 查不到出处或无法判定 → 进 research/notes.md 的\"不确定项\", 绝不进口播稿。数字类断言还要注意时效(优先 12 个月内)与\"最快/第一\"这类需要新来源的表述。Gate 0 让用户确认数字/名称/口径。", + "files": [] + } + ] +} diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md new file mode 100644 index 00000000..144a247c --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md @@ -0,0 +1,307 @@ +# HTML 设计与内容量规范 + +## 核心原则:每张 slide 是"一句主张 + 一组展开" + +成片最常见的两个败笔,都在authoring阶段就能拦住: + +1. **只有标题没有展开** —— 观众 7 秒只看到一行大字,信息量为零。 +2. **文字一次性全糊上屏** —— 没有节奏,口播念到后半句时视觉早已无话可说。 + +解法是强制的三层结构:**标题层(stage 1)先出 → 展开层(stage 2)在口播展开句开口时入 → 视觉锚点(stage 3,可选)压轴**。口播与画面是同一句话的两个声部。 + +## 内容量表(硬规则) + +中文口播 ≈ 4.8 字/秒(speed 1.0),每张目标 6–10s。**英语按 ~14 字符/秒(约 150 词/分)**——下表字数是中文口径;英语项目按"词"折半看:每张 14–26 词、硬上限 34 词(plan-timings 会按 `lang` 自动切换基准与预警区间)。 + +| 版式 | 用途 | 画面必含(缺一即违规) | 口播字数 | clauses 结构 | +|---|---|---|---|---| +| title-hero | 开场主张 | 大标题 + 副题一行 + logo/主题角标 | 12–20 | 1 句 | +| statement | 单点断言 | 断言大字(s1) + 支撑小字(s2) + 视觉锚点(s3 可选) | 18–28 | 2 句 | +| bullets | 并列要点 | 标题(s1) + 3–4 条要点,每条 ≤14 字(s2) | 25–40 | 2 句 | +| compare | 前后对比 | 标题(s1) + 左右两栏各 ≥1 项 + 分隔(s2) | 22–35 | 2 句 | +| data-viz | 数据说服 | 标题(s1) + ≥2 个数字卡/图表(s2) | 25–40 | 2 句 | +| code | 演示调用 | 标题(s1) + 代码块 ≥3 行 + 高亮注释(s2) | 25–40 | 2 句 | +| quote | 引语转场 | 引语大字(s1) + 署名/头衔(s2) | 20–32 | 2 句 | +| closing | 收束 | 一句收束 + logo/CTA | 12–20 | 1 句 | + +判定规则(写给执行者,自查用): + +- 除 title-hero/closing 外,**画面视觉块 ≥2 个且分属不同 stage**;"光标题 + 页码"直接打回。 +- **强同步原则(2026-09 实测教训):每个 stage 的视觉锚点(大数字/关键词/主体图)必须写在触发它的那句口播里,不要放在上一句。**反例:口播第一句就是"三亿人在用",但"3 亿"数字卡挂在 stage 2——观众听到"三亿"时画面没反应,数字卡进场时口播已讲到下一句,体感就是"动画对不上"。正例:第一句只铺垫("它有三个你可能用过的产品"),"3 亿"作为第二句开头,数字卡挂 stage 2 → 声画同时砸出"3 亿",强同步。 +- 口播 clauses 与 stage 的映射:**stage k 的视觉在 clause k 开口时入场**。stage 数 ≈ clause 数;多出的视觉层用 script.json 的 `stageTimes` 显式给时刻。 +- 单张口播硬上限 60 字;超了说明这张在干两张的活,拆。字幕场景下单句建议 ≤18 字(capture 烧录字幕单行展示);双语时第二行(text2)≤60 字符。 +- 数字、专有名词在口播里用中文读法(“九亿”而非“900000000”),画面上才出现阿拉伯数字。(英语项目相反:口播写英文数字读法 “nine hundred million”,画面用 900M。) +- 双语字幕(可选):每句给 `text2` 即自动两行(主行中文 + 次行小字号);text2 是同一句的翻译,不要重排语序。 + +## 分步入场系统(怎么写 HTML) + +tokens.css 已内置,规则只有四条: + +1. 要入场的块:`data-stage="1|2|3"` + fx 工具类(`fx-up/fx-fade/fx-grow/fx-draw`)。 +2. **不写任何延迟秒数**。`--t1/--t2/--t3` 由渲染管线按 TTS 实测时长注入;浏览器里直接打开时用 tokens 里的占位默认值(0/0.8/2.0s)。 +3. 同层错峰(如三条 bullets 依次出现):内联 `style="animation-delay:calc(var(--t2) + 150ms)"`,第二块 +300ms,以此类推。 +4. 氛围动画(`fx-pulse` 等无限循环)只能用于装饰(光晕、呼吸点),**不加 data-stage、不承载信息**——它们不参与时长计算,静止截图中可能停在任意相位。 + +禁令:入场效果不要用 transition(管线逐帧 seek 不到 transition 状态);不要用 JS 定时器编排(setTimeout 驱动的分层,帧步进模式下不会发生);字体不要引外部 Google Fonts(离线环境直接方框),用 tokens.css 的系统字体栈。 + +### 写 HTML 的三条硬检查(写完立刻跑 `node <技能>/scripts/check-slides.mjs <项目>`) + +这三条都是 2026-09 实测踩过的静默故障 —— 画面明显不对,但流水线一路报成功: + +1. **变量必须先定义,或带 fallback**。`background: linear-gradient(135deg, var(--coral-a), var(--coral-b))` 里只要有一个变量没定义,整条声明计算为 `none`;若该元素又用了 `-webkit-text-fill-color: transparent`(渐变数字的常规写法),**文字会彻底隐形且不报错**。从别的单页 HTML 抄配色时最容易踩:抄来的变量名(如 `--coral-*`)不在 tokens.css 里。要么在项目 tokens.css 补定义,要么写 `var(--coral-a, #ED3366)` 给默认值。 +2. **图片用相对路径且文件必须存在**;`.svg` 建议 **inline 进 HTML**。实测 file:// 下 `` 能正常加载(400px),所以 broken 图标通常不是协议问题,而是 SVG 本身:XML 有误、依赖外部资源/Web 字体、缺 width/height、或下载失败存成了 HTML。inline 一次解决全部。 +3. **不要把外部单页的配色/变量整套抄进来**。可抄版式结构,配色必须落到 tokens 变量(`--accent` / `--good` / `--grad` …);硬编码 hex/rgb 换主题时会串色。 + +## 主题与视觉基调 + +``: + +- **a · 米白+暖橙**(默认,通用商务):bg `#FAFAF7`,accent `#FF5B2E`,衬线大标题。讲述、观点、人物向。 +- **b · 深色+绿**(科技感):bg `#0E0F12`,accent `#10A37F`,高对比数据。产品、AI、开发者向。 +- **c · 黑白+蓝**(极简高端):bg `#FFFFFF`,accent `#1F6FEB`。财务、报告、严肃向。 + +字号纪律:一张 slide 上最多两个层级同屏(标题 + 一个次级);正文 ≥30px,再小就是给审查者找茬。安全边距:内容离边缘 ≥96px(brand/slide-num 除外)。 + +## 素材获取(Phase 3 · Gate 3) + +流程:列需求清单(哪几张要图、要什么)→ 按下面的优先级取材 → 合规自查 → 落盘 `assets/` 并登记 `MANIFEST.md` → **Gate 3 给用户过**。 + +**获取优先级(逐级降级,不硬找):** + +1. **官方渠道(首选)**:logo 用官方 brand kit 或 simple-icons(可直接 curl);产品截图截官方 help/文档;数据用官方报告。合规性最强,事实性题材尽量停在这级。 +2. **内置浏览器 / Playwright 访问官网提取(2026-09 实测推荐)**:用 agent 的内置浏览器或 Playwright 打开官网/官方新闻页,inspect DOM 提取 ``/`` 资源(校验 src 是官方 CDN 域名再下载)。实测中国 AI 公司题材 6/6 全相关,远好于搜索引擎图搜。开工对齐时用户选了"只用官方素材/纯排版"则整级跳过。 +3. **纯排版降级(永远可用的兜底)**:大字 + 数字卡 + 配色 + 版式本身的表现力。宁缺毋假——没有合适素材就用这级,画面依然成立。 + +> ⚠ **image-downloader(Bing 搜图)默认不用。** 2026-09-17 实测:中文 AI 公司题材 5/5 关键词返回的全部是无关图(Bing 端返回"猜你喜欢"推荐卡,脚本正则照单全收)。除非题材是英文大众品牌且愿意逐张人工筛,否则不要走这条路。 + +**合规自查(每个素材过一遍):** + +- 无水印。有水印的候选:换一张,或裁到水印外;裁不掉就不用。 +- 商标/logo 只在"谈论该品牌"的合理引用语境使用,不做装饰滥用。 +- 照片须来自可授权来源(官方新闻图、CC 授权、免费图库);查不到授权的明星/人物照宁可用纯文字引语卡。 +- 截图注明出处(官方文档名 + 日期)。 + +**登记与使用:** + +- `assets/MANIFEST.md` 每个素材一行:文件名 / 内容 / 来源 URL 或渠道 / 许可。Gate 3 连同素材预览一起给用户,明确问"来源与授权没问题吗"。 +- **禁止凭空生成 logo、截图、头像、二维码;禁止带水印图入素材。** +- **图片必须套框,禁止裸放 ``**:用 `.img-frame`(比例与裁切归框)/ `.img-frame.contain`(截图、图表、带文字的图必须用,不裁切居中留白)/ `--img-ratio`(框比例,不必强求 16:9)/ `--img-pos: top|center|bottom`(控制主体可见区,**替代硬裁的首选**)/ `.img-scrim`(压暗垫白字)/ `.img-cap`(图注,写在框外)/ `.img-tag`(角标)。选图与裁切的完整 SOP 见 `references/image-sources.md`。 +- 图片入 HTML 用相对路径 `../assets/xxx.png`;capture 会等图片加载完(每张上限 4s,加载不动就跳过——所以素材必须先落本地,绝不引外链图)。 + +## 版式片段(可抄) + +以下片段都基于 tokens.css,只列 `
` 内部结构;外层 `.stage`、`.brand`、`.slide-num` 照 `_template.html`。 + +**bullets** +```html +
+

三大产品线

+
    +
  • ChatGPT — 对话
  • +
  • GPT 系列 — 模型
  • +
  • Sora — 视频生成
  • +
+
+``` + +**compare** +```html +
+

三年,高中生 → 研究员

+
+
+

2023 · GPT-3.5

会聊天,常出错

+
+
+

今天 · GPT-5.6

可托付研究级任务

+
+
+
+``` + +**data-viz** +```html +
+

九亿人每周在用

+
+
+
9亿
周活跃用户
+
+
+
$25B
年化营收
+
+
+
92%
财富500强渗透
+
+
+
+``` + +**code** +```html +
+

一行调用

+
const answer = await openai.responses.create({
+  model: "gpt-5.6",
+  input: "帮我总结这份报告",
+});
+console.log(answer.output_text);
+
+``` + +**quote** +```html +
+
“我们想打造的是比人类更聪明的工具,而不是替代人类。”
+

— Sam Altman, OpenAI CEO

+
+``` + +**title-hero / closing**:大标题居中 + 一行副题/CTA,单 stage 即可;closing 可加 logo `fx-grow`。 + +布局 CSS(`.layout/.cols/.cards/.num/.cap` 等)每张 slide 内联在 ` + + +
+ A · ${topic || '主题名'} +
+

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 — 语种必须与音色匹配, 且影响语速基准与字幕行宽校验。', + topic, + lang: "zh", + voice: 'Chinese (Mandarin)_Gentleman', + speed: { default: 1.0, first: 0.95, last: 0.95 }, + 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: '' }] }, + ], +}; + +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 3 终态预览 + 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/plan-timings.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/plan-timings.mjs new file mode 100644 index 00000000..dec19768 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/plan-timings.mjs @@ -0,0 +1,102 @@ +#!/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 } 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')); +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; + +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 = path.join(dir, 'audio', s.audio ?? `${s.id}.mp3`); + 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 重估`); + 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(path.join(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⁻¹) → build/timings.json`); +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..ef80d05a --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/prep-image.mjs @@ -0,0 +1,95 @@ +#!/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; }; + +const FFMPEG = requireTool('ffmpeg'); +const FFPROBE = requireTool('ffprobe'); +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]'); 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/tools.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/tools.mjs new file mode 100644 index 00000000..79570aee --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/tools.mjs @@ -0,0 +1,99 @@ +// html2video-for-mcode · ffmpeg/ffprobe 探测: PATH → node_modules → 常见安装位置 +// 找不到时给可诊断的提示, 而不是让下游脚本报莫名其妙的错。 +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const IS_WIN = process.platform === 'win32'; +const EXE = IS_WIN ? '.exe' : ''; + +function onPath(name) { + const r = spawnSync(name + EXE, ['-version'], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && r.stdout ? name + EXE : null; +} + +const SELF_DIR = path.dirname(fileURLToPath(import.meta.url)); // Node 20.11 以下没有 import.meta.dirname, 用 fileURLToPath 保兼容 + +function candidatePaths(name) { + const dirs = []; + // 调用方项目目录(若给了)与技能自身位置的两个上层(仓库根/项目根都可能装了 node_modules) + const anchors = [process.env.KIT_PROJECT_DIR, SELF_DIR, path.resolve(SELF_DIR, '..'), path.resolve(SELF_DIR, '../..')] + .filter(Boolean); + for (const base of anchors) { + dirs.push(path.join(base, 'node_modules', 'ffmpeg-static')); + dirs.push(path.join(base, 'node_modules', 'ffprobe-static', 'bin', process.platform, process.arch)); + } + if (IS_WIN) { + const la = process.env.LOCALAPPDATA; + if (la) { + dirs.push(path.join(la, 'Microsoft', 'WinGet', 'Links')); + dirs.push(path.join(la, 'scoop', 'shims')); + } + dirs.push('C:\\ffmpeg\\bin'); + } + return dirs.map(d => path.join(d, name + EXE)); +} + +export function findTool(name, projectDir) { + try { const p = onPath(name); if (p) return p; } catch { /* not on PATH */ } + if (projectDir) { + const local = [ + path.join(projectDir, 'node_modules', 'ffmpeg-static', name + EXE), + path.join(projectDir, 'node_modules', 'ffprobe-static', 'bin', process.platform, process.arch, name + EXE), + ]; + for (const p of local) if (fs.existsSync(p)) return p; + } + for (const p of candidatePaths(name)) if (fs.existsSync(p)) return p; + return null; +} + +export function requireTool(name, projectDir) { + const p = findTool(name, projectDir); + if (!p) { + console.error([ + `✗ 找不到 ${name}。按顺序排查:`, + ` 1. 系统 PATH 上没有 ${name}`, + ` 2. 项目/仓库 node_modules 里没有 (试试: npm i ffmpeg-static ffprobe-static)`, + ` 3. 常见安装位置 (winget Links / scoop / C:\\ffmpeg\\bin) 也没有`, + ` 最快解法: winget install Gyan.FFmpeg (Windows) · brew install ffmpeg (macOS) · apt install ffmpeg (Linux)`, + ].join('\n')); + process.exit(2); // 2 = 环境错误, 与 1 (业务错误) 区分 + } + return p; +} + +// ── Node 包解析(playwright) ────────────────────────────────────── +// 技能通常装在 ~/.claude/skills/ 或 ~/.openclaw/skills/ —— 不在项目树里, +// 而 Node 的 import 是按"脚本所在位置"向上找 node_modules 的, 于是会出现 +// "项目里明明装了 playwright 却报找不到"。这里按多个锚点依次尝试。 +import { createRequire } from 'node:module'; +import { pathToFileURL, fileURLToPath } from 'node:url'; + +const PKG_ANCHORS = () => { + const list = [process.env.KIT_PROJECT_DIR, process.cwd()]; + // 全局 npm root(全局装的 playwright 也能用) + for (const npm of IS_WIN ? ['npm.cmd', 'npm'] : ['npm']) { + try { + const r = spawnSync(npm, ['root', '-g'], { encoding: 'utf8', windowsHide: true }); + if (r.status === 0 && r.stdout) { list.push(r.stdout.trim()); break; } + } catch { /* ignore */ } + } + return [...new Set(list.filter(Boolean))]; +}; + +export async function loadPackage(name, { projectDir } = {}) { + // CJS 包(如 playwright)动态 import 后命名导出可能拿不到, 统一解包出真实模块对象 + const unwrap = mod => (mod && (mod.chromium || mod.default?.chromium)) ? (mod.chromium ? mod : mod.default) : mod; + // 1) 脚本自身位置(技能装在项目内, 或全局 node_modules 可被解析时) + try { const m = unwrap(await import(name)); if (m?.default !== undefined || m) return m; } catch { /* 继续找 */ } + // 2) 项目目录 / 调用目录 / 全局 npm root, 逐个用 createRequire 解析真实入口 + const anchors = [...new Set([projectDir, ...PKG_ANCHORS()].filter(Boolean))]; + for (const base of anchors) { + try { + const req = createRequire(path.join(base, 'package.json')); + const entry = req.resolve(name); + return unwrap(await import(pathToFileURL(entry).href)); + } catch { /* 试下一个 */ } + } + return null; +} From 9684d4ba9c66742d1332c587062bb29b0573e861 Mon Sep 17 00:00:00 2001 From: Hahaha Date: Thu, 17 Sep 2026 23:24:31 +0800 Subject: [PATCH 02/24] Add Plugin: html2video-for-mcode Add Plugin: html2video-for-mcode Narrated-video pipeline: HTML slides with staged entrance animations, TTS voiceover measured with ffprobe, burned-in subtitles, deterministic frame-stepping capture, ffmpeg assembly, and ASR verification. - 11 Node scripts, no build step; works with mcode connectors or mmx-cli - 13 themes, 17 layout recipes, image framing primitives - Subtitles confined to their own windows (no overlap); 16:9 and 9:16 canvases - Research phase documented: source grading, cross-verification rules, notes template, plus how to fetch official-site / press-release text (SPA rendering, PDF-first numbers) - Discloses dependencies, accounts, network destinations and data handling (bilingual README) - Validation: node scripts/validate.mjs -> OK (exit 0) --- .claude-plugin/plugin.json | 8 + CHANGELOG.md | 46 ++ LICENSE | 213 +------- README.md | 215 ++++---- README.zh-CN.md | 185 +++---- THIRD-PARTY-NOTICES.md | 61 +++ plugin.json | 23 + skills/html2video-for-mcode/SKILL.md | 250 +++++++++ skills/html2video-for-mcode/evals/evals.json | 148 ++++++ .../references/authoring.md | 307 +++++++++++ .../references/image-sources.md | 107 ++++ .../html2video-for-mcode/references/render.md | 130 +++++ .../references/research.md | 114 +++++ .../references/tts-and-timing.md | 159 ++++++ skills/html2video-for-mcode/scripts/asr.mjs | 226 +++++++++ .../scripts/build-video.mjs | 205 ++++++++ .../html2video-for-mcode/scripts/capture.mjs | 250 +++++++++ .../scripts/check-slides.mjs | 117 +++++ .../scripts/check-theme.mjs | 93 ++++ .../scripts/check-timing.mjs | 125 +++++ .../scripts/fetch-official-images.mjs | 132 +++++ .../scripts/init-project.mjs | 480 ++++++++++++++++++ .../scripts/plan-timings.mjs | 102 ++++ .../scripts/prep-image.mjs | 95 ++++ skills/html2video-for-mcode/scripts/tools.mjs | 99 ++++ 25 files changed, 3518 insertions(+), 372 deletions(-) create mode 100644 .claude-plugin/plugin.json create mode 100644 CHANGELOG.md create mode 100644 THIRD-PARTY-NOTICES.md create mode 100644 plugin.json create mode 100644 skills/html2video-for-mcode/SKILL.md create mode 100644 skills/html2video-for-mcode/evals/evals.json create mode 100644 skills/html2video-for-mcode/references/authoring.md create mode 100644 skills/html2video-for-mcode/references/image-sources.md create mode 100644 skills/html2video-for-mcode/references/render.md create mode 100644 skills/html2video-for-mcode/references/research.md create mode 100644 skills/html2video-for-mcode/references/tts-and-timing.md create mode 100644 skills/html2video-for-mcode/scripts/asr.mjs create mode 100644 skills/html2video-for-mcode/scripts/build-video.mjs create mode 100644 skills/html2video-for-mcode/scripts/capture.mjs create mode 100644 skills/html2video-for-mcode/scripts/check-slides.mjs create mode 100644 skills/html2video-for-mcode/scripts/check-theme.mjs create mode 100644 skills/html2video-for-mcode/scripts/check-timing.mjs create mode 100644 skills/html2video-for-mcode/scripts/fetch-official-images.mjs create mode 100644 skills/html2video-for-mcode/scripts/init-project.mjs create mode 100644 skills/html2video-for-mcode/scripts/plan-timings.mjs create mode 100644 skills/html2video-for-mcode/scripts/prep-image.mjs create mode 100644 skills/html2video-for-mcode/scripts/tools.mjs diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 00000000..7298ffd1 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "html2video-for-mcode", + "version": "1.0.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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..732f2ca9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +## 1.0.0 — 2026-09-17 + +首个公开版本。 + +**流水线**:开工对齐(语言/风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 → HTML 分步入场 → 逐帧渲染 → ASR 反向校验,共 7 阶段 6 个确认闸门。 + +**脚本(11 个,纯 Node,无构建步骤)** + +| 脚本 | 作用 | +|---|---| +| `init-project.mjs` | 生成项目骨架(目录 + tokens.css + slide 模板 + script.json 契约) | +| `plan-timings.mjs` | ffprobe 实测每段 TTS → 时长、每层入场时刻、每句开口时刻 | +| `check-timing.mjs` | 静音检测实测每句真实开口,与估算对比并可校准 | +| `check-theme.mjs` | 全部主题的 WCAG 对比度闸门(正文/次级/字幕/强调色) | +| `check-slides.mjs` | 渲染前静态检查(未定义变量/图片缺失/外链/data-stage 未配动画/硬编码颜色) | +| `capture.mjs` | 终态截图或逐帧步进捕获,字幕默认烧录 | +| `build-video.mjs` | 编码 → 拼接 → 音轨对位 → BGM 混音 → 合成 → 自检 + SRT | +| `asr.mjs` | ASR 转写与脚本比对(直调 REST),支持字级时间戳核对开口时刻 | +| `prep-image.mjs` | 配图检查与受限裁切(裁掉面积上限 20%) | +| `fetch-official-images.mjs` | 从官方站点列取并下载候选素材图 | +| `tools.mjs` | ffmpeg/ffprobe 与 Node 包的多锚点探测 | + +**设计系统**:13 套主题、17 种版式、图片框原语(`.img-frame`)、14 个入场/氛围动画、分步入场与错峰容器。 + +**语言**:中文普通话 / 英语 / 粤语,字数与语速基准、字幕行宽、ASR 语言校验随语言切换。 + +**画布**:1920×1080 横屏与 1080×1920 竖版(字幕几何随画布比例自适应)。 + +**字幕**:单语或双语烧录,另出 `out/subs.srt`。 + +**声音**:BGM 垫底(可选,自动循环与淡入淡出)、ASR 反向校验(音色语种、数字与专名一致性)。 + +**资料搜集**:`references/research.md` —— 来源四级分级、四条硬规则(多源交叉验证/一手优先/标注口径日期/不确定不进脚本)、query 设计、多源矛盾处理、notes 模板、各环境搜索工具差异(含 mmx search 10 条上限)。 + +**两套工具链**:mcode 沙箱用 platform connectors;其他 Agent 环境用 mmx-cli(配 TTS)与本仓 `asr.mjs`(配 ASR)。 + +**已修复的静默故障(均加了闸门)** + +- 入场延迟被 `animation` 简写覆盖 → 元素在 0 秒入场(改用变量槽传递延迟) +- 未定义 CSS 变量 + 透明文字填充 → 文字完全隐形(check-slides 静态拦截) +- 图片加载失败 → 只出 broken 图标而无报错(capture 运行期点名) +- 缺少 clauses → 静默出无字幕片(capture 告警) +- 字幕窗口重叠 → 相邻两句同时可见, 看起来像"重影/错字"(淡出改为在窗口内归零, 并加窗口自检) +- 主题覆盖被忽略、同名选择器只取首个块(check-theme 合并语义修正) diff --git a/LICENSE b/LICENSE index 125be1b8..73c5531e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,192 +1,21 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - Copyright 2026 MCode Plugins contributors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +MIT License + +Copyright (c) 2026 Wzdhehe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 37e4c6cb..8bd94f36 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,154 @@ -

- MiniMax Code Plugins — one folder, one pull request, a new agent superpower -

- -

- 简体中文 · - Contribute · - Plugin contract · - Security -

- -

- Build status - Agent Plugins 1.0 - Apache-2.0 license - Pull requests welcome -

- -## One folder is the release - -MiniMax Code Plugins is the community home for Agent Plugins that run in MiniMax Code. Put a -portable Plugin under `plugins//`, open a pull request, and let CI check -the package users will actually install. - -```text -fork → create → build → check → pull request → discover -``` +**English** | [中文](README.zh-CN.md) -No second repository. No catalog JSON. No commit pin to copy. Your Plugin source, docs, review, and -history live together. +# HTML 2 Video for mcode -## Ship your first Plugin +Turn a topic, an outline, or a finished script into a **narrated MP4**: HTML slides with staged +entrance animations, a TTS voiceover, burned-in subtitles, and an ASR pass that verifies the +voiceover actually says what the script says. -```bash -git clone https://github.com//MiniMax-Code-Plugins.git -cd MiniMax-Code-Plugins -npm install -npm run create -- /my-first-plugin -``` +Built for MiniMax Code (mcode), and runnable in any agent host through `mmx-cli`. -The scaffold gives you a Skill-first Plugin: +## What the user gets -```text -plugins//my-first-plugin/ -├── plugin.json -├── README.md -├── LICENSE -└── skills/ - └── my-first-plugin/ - └── SKILL.md -``` +Ask in plain language, get a publishable video: -Replace every `TODO`, then run: +> 帮我把这份大纲做成一条 60 秒的中文口播视频:三张关键数字、结尾一句行动号召,用深色科技主题,加中文字幕。 -```bash -npm run check +Result: + +``` +my-video/ +├── slides/ 8 HTML slides + tokens.css (13 themes, 17 layout recipes) +├── audio/ 8 TTS clips +├── build/timings.json measured durations + per-layer entrance times +├── preview/*.png terminal-state frames +└── out/ + ├── final.mp4 ★ the deliverable (1920×1080 or 1080×1920, H.264 + AAC) + ├── subs.srt subtitles for platform upload + └── slide-*.mp4 per-slide segments ``` -If it passes, open one pull request for that Plugin. Start with -[`CONTRIBUTING.md`](CONTRIBUTING.md) when you want the full review checklist. +## Pipeline -## What can a Plugin add? +One Skill drives an 11-script pipeline (`skills/html2video-for-mcode/scripts/`): -### Skills +| Stage | What happens | +|---|---| +| 1. Kickoff alignment | Ask about language (Chinese / English / Cantonese), style & brand color, subtitles (none / single / bilingual), canvas (16:9 or 9:16), duration, voice, asset boundaries | +| 2. Research | Fact-check the topic and record sources before writing | +| 3. Script | Per-slide narration split into clauses; every slide must have a title layer and a detail layer | +| 4. TTS | Voice synthesis (mcode connector, or `mmx-cli` elsewhere), then measure every clip with ffprobe | +| 5. Timing | Derive each slide's duration and each animation's entrance time from the **measured** audio — nothing is hand-written | +| 6. Assets | Official sources first, image framing primitives, compliance manifest | +| 7. HTML | Staged entrance animations bound to the measured timings; layout recipes for 17 slide types | +| 8. Render | Deterministic frame-stepping capture (animations land in the video), ffmpeg assembly with subtitles, optional background music | +| 9. Verify | ASR transcription compared against the script; contrast, theme, and static-slide gates | -Package reusable instructions, workflows, and domain knowledge. Skills are the fastest path from a -good prompt pattern to a capability anyone can install. +## Design decisions worth knowing -### MCP servers +- **Nothing about timing is hand-written.** Every slide's duration and every animation's entrance + time comes from the measured TTS audio, so "the voiceover finished but the picture is still + waiting" cannot happen by construction. +- **Every slide has a title layer and a detail layer** on separate animation stages, so a slide is + never just a big title with nothing to look at. +- **Animations land in the video.** Capture steps frames deterministically instead of + screen-recording, so entrance animations are actually rendered rather than frozen. +- **Rendering is gated.** A static check refuses to render slides with undefined CSS variables, + missing images, external resources, or entrance animations without an animation class — the + failure modes that otherwise ship a video that looks broken while every script reports success. -Connect MiniMax Code to local tools or remote services with `stdio`, `streamable-http`, or `sse`. -Dependencies, accounts, network destinations, and data handling must be visible before install. +## Install -### Both +**As a plugin (MiniMax Code):** add `plugins/Wzdhehe/html2video-for-mcode` from this repository, or +install it from the community catalog once merged. -Use a Skill to teach the workflow and MCP to provide the tools. The portable package stays small: +**As a standalone skill (any AgentSkills host):** -```text -plugin-root/ -├── plugin.json -├── mcp.json # optional -└── skills/ # optional +```bash +cp -r html2video-for-mcode ~/.claude/skills/ # or ~/.openclaw/skills/ +# project-level +cp -r html2video-for-mcode /.claude/skills/ +# or from GitHub +npx skills add Wzdhehe/html2video-for-mcode ``` -This repository is for **Agent capabilities**. TUI Extensions are a separate system and are not -loaded from this package format. +Then install the two dependencies **in your video project** (not in the skill directory): + +```bash +cd +npm i playwright && npx playwright install chromium +# ffmpeg: winget install Gyan.FFmpeg / brew install ffmpeg / apt install ffmpeg +# or: npm i ffmpeg-static ffprobe-static +``` + +## Requirements + +- **Node.js 18+** (plain ESM, no build step). +- **ffmpeg / ffprobe** on `PATH`, or `ffmpeg-static` / `ffprobe-static` in the project. Scripts + probe `PATH → project node_modules → skill parents → common install locations`. +- **Playwright Chromium** for capture. Install it in the video project — scripts resolve it from + the project directory, the working directory, or the global npm root. +- **Voice synthesis**, one of: mcode platform connectors, `mmx-cli` + (`npm i -g mmx-cli && mmx auth login --api-key sk-...`), or your own TTS written to + `audio/.mp3`. +- Optional: `MINIMAX_API_KEY` for `scripts/asr.mjs`, which transcribes the voiceover and compares + it against the script (numbers, proper nouns, and spoken language). + +## Quick start + +```bash +node /scripts/init-project.mjs ./my-video --topic "My topic" +# fill research/notes.md and script.json (clauses = one line of narration each) +# synthesize audio/01.mp3 … audio/08.mp3 +node /scripts/plan-timings.mjs ./my-video # measure audio → timings.json +node /scripts/check-slides.mjs ./my-video # static gate before rendering +node /scripts/capture.mjs ./my-video --mode motion +node /scripts/build-video.mjs ./my-video --asr +``` + +`SKILL.md` carries the full workflow (7 phases, 6 confirmation gates); `references/` holds the +authoring rules, asset-sourcing SOP, TTS/timing notes, and rendering internals. + +## Supported platforms -## The gate is simple +Windows, macOS, and Linux. All scripts are Node ESM and shell-agnostic. On Windows prefer Git +Bash / WSL over PowerShell (complex argument lists with non-ASCII paths can misbehave there); +ffmpeg and Chromium paths are auto-detected. -A contribution must: +## Network access -- live at `plugins//`; -- include `plugin.json`, `README.md`, and `LICENSE`; -- expose at least one valid Skill or MCP server; -- document a copyable example, requirements, network access, and data use; -- contain no secrets, private endpoints, hidden telemetry, native binaries, or symlinks; -- pass `npm run check` and human review. +Nothing is contacted unless you invoke the step that needs it: -Passing review means the Plugin is available as community software. It is not a MiniMax endorsement -or a complete security audit. Read the source and requested capabilities before installing. +- `scripts/asr.mjs` — HTTPS `POST` to `https://api.minimaxi.com/v1/speech_to_text` + (or `https://api.minimax.io` when `MINIMAX_REGION=global`). Only when you run it. +- `scripts/fetch-official-images.mjs` — opens the URL **you** pass (an official site or a local + `file://` page) to list and download candidate images. +- Voice synthesis happens through mcode connectors or `mmx-cli`, which contact MiniMax. +- Everything else (timing, static checks, capture, encoding, theme validation) is fully offline. -## Explore the project +No telemetry, no analytics, no hidden endpoints, no installers, no native binaries. -- [`plugins/`](plugins/) — community Plugin source -- [`examples/hello-mcode`](examples/hello-mcode/) — smallest Skill Plugin -- [`examples/hello-mcode-mcp`](examples/hello-mcode-mcp/) — dependency-free stdio MCP -- [`docs/plugin-compatibility.md`](docs/plugin-compatibility.md) — exact supported contract -- [`docs/security-model.md`](docs/security-model.md) — validation and trust model -- [`docs/architecture.md`](docs/architecture.md) — hosted contribution architecture -- [`GOVERNANCE.md`](GOVERNANCE.md) — decisions and maintainer responsibilities +## Data use -## Community preview +- The narration text you write is sent to the speech service you chose; the produced audio is sent + to the ASR service only if you run `scripts/asr.mjs`. +- Assets you fetch are downloaded into your project's `assets/` directory and must be recorded in + `assets/MANIFEST.md` with source and license. +- Everything else stays on disk inside your project directory. The Skill writes only inside the + project directory you pass to it. +- No credentials are stored or embedded: the ASR script reads a key from `MINIMAX_API_KEY` or + `--api-key` at runtime and never writes it anywhere. -The contract is intentionally narrow while MiniMax Code's public Plugin surface stabilizes. Hooks, -custom Agents, Commands, LSP, Apps, generic OAuth, and TUI Extensions are not advertised as current -Agent Plugin capabilities. +## Troubleshooting -Bring one useful capability. Make the example undeniable. Ship it in one pull request. +`SKILL.md` ends with a symptom → cause → fix table covering the failures this pipeline has actually +hit: silent tails after the voiceover, slides with nothing on them, elements entering at 0 seconds, +invisible text from undefined CSS variables, broken images, subtitles washed out on dark themes, +wrong voice language, and mismatched concat durations. ## License -Repository tooling and documentation use Apache-2.0. Every hosted Plugin includes and declares its -own open-source license. +MIT — see `LICENSE`. Portions of the design system (10 themes, image-frame primitives, several +entrance animations) are adapted from +[html-ppt-skill](https://github.com/lewislulu/html-ppt-skill) (MIT, Copyright (c) 2026 lewis); the +full notice and the upstream MIT text are in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index b77b9a4a..8e8cd0ad 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,118 +1,127 @@ -

- MiniMax Code Plugins:一个目录、一个 PR,给 Agent 一项新能力 -

- -

- English · - 贡献指南 · - Plugin 契约 · - 安全 -

- -

- 构建状态 - Agent Plugins 1.0 - Apache-2.0 License - 欢迎提交 PR -

- -## 一个目录,就是一个发布单元 - -MiniMax Code Plugins 是 MiniMax Code Agent Plugin 的社区入口。把 Plugin 放进 -`plugins//`,提交一个 PR,CI 会直接检查用户最终安装的那份代码。 - -```text -Fork → 创建 → 开发 → 校验 → Pull Request → 被发现 -``` +[English](README.md) | **中文** -不用另建仓库,不用写 Catalog JSON,也不用手抄 commit SHA。源码、文档、Review 和修改历史都在 -一个地方。 +# HTML 2 Video for mcode -## 30 秒创建第一个 Plugin +把一句主题、一份大纲或一篇定稿脚本,变成**带口播的成片 MP4**:HTML 幻灯片(分步入场动画)+ TTS 配音 + 烧录字幕,并用 ASR 反向校验"配音是否真的念的是脚本里的话"。 -```bash -git clone https://github.com/<你的用户名>/MiniMax-Code-Plugins.git -cd MiniMax-Code-Plugins -npm install -npm run create -- <你的用户名>/my-first-plugin -``` +为 MiniMax Code(mcode)定制,也能在其他 Agent 环境里通过 `mmx-cli` 运行。 -脚手架会生成一个 Skill-first Plugin: +## 用户能得到什么 -```text -plugins/<你的用户名>/my-first-plugin/ -├── plugin.json -├── README.md -├── LICENSE -└── skills/ - └── my-first-plugin/ - └── SKILL.md -``` +用大白话提需求,拿到一条可直接发布的视频: -替换全部 `TODO`,然后运行: +> 帮我把这份大纲做成一条 60 秒的中文口播视频:三张关键数字、结尾一句行动号召,用深色科技主题,加中文字幕。 -```bash -npm run check +产物: + +``` +my-video/ +├── slides/ 8 张 HTML + tokens.css(13 套主题、17 种版式配方) +├── audio/ 8 段 TTS 音频 +├── build/timings.json 实测时长 + 每个视觉层的入场时刻 +├── preview/*.png 终态预览帧 +└── out/ + ├── final.mp4 ★ 交付成片(1920×1080 或 1080×1920,H.264 + AAC) + ├── subs.srt 供平台上传的字幕 + └── slide-*.mp4 逐张分段 ``` -通过后,为这个 Plugin 提交一个 PR。完整 Review 要求见 -[`CONTRIBUTING.md`](CONTRIBUTING.md)。 +## 流水线 -## Plugin 能给 Agent 加什么? +一个技能驱动 11 个脚本(`skills/html2video-for-mcode/scripts/`): -### Skills +| 阶段 | 做什么 | +|---|---| +| 1. 开工对齐 | 询问语言(中文/英语/粤语)、风格与品牌色、字幕(不要/单语/双语)、画布(16:9 或 9:16)、时长、音色、素材边界 | +| 2. 信息搜集 | 事实性题材先核查并记录来源,再动笔 | +| 3. 脚本 | 每张 slide 的口播拆成逐句 clauses;每张必须有"标题层 + 展开层" | +| 4. TTS | 配音合成(mcode connector,其他环境用 `mmx-cli`),随后用 ffprobe 实测每段时长 | +| 5. 对时 | 每张时长、每层入场时刻**全部由实测音频推出**,不手写任何秒数 | +| 6. 配图 | 官方素材优先、图片框原语、合规清单登记 | +| 7. HTML | 分步入场动画绑定实测时刻;17 种版式配方 | +| 8. 渲染 | 确定性逐帧步进捕获(动画真正进视频)、ffmpeg 合成与字幕、可选 BGM | +| 9. 校验 | ASR 转写与脚本比对;对比度、主题、渲染前静态闸门 | -把可复用的指令、工作流和领域知识打包。一个验证过的提示词方法,可以直接变成任何人都能安装的能力。 +## 几个关键设计 -### MCP Servers +- **时序不靠手写**:每张时长与每个动画的入场时刻都来自实测音频,所以"配音念完了画面还在等"在结构上就不可能发生。 +- **每张都有标题层与展开层**,分属不同动画 stage,不会出现"只有一行大字"的页面。 +- **动画真的进视频**:捕获用逐帧步进而不是录屏,入场动画是渲染出来的,不是冻结在终态。 +- **渲染有闸门**:静态检查会拒绝未定义 CSS 变量、图片缺失、外链资源、入场动画缺动画类的页面 —— 这些正是"视频看着坏了但每个脚本都报成功"的元凶。 -通过 `stdio`、`streamable-http` 或 `sse` 连接本地工具和远程服务。依赖、账号、网络目标和数据处理必须 -在安装前说清楚。 +## 安装 -### Skill + MCP +**作为插件(MiniMax Code)**:使用本仓库的 `plugins/Wzdhehe/html2video-for-mcode`,合并后也可从社区目录安装。 -Skill 教会 Agent 怎么做,MCP 给它真正的工具。可移植包结构保持简单: +**作为独立技能(任意 AgentSkills 宿主)**: -```text -plugin-root/ -├── plugin.json -├── mcp.json # 可选 -└── skills/ # 可选 +```bash +cp -r html2video-for-mcode ~/.claude/skills/ # 或 ~/.openclaw/skills/ +# 项目级安装 +cp -r html2video-for-mcode <你的项目>/.claude/skills/ +# 或直接从 GitHub 安装 +npx skills add Wzdhehe/html2video-for-mcode ``` -这个仓库只承接 **Agent 能力**。TUI Extension 是另一套独立扩展体系,不使用这里的包格式和加载流程。 +然后把两个依赖装到**你的视频项目里**(不是技能目录里): + +```bash +cd <你的视频项目> +npm i playwright && npx playwright install chromium +# ffmpeg: winget install Gyan.FFmpeg / brew install ffmpeg / apt install ffmpeg +# 或: npm i ffmpeg-static ffprobe-static +``` + +## 依赖要求 + +- **Node.js 18+**(纯 ESM,无构建步骤)。 +- **ffmpeg / ffprobe**:在 `PATH` 上,或项目里装 `ffmpeg-static` / `ffprobe-static`。脚本按 `PATH → 项目 node_modules → 技能上两级 → 常见安装位置` 探测。 +- **Playwright Chromium**:截图用。装在视频项目里即可 —— 脚本会从项目目录、工作目录、npm 全局逐个解析。 +- **配音**:三选一 —— mcode 平台 connector、`mmx-cli`(`npm i -g mmx-cli && mmx auth login --api-key sk-...`)、或你自己的 TTS(把音频写到 `audio/.mp3`)。 +- 可选:`MINIMAX_API_KEY`,用于 `scripts/asr.mjs` 把配音转写回来与脚本比对(数字、专名、语种)。 + +## 快速开始 + +```bash +node /scripts/init-project.mjs ./my-video --topic "我的主题" +# 填 research/notes.md 与 script.json(clauses = 每句口播一行) +# 合成 audio/01.mp3 … audio/08.mp3 +node /scripts/plan-timings.mjs ./my-video # 实测音频 → timings.json +node /scripts/check-slides.mjs ./my-video # 渲染前静态闸门 +node /scripts/capture.mjs ./my-video --mode motion +node /scripts/build-video.mjs ./my-video --asr +``` + +工作流全文(7 阶段、6 个确认闸门)在 `SKILL.md`;`references/` 放着编写规范、配图 SOP、TTS/对时说明与渲染内幕。 + +## 支持平台 -## 门槛也很简单 +Windows / macOS / Linux。脚本全部是 Node ESM,不依赖特定 shell。Windows 上建议用 Git Bash 或 WSL 而非 PowerShell(非 ASCII 路径 + 复杂参数组合容易出问题);ffmpeg 与 Chromium 路径自动探测。 -一个贡献必须: +## 网络访问 -- 位于 `plugins//`; -- 包含 `plugin.json`、`README.md` 和 `LICENSE`; -- 至少提供一个有效的 Skill 或 MCP Server; -- 写清示例、依赖、网络访问和数据用途; -- 不包含密钥、私有地址、隐藏遥测、原生二进制或 symlink; -- 通过 `npm run check` 和人工 Review。 +**只有你主动执行的那一步才会联网**: -通过 Review 代表它可以作为社区软件被发现,不代表 MiniMax 背书或已经完成完整安全审计。安装前仍需阅读 -源码和能力声明。 +- `scripts/asr.mjs` —— HTTPS `POST` 到 `https://api.minimaxi.com/v1/speech_to_text`(海外套餐设 `MINIMAX_REGION=global` 时走 `https://api.minimax.io`)。仅在你运行时。 +- `scripts/fetch-official-images.mjs` —— 打开**你传入的**网址(官方网站或本地 `file://` 页面)以列出并下载候选配图。 +- 配音合成经由 mcode connector 或 `mmx-cli`,它们会访问 MiniMax。 +- 其余全部离线:对时、静态检查、截图、编码、主题对比度校验。 -## 逛逛这个仓库 +无遥测、无埋点、无隐藏端点、无安装器、无原生二进制。 -- [`plugins/`](plugins/):社区 Plugin 源码 -- [`examples/hello-mcode`](examples/hello-mcode/):最小 Skill Plugin -- [`examples/hello-mcode-mcp`](examples/hello-mcode-mcp/):零依赖 stdio MCP -- [`docs/plugin-compatibility.md`](docs/plugin-compatibility.md):当前支持的精确契约 -- [`docs/security-model.md`](docs/security-model.md):校验与信任模型 -- [`docs/architecture.md`](docs/architecture.md):中央托管架构 -- [`GOVERNANCE.md`](GOVERNANCE.md):决策与维护者职责 +## 数据使用 -## Community Preview +- 你写的口播文本会送到你选择的语音服务;产物音频只在你运行 `scripts/asr.mjs` 时才送到 ASR 服务。 +- 抓取的素材下载到项目的 `assets/` 目录,并需在 `assets/MANIFEST.md` 登记来源与许可。 +- 其余数据全部留在你传入的项目目录内 —— 技能只往这个目录里写东西。 +- **不存储、不内嵌任何凭据**:ASR 脚本运行时从 `MINIMAX_API_KEY` 或 `--api-key` 读取,且从不写出到任何文件。 -MiniMax Code 的公开 Plugin 能力仍在稳定中,所以首版契约刻意保持克制。Hooks、自定义 Agent、Commands、 -LSP、Apps、通用 OAuth 和 TUI Extension 暂不作为当前 Agent Plugin 能力宣传。 +## 排错 -带来一个真的有用的能力,给出一个无法误解的示例,然后用一个 PR 把它发布出来。 +`SKILL.md` 末尾有一张"症状 → 原因 → 处置"表,覆盖这条流水线真实踩过的坑:配音念完画面还在等、页面只有标题、元素在 0 秒就入场、未定义 CSS 变量导致文字隐形、图片 broken、深色主题字幕糊底、音色语种不对、拼接后时长不符。 -## License +## 许可 -仓库工具和文档使用 Apache-2.0。每个托管 Plugin 都必须包含并声明自己的开源 License。 +MIT —— 见 `LICENSE`。设计系统的一部分(10 套主题、图片框原语、若干入场动画)改编自 +[html-ppt-skill](https://github.com/lewislulu/html-ppt-skill)(MIT,Copyright (c) 2026 lewis); +完整声明与上游 MIT 原文见 [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md)。 diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..db353ffd --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,61 @@ +# 第三方组件与许可声明 + +本技能包含改编自第三方开源项目的内容。按 MIT 许可要求,原版权声明与许可原文如下。 + +--- + +## html-ppt-skill + +- **来源**:https://github.com/lewislulu/html-ppt-skill +- **版权**:Copyright (c) 2026 lewis +- **许可**:MIT +- **引入日期**:2026-09-17 +- **改编范围**(均经改写以适配本技能的单页静态渲染与音画同步体系): + - `scripts/init-project.mjs` 生成的 `slides/tokens.css`: + - 10 套命名主题(`minimal-white` / `swiss-grid` / `corporate-clean` / `editorial-serif` / + `magazine-bold` / `tokyo-night` / `catppuccin-mocha` / `nord` / `xiaohongshu-white` / `soft-pastel`) + 的配色令牌 —— 变量名已映射为本技能的命名(`--surface`→`--panel`、`--text-1/2/3`→`--fg/--muted/--fg-3`、 + `--border`→`--line` 等),外链字体(Playfair Display / Space Grotesk 等)已替换为本地字体栈 + - 图片框原语 `.img-frame` / `.contain` / `.fill` / `.img-scrim` / `.img-cap` / `.img-tag` 及其 + `--img-ratio` / `--img-pos` 变量 + - 部分入场动画的 keyframes(`fx-blur` `fx-rise` `fx-pop` `fx-spotlight` `fx-ripple` `fx-glitch` + `fx-shimmer` `fx-kenburns` `fx-stagger`)—— 触发机制已由"切页时 JS 重新触发"改为 + "`animation-delay: var(--tN)` 绝对时间",以配合逐帧步进渲染 + - 排版角色与容器类 `.kicker` / `.eyebrow` / `.lede` / `.card`(含三变体)/ `.pill` / `.divider-accent` + - `references/authoring.md` 的部分版式骨架改写自其 `templates/single-page/` 下的同名版本 + - `references/image-sources.md` 的图片框使用规范参照其 `.img-frame` 设计意图 + +**未引入的部分**(与其设计不兼容或有额外依赖):`runtime.js`(键盘导航/演讲者模式/概览)、 +`assets/animations/fx/` 下的 20 个 canvas 特效(rAF 驱动,无法被逐帧 seek)、 +`fonts.css` 与全部 Google Fonts 外链、Chart.js / highlight.js CDN 依赖、`@media print` 分页逻辑。 + +### MIT 许可原文 + +``` +MIT License + +Copyright (c) 2026 lewis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +--- + +**商用说明**:MIT 许可允许商业使用、修改、再分发与再许可,唯一义务是保留上述版权与许可声明。 +本技能生成的最终产物(视频、字幕、封面等)属于使用者自己的创作,不继承上述许可义务。 diff --git a/plugin.json b/plugin.json new file mode 100644 index 00000000..7af3f7f6 --- /dev/null +++ b/plugin.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "html2video-for-mcode", + "version": "1.0.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.", + "author": { + "name": "Wzdhehe", + "url": "https://github.com/Wzdhehe" + }, + "homepage": "https://github.com/Wzdhehe/html2video-for-mcode", + "repository": "https://github.com/MiniMax-AI/MiniMax-Code-Plugins", + "license": "MIT", + "keywords": [ + "video", + "html-to-video", + "slides", + "tts", + "voiceover", + "subtitles", + "ffmpeg", + "playwright" + ] +} diff --git a/skills/html2video-for-mcode/SKILL.md b/skills/html2video-for-mcode/SKILL.md new file mode 100644 index 00000000..988e0533 --- /dev/null +++ b/skills/html2video-for-mcode/SKILL.md @@ -0,0 +1,250 @@ +--- +name: html2video-for-mcode +description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻灯片 + TTS + ffmpeg 渲染),为 mcode 环境定制、也可在其他 Agent 环境用 mmx-cli 跑通。当用户想把内容做成视频、html 转 mp4、幻灯片口播视频、slides video、narrated video、一分钟介绍视频、抖音/视频号竖版视频时触发;也当症状出现时触发——TTS 念完留白过长、画面只有标题没有细节展开、部分元素 0 秒就入场、入场动画没渲染进视频、音画不同步、图片主体被裁到画面外、字幕糊在深色背景上、拼接后总时长不对、字体闪烁或方框。覆盖全流程:开工对齐(风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 SOP → HTML 分步入场 → 逐帧渲染 → ASR 反向校验。工具分两套:mcode 沙箱用 connector__matrix__*(TTS/ASR/音乐),其他环境用 mmx-cli(注意:mmx-cli 无 ASR 与音乐生成,见文末环境对照表)。 +--- + +# HTML 2 Video for mcode:脚本 → 口播成片 + +把一个主题/大纲变成一条可发布的 MP4(默认 1920×1080,可切 1080×1920 竖版):HTML 幻灯片(分步入场动画)+ 中文 TTS 口播 + ffmpeg 组装 + ASR 反向校验。 + +## 四条铁律(违反任何一条,产出必然返工) + +1. **所有时长只来自 ffprobe 实测,永远不手写。** 每张 slide 的时长 = 该段 TTS 实测时长 + 尾部留白(默认 0.8s)。不估、不凑整、不写死。 +2. **TTS 先于 HTML。** 先出音频、实测时长、算好每个视觉层的入场时刻,再写 HTML。动画延迟全部通过 CSS 变量 `--t1/--t2/--t3` 注入,HTML 里不写死秒数。这是消灭"念完留白过长"和"音画不同步"的根本手段。 +3. **每个 Gate 等用户确认,不跳步。** Gate 清单见下文工作流。 +4. **开工先对齐,不要闷头开跑。** 第一次响应就把下面这批问题一次问清(用户没答的项可用默认值,但**必须先问**),答案即后续所有 Gate 的验收基线: + +| 要问的 | 选项 / 默认 | +|---|---| +| 主题与受众 | 题材 + 给谁看(决定版式与信息密度) | +| **语言(必问)** | 口播说什么语言:**中文普通话 `zh`**(默认)/ **英语 `en`** / 粤语 `yue` / 其他 BCP-47。这项决定 4 件事:① 口播稿用哪种语言写;② **音色必须匹配语种**(中文用 `Chinese (Mandarin)_*`、英语用 `English_*`,错配会出怪腔调,写完必须 ASR 验语种);③ 字数/语速基准(中文字/秒 vs 英文词/秒,plan-timings 自动切换);④ ASR 识别语言头(zh 强制普通话,能识破粤语) | +| **风格与配色** | 先按受众给 2–3 个候选主题让用户挑(商务 `minimal-white`/`swiss-grid`/`corporate-clean`;编辑杂志 `editorial-serif`/`magazine-bold`;科技深色 `tokyo-night`/`catppuccin-mocha`/`nord`;消费生活 `xiaohongshu-white`/`soft-pastel`)。**再问一句配色偏好**:直接用主题自带主色,还是有品牌色要指定(给了就按 authoring.md 的"自定义主色"覆写 `--accent` 并跑 check-theme 验对比度) | +| **字幕** | 不要字幕 / 单语(**与口播同语言**,默认)/ **双语**(主行=口播语言,次行 `text2` = 另一语言,如中文口播配英文字幕)——决定要不要写 text2、要不要 `--no-subs` | +| **画布与平台** | 横屏 1920×1080(默认,适合 B站/官网)或竖版 1080×1920(抖音/视频号/小红书);竖版要换堆叠版式 | +| 时长 | 默认 ~60s(6–10s/张 × 8 张);短视频平台可压到 30s | +| 音色 | 温润男声 / 干练女声 / 其他;给出 3 个候选试听(见 tts-and-timing.md) | +| 素材边界 | 允许网络取官方图 / 只用官方素材 / 纯排版不出图 | + +## 目录与工具 + +技能自带四个脚本(直接以本技能目录为路径调用,项目目录作为参数,无需复制): + +| 脚本 | 作用 | +|---|---| +| `scripts/init-project.mjs <项目目录>` | 生成项目骨架:目录 + tokens.css + slide 模板 + script.json 契约 | +| `scripts/plan-timings.mjs <项目目录>` | ffprobe 实测每段 TTS → 每张时长、各 stage 入场时刻、**每句 clauses 时刻** → `build/timings.json` | +| `scripts/check-timing.mjs <项目目录> [--calibrate]` | 静音检测实测每句真实开口, 与估算对比;`--calibrate` 按实测校准 timings 后重渲染 | +| `scripts/check-theme.mjs <项目目录>` | 校验全部主题的 WCAG 对比度(正文/次级/字幕/accent-ink), 不达标退出码 1;新增主题必须过闸 | +| `scripts/prep-image.mjs --check <图...>` / `--crop [--ratio 16:9] [--anchor ...]` | 配图 SOP 的执行辅助:查尺寸与裁切风险;按锚点裁切(强制"裁掉 ≤20%、不放大补边") | +| `scripts/capture.mjs <项目目录> [--mode still\|motion] [--no-subs]` | Playwright 截图。still=终态单帧;motion=逐帧步进入场动画。**字幕默认烧录**(内容取自 clauses),`--no-subs` 关闭 | +| `scripts/build-video.mjs <项目目录> [--asr]` | 编码每张 → 拼接 → 音轨对位 → 合成 → 自检 + 出 `out/subs.srt`;`--asr` **按句**切分音频 + 校验清单 | + +环境要求:Node 18+(脚本用 fileURLToPath 保兼容, 不依赖 Node 20.11 的 import.meta.dirname)、`npm i playwright && npx playwright install chromium`(项目目录内)。ffmpeg/ffprobe 自动探测:PATH → node_modules(ffmpeg-static/ffprobe-static)→ 常见安装位置,找不到会给逐条诊断而不是莫名报错。 + +## 安装到其他 Agent 环境 + +本技能就是「一个文件夹 + 根目录 SKILL.md」的标准形态(frontmatter 的 `name` / `description` 已按规范写好),放到对应技能目录即可被识别: + +```bash +# Claude Code(个人级)/ OpenClaw / 其他兼容 AgentSkills 的工具 +cp -r html2video-for-mcode ~/.claude/skills/ # 或 ~/.openclaw/skills/ +# 项目级安装 +cp -r html2video-for-mcode <你的项目>/.claude/skills/ +# 或者把它推到一个 git 仓库后走 skills CLI +npx skills add --skill html2video-for-mcode +``` + +装完只需再补两件**依赖**(装在你的视频项目里,不是技能目录里): + +```bash +cd <你的视频项目目录> +npm i playwright && npx playwright install chromium # 截图用 +# ffmpeg 任选其一: winget install Gyan.FFmpeg / brew install ffmpeg / apt install ffmpeg +# 或: npm i ffmpeg-static ffprobe-static +``` + +**为什么技能装在别处也能跑**:`scripts/tools.mjs` 按「技能自身位置 → 项目目录 → 调用时的工作目录 → npm 全局」逐个锚点解析 `playwright`;ffmpeg/ffprobe 也是四级探测(PATH → 项目 node_modules → 技能上两级 → 常见安装位置)。所以"技能在 `~/.claude/skills/`、依赖装在项目里"是受支持的用法(已实测:技能放到项目树之外仍能出图)。 + +## 运行环境:两套工具链(同一套脚本,只换工具源) + +脚本层(截图 / 渲染 / 合成 / 校验 / 配图)完全环境无关;**只有 TTS、音乐、ASR 三件事依赖平台能力**。 + +| 能力 | mcode 沙箱(首选) | 其他 Agent 环境(Claude Code / OpenClaw / Cursor 等) | +|---|---|---| +| TTS 合成 | `mcode-tools connector call connector__matrix__batch_text_to_audio --args '{...}'`(≤10 条/批,主用);单条试音用 `connector__matrix__synthesize_speech` | `mmx speech synthesize --text "第一句口播。" --voice --speed 1.0 --out audio/01.mp3`;音色列表 `mmx speech voices` | +| 结果落盘 | `get_asset_url ` → 下载到 `audio/.mp3` | `--out` 直接写盘 | +| BGM 音乐 | `connector__matrix__batch_text_to_music`(≤5 条/批) | ⚠ **mmx-cli 无音乐生成** → 让用户提供音乐文件(确认授权后登记 MANIFEST),或跳过 BGM | +| ASR 反向校验 | `mcode-tools upload_temp_url` + `connector__matrix__listen_audio` | **`node scripts/asr.mjs <项目目录>`** —— 用同一把 API Key 直调 REST(`/v1/speech_to_text`),不依赖 mcode、也不用装 whisper;**会自动与 checklist 的预期文本比对并回填,数字/繁体字(粤语)不符直接判 ✗**。想用字级时间戳实测句开口:`--verify-timing` | +| 素材配图 | 内置浏览器 inspect 官网 DOM(首选)/ 官方 brand kit | 同上;抽象配图可用 `mmx image generate --prompt "..." --aspect-ratio 16:9 --n 3`(**仅限抽象概念图,禁止生成 logo / 截图 / 真人头像**),再按 `image-sources.md` 登记 | +| 调研 | `web_search` / `web_fetch` | `mmx search "关键词"` / `mmx text chat` | + +**mmx-cli 首次配置**(非 mcode 环境):`npm install -g mmx-cli` → `mmx auth login --api-key sk-xxx` → `mmx quota` 验证。401 多半是 region 不匹配:`mmx config set --key region --value cn|global`。脚本侧用 `MINIMAX_API_KEY`(必给)与 `MINIMAX_REGION=cn|global`(可选)对齐同一套身份。 + +**纪律不因环境而变**:时长仍由 ffprobe 实测、字幕仍来自 `clauses[]`、音色仍要试听并验语种(走上面的 asr.mjs)、Gate 一个都不跳。 + +## 工作流(7 阶段 · 6 Gate) + +``` +开工对齐(铁律 4, 不设 Gate 但必须先做) +Phase 0 信息搜集 → Gate 0 事实清单 +Phase 1 脚本设计 → Gate 1 逐张口播稿 +Phase 2 TTS + 对时 → Gate 2 试听 + 时长表 +Phase 3 素材收集 → Gate 3 素材清单 + 预览 + 合规确认 +Phase 4 HTML → Gate 4 终态截图 +Phase 5 渲染 + ASR → Gate 5 成片 +Phase 6 交付 +``` + +**顺序是脚手架,不是建议。** 口播稿定了才做 TTS;TTS 时长实测了才动素材和 HTML;素材清单过了 Gate 3 才写进页面;HTML 过了终态截图才渲染。改了口播稿 = 从 Phase 2 重跑(TTS 便宜,重做不贵;带着旧时长硬改才是灾难)。每个 Gate 向用户呈现"验收物清单"里明确的东西,没收到 OK 绝不前进——即使看起来显然,也要确认。 + +### Phase 0 · 信息搜集(条件执行) + +**完整方法见 `references/research.md`**(来源分级、query 设计、矛盾处理、notes 模板)。要点: + +- 判断标准很简单:成片里会出现具体**数字、日期、名称、引语或归属关系** → 必须搜集。通用/抒情/创意题材可跳过,但"看起来像事实"的句子仍要核实。 +- **四条硬规则**:① 关键数字**至少 2 个独立来源**(只有一个就用限定措辞或降级为约数);② **一手优先**(官方公告/财报/技术报告/政府统计),二手转述要回溯原文;③ 标注**口径与日期**(年化还是单季?周活还是月活?币种?);④ **查不到出处或无法判定的,进"不确定项",绝不进口播稿**。 +- **正文怎么取**:先 `web_fetch`;SPA/JS 渲染的官网与投资者关系页常只返回空壳,改用**内置浏览器或 Playwright 渲染后取正文**(最小命令见 references/research.md);招股书/财报是 PDF,以 PDF 原文数字为准。新闻稿与媒体转述冲突时以官方原文为准。 +- 输出 `research/notes.md`:每条含 来源 URL + 口径日期 + 等级 + 第二来源;另列"不确定项"与"不该进脚本的内容"。 +- **Gate 0**:事实清单给用户过 —— 重点让用户确认**数字、名称与口径**。 + +### Phase 1 · 脚本设计(内容量在这里控制) + +在 `script.json` 里逐张填写(契约文件,后续所有脚本都读它): + +```json +{ + "topic": "OpenAI 一分钟", "voice": "Chinese (Mandarin)_Gentleman", + "speed": {"default": 1.0, "first": 0.95, "last": 0.95}, + "fps": 30, "width": 1920, "height": 1080, + "bgm": "assets/bgm.mp3", + "slides": [ + {"id": "03", "layout": "bullets", "html": "03-lines.html", "audio": "03.mp3", + "title": "三大产品线", + "clauses": [ + {"stage": 1, "text": "三大产品线。"}, + {"stage": 2, "text": "ChatGPT 对话,GPT 系列模型,Sora 视频生成。", + "text2": "Chat, models, video generation."} + ]} + ] +} +``` + +可选字段:`clauses[].text2` = 双语字幕第二行(不写则纯中文字幕);顶层 `bgm` = `"assets/bgm.mp3"` 或 `{file, volume:0.12, fadeIn:1.5, fadeOut:2.5}`(写了由 build-video 自动循环+淡入淡出垫底)。 + +**内容量硬规则**(详细版见 `references/authoring.md`): + +- 中文口播 ≈ 4.8 字/秒。每张目标 6–10 秒 → 口播 25–48 字;首尾张 12–20 字。单张硬上限 60 字,超了拆两张。 +- **每张(除首尾)必须同时有"标题层 + 展开层",映射到不同 stage。只有标题、没有展开 = 违规,打回。** 口播也一样:每张至少两句——引入句(配标题层)+ 展开句(配细节层)。 +- `clauses` 的每个元素是一句口播,`stage` 声明"这句开口时,哪个视觉层该出现"。stage 数 ≈ clause 数,一一对应。 +- 版式共 17 种(原 8 + 补充 9:kpi-grid / stat-highlight / table / timeline / roadmap / comparison / flow-diagram / terminal / big-quote),每种画面必含项与字数区间见 `references/authoring.md` 的版式表。 +- 开工对齐时若用户要**双语字幕**,这里就要给每句写 `text2`(同句翻译,不重排语序,≤60 字符)。 + +**Gate 1**:逐张口播稿 + 版式分配给用户过。**未逐张 OK 不进 Phase 2。** + +### Phase 2 · TTS + 实测对时 + +TTS 走哪套工具见上文"运行环境"对照表(mcode 用 `connector__matrix__*`,其他环境用 `mmx speech synthesize`);命令模板与重试纪律见 `references/tts-and-timing.md`。要点:批量 ≤10 条,部分失败 sleep 10–30s 后**只重试失败项**;首尾张 speed 0.95(但 ≤10 字的短句保持 1.0,见该文件实测坑)。产物落 `audio/.mp3`。 + +然后对时: + +```bash +node <技能目录>/scripts/plan-timings.mjs <项目目录> +``` + +它会:ffprobe 每段实测时长 → 每张时长 = 实测 + 尾部留白(默认 0.8s,可在 slide 的 `tail` 字段调:紧凑 0.4 / 舒缓 1.2)→ 每个 stage 的入场时刻 = 该句口播按字数占比估算的开口时刻 − 0.2s(视觉略提前于语音,观感同步)→ 每句的开口时刻/时长写入 `clauses[]`(字幕、ASR 按句切分、对时校准共用)→ 写 `build/timings.json`,并输出警告(语速异常、超 15s、末 stage 离收尾太近、单句超 18 字字幕会换行)。 + +可选但推荐(尤其用户反馈过"音画不同步"时):`node <技能目录>/scripts/check-timing.mjs <项目目录>` 用静音检测实测每句真实开口,输出"估算 vs 实测"对比表;偏差大就 `--calibrate` 校准后删 `build/frames/` 重渲染。注意"体感不同步"也常是设计错位——大数字/主体图必须挂在**提到它的那句**的 stage(强同步原则,见 authoring.md)。 + +**Gate 2**:8 段音频试听 + plan-timings 的时长表给用户过。 + +### Phase 3 · 素材收集(合规在这里把关) + +按 `references/image-sources.md` 的配图 SOP 执行(核心原则:**主体不全/半截/比例差的图,先重搜全貌图,别硬裁硬用**),要点: + +1. 列需求清单(哪几张要图、要什么)。 +2. **优先级:官方渠道(brand kit / simple-icons / 官方文档截图)→ 内置浏览器或 Playwright 打开官网 inspect DOM 取官方资源(实测成功率最高)→ Wikipedia CC → 纯排版降级(数字卡 + 大字 + 引言,不用图)。** 旧 image-downloader(Bing)默认不用:中文冷门题材实测 5/5 返回无关图。 +3. 搜图加正向词(全景/全貌/正面/远景),下载前用缩略图筛:主体居中、比例接近、无水印无无关 logo、≥1200px;一张不合适就换,不凑合。 +4. 拿到图先跑 `node <技能>/scripts/prep-image.mjs --check <图>` 看尺寸与裁切风险;主体贴边就换图。 +5. 每张素材查合规:无水印、商标仅限合理引用语境、照片须可授权来源、截图引用官方文档并注明。 +6. 落盘 `assets/`,每个素材一行登记 `assets/MANIFEST.md`(内容/来源/许可)。 +7. **禁止凭空生成 logo、截图、头像、二维码;禁止带水印图直接入素材;禁止裸放 ``(必须套 `.img-frame`)。** + +**Gate 3**:素材清单表 + 每张素材的预览(缩略图/说明)给用户过,明确问一句"素材来源与授权没问题吗"。**用户过完才写 HTML。** + +### Phase 4 · HTML(分步入场在这里实现) + +每张一个文件,放 `slides/`,文件名与 script.json 的 `html` 字段一致。约定: + +- 根元素 `` 选主题(初版 `a|b|c`,另有 `minimal-white / swiss-grid / corporate-clean / editorial-serif / magazine-bold / tokyo-night / catppuccin-mocha / nord / xiaohongshu-white / soft-pastel`;选主题速查与对比度校验见 authoring.md);引 `tokens.css`;画面容器 `.stage`,含 `.brand` 角标与 `.slide-num` 页码。 +- **每个要入场的块加 `data-stage="1|2|3"` + 一个 fx 工具类**(`fx-up/fx-fade/fx-grow/fx-blur/fx-rise/fx-pop/fx-spotlight/fx-ripple/fx-glitch/fx-draw`;氛围类 `fx-pulse/fx-shimmer/fx-kenburns` 不加 data-stage);延迟不用写——管线按 `timings.json` 注入 `--t1/--t2/--t3`。同层错峰用容器 `.fx-stagger`(基准 `style="--stagger-base:var(--t3)"`)或内联 `style="animation-delay:calc(var(--t2) + 150ms)"`。 +- ⚠ **`data-stage` 必须与 fx 类同时用**(只有属性没有动画类会永远停在 opacity:0);延迟实现见 authoring.md 的"stage 延迟的实现原理",改动画时不要手写 `animation-delay: var(--tN)`。 +- 图片一律套 `.img-frame`(`.contain` 给截图/图表;`--img-ratio` 定比例;`--img-pos` 保主体;图注 `.img-cap` 写在框外)。 +- 氛围动画(无限循环的呼吸/漂浮)允许,但不能承载信息、不加 `data-stage`。 +- 禁用 transition 做入场(截图管线 seek 不到),只用 `@keyframes`。禁外部 Google Fonts(离线不稳),用系统字体栈(tokens.css 已配 CJK fallback)。 +- 素材只用 Gate 3 已确认的 `assets/` 清单,不新增未审素材。 +- ⚠ **写完 8 张后先跑静态检查再截图**:`node <技能>/scripts/check-slides.mjs <项目目录>` —— 抓未定义 CSS 变量(会导致文字隐形)、图片缺失/外链资源、data-stage 没配 fx 类、硬编码颜色。有 ✗ 就别截图,画面对但"看不见"是最难查的。 + +写完终态预览(最快路径,给 Gate 4 看): + +```bash +node <技能目录>/scripts/capture.mjs <项目目录> --mode still +``` + +**Gate 4**:`preview/.png` 逐张给用户过(看版式、字压、素材,不看动画时序——时序由 timings 保证)。 + +### Phase 5 · 渲染 + ASR 校验 + +```bash +node <技能目录>/scripts/capture.mjs <项目目录> --mode motion # 入场动画逐帧进视频 +node <技能目录>/scripts/build-video.mjs <项目目录> --asr +``` + +- `--mode motion`:逐帧步进(暂停全部动画 → 逐帧 seek → 截图 → 编码),动画窗口逐帧渲染、静止段自动补尾帧,时长精确。**字幕默认烧录**(内容取自 clauses、显示窗=该句开口到下句开口,画面底部居中,still 预览里不显示、成片里才有;`--no-subs` 关闭)。成本约 50–200ms/帧,8 张 × 30fps 约 3–6 分钟,预算进超时。赶时间可用 still 模式出片(动画不进视频,只有淡入淡出)。 +- build-video 自动:每张编码(统一参数)→ concat 拼接(时长漂移自动回退重编码)→ 音轨按每张实测时长 `apad` 对位 → **BGM 垫底(配了 bgm 才走:循环补满、淡入淡出、人声优先;混音失败自动退回纯人声)** → mux → ffprobe 时长校验 + 全量解码自检,不过关退出码非 0;同时输出 `out/subs.srt`(与烧录字幕同源同窗,中英双语按 clauses 的 text2 自动两行,供平台上传)。 +- `--asr`:**按句**切出 `asr/part--.mp3` + 生成 `asr/checklist.md`。转写与预期文本比对:数字、年份、产品名必须一致;同音字可容忍。**若某段转写混入上一句的开头,说明那句实际开口比估算晚——跑 check-timing 校准。** + - mcode:`mcode-tools upload_temp_url` 上传后交 `connector__matrix__listen_audio`。 + - **其他环境**:`MINIMAX_API_KEY=sk-xxx node scripts/asr.mjs <项目目录>` —— 直调 REST(同一把 Key),自动比对并回填 checklist;失败项(数字不符/繁体字)会以非 0 退出码报出。想拿更准的开口时刻:`--verify-timing`。 + - 不过关的 slide:改口播或重做该段 TTS → 重跑 plan-timings → 该张重渲染(帧目录删掉对应张即可)。 + +**Gate 5**:成片 `out/final.mp4` + ASR 校验表给用户过,含字幕可读性检查(静音播放一遍,字幕能否撑起理解)与 BGM 电平(人声是否始终清晰)。 + +### Phase 6 · 交付 + +``` +out/final.mp4 # 主交付 +out/slide-*.mp4 # 单段(可单独发布) +build/audio-timeline.wav # 对位后音轨 +preview/*.png slides/*.html slides/tokens.css +audio/*.mp3 assets/(含 MANIFEST.md) research/notes.md asr/(校验记录) +``` + +## 参考文件(按需读,别全读) + +- `references/authoring.md` — 17 种版式规范(每种画面必含项)+ 内容量表 + 入场系统用法(含 stage 延迟实现原理)+ 主题速查 + 竖版说明 + 可抄的 HTML 片段 +- `references/research.md` — 资料搜集(来源分级、交叉验证硬规则、query 设计、矛盾处理、notes 模板) +- `references/image-sources.md` — 配图与素材 SOP(三条取图路径、query 正/反词、两级筛选、图片框用法、裁切硬限制、视觉验证三件套、常见题材索引) +- `references/tts-and-timing.md` — mcode TTS connector 命令、重试纪律、对时算法、留白与语速调校、实测音色表 +- `references/render.md` — 渲染原理(为什么逐帧步进、字体怎么等)、字幕系统、BGM 混音、ffmpeg 手工命令、排错表 +- `THIRD-PARTY-NOTICES.md` — 第三方组件许可声明(10 套主题与部分 CSS 原语改编自 html-ppt-skill,MIT) + +## 常见症状 → 一句话诊断 + +| 症状 | 根因 | 动作 | +|---|---|---| +| TTS 念完画面还停很久 | 时长是估的不是实测,或 tail 过大 | 重跑 plan-timings;调该张 `tail` | +| 画面只有标题没有展开 | 违反内容量硬规则 | 补展开层 + 对应 clause,回 Phase 1 | +| 动画没进视频 | 用了 still 模式 | motion 模式重出 | +| 音画不同步(体感) | 三选一:手写了动画延迟 / 估算偏差 / 视觉锚点挂错句(强同步原则) | 先删手写延迟;跑 check-timing 拿实测数据,偏差大就 --calibrate;锚点错句则调 stage 映射 | +| 音色语种不对(粤语/繁体) | 平台 voice 标签错位,名称不可信 | Phase 1 试听必须带 ASR 验音(见 tts-and-timing.md 实测音色表) | +| 成片没字幕 | 用了 --no-subs 或 clauses 缺失 | capture 默认烧录;确认 timings.json 有 clauses | +| 要双语字幕 | — | clause 加 `text2`,画面两行 + SRT 双行自动出;第二行建议 ≤60 字符 | +| 想要背景音乐 | — | 顶层 `bgm` 配置,build-video 自动循环+淡入淡出垫底(默认音量 0.12);ASR 校验仍走纯人声轨 | +| 拼接后总时长不对 | 混用不同编码器参数的段 | 全部段由 build-video 统一编码;已自动回退重编码 | +| 中文方框 | 系统无 CJK 字体 | Linux 装 fonts-noto-cjk;或改用已装字体 | +| 报"找不到 ffprobe/ffmpeg" | 二进制不在 PATH | 脚本已自动探测 PATH→node_modules→常见位置;装 ffmpeg-static 或 winget install Gyan.FFmpeg | +| **一部分元素 0 秒就入场、一部分按时序** | 旧版 tokens.css 的延迟被 `.fx-*` 简写覆盖 | 换用新版 tokens.css(延迟走 `--fx-delay`);原理见 authoring.md | +| 图片主体被裁到画面外 / 图片撑破版式 | 裸放 ``,或 cover 配错比例 | 套 `.img-frame` + `--img-pos` 保主体;截图类改 `.contain`;主体贴边按 SOP 重搜图 | +| 字幕在深色主题下糊在背景里 | 主题没覆写字幕钩子 | 该主题加 `--sub-bg`(更深)+ `--sub-ring: 1px solid rgba(255,255,255,.16)`;跑 check-theme 验 | +| 要出竖版(抖音/视频号) | — | `script.json` 设 `width:1080, height:1920`,版式改堆叠(见 authoring.md 竖版章节) | +| 数字/文字明明写了却看不见 | 未定义 CSS 变量 + `-webkit-text-fill-color: transparent`,整条 background 失效 | 跑 `check-slides.mjs` 定位,补定义或写 `var(--x, 默认值)` | +| 图片显示 broken 图标 | 文件缺失,或 SVG 本身有问题(XML 错/依赖外部资源/缺尺寸) | `check-slides.mjs` 查路径;SVG 改 inline 进 HTML;capture 也会在渲染时点名哪张没加载 | diff --git a/skills/html2video-for-mcode/evals/evals.json b/skills/html2video-for-mcode/evals/evals.json new file mode 100644 index 00000000..fb49c529 --- /dev/null +++ b/skills/html2video-for-mcode/evals/evals.json @@ -0,0 +1,148 @@ +{ + "skill_name": "html2video-for-mcode", + "evals": [ + { + "id": 0, + "name": "full-run-from-outline", + "prompt": "帮我把这份大纲做成一条一分钟的中文口播视频,1920×1080:介绍 OpenAI——十年时间、三大产品线、九亿周活、25B 年化营收、92% 财富500强渗透、一句 Altman 引语收尾。", + "expected_output": "走完整工作流且顺序正确: 事实性题材先搜集过 Gate 0 → 逐张填 script.json(内容量硬规则: 每张标题层+展开层, 口播 25-48 字)过 Gate 1 → TTS 落 audio/ → plan-timings 实测时长过 Gate 2 → 按 authoring.md 写 8 张 HTML(data-stage+fx-*, 不写死延迟) → capture still 过 Gate 3 → capture motion + build-video --asr → ASR 逐段比对过 Gate 4。全程不手写任何时长数字。", + "files": [] + }, + { + "id": 1, + "name": "long-tail-silence", + "prompt": "之前用别的方法做的幻灯片视频有个毛病:每页 TTS 念完以后画面还要呆等三四秒才切页,很拖。你这套能解决吗?", + "expected_output": "解释根因是时长靠估算而非实测。给出本流水线的解法: TTS 先行 + ffprobe 实测 + slide 时长=实测+tail(默认0.8s), tail 可按紧凑/舒缓调 0.4/1.2; 并说明 stage 入场时刻按字数占比从实测时长推出, 音画同源。", + "files": [] + }, + { + "id": 2, + "name": "title-only-slide", + "prompt": "我看你出的第 3 张只有一个大标题,口播念了一整句,画面什么都没有,能不能这页先出标题,然后跟着口播出几条细节?", + "expected_output": "判定为违反内容量硬规则(画面视觉块≥2 且分属不同 stage)。修正: 补展开层(如 bullets 三条)挂 data-stage=2 + fx-up, 口播拆成引入句(clause stage=1)+展开句(clause stage=2), 入场时刻由 plan-timings 按展开句开口时间自动注入, 不手写延迟。", + "files": [] + }, + { + "id": 3, + "name": "tts-partial-failure", + "prompt": "batch_text_to_audio 返回 8 条里成功 5 条失败 3 条, 报 rate limit, 怎么重试?", + "expected_output": "sleep 10-30s 后只把失败的 3 条重新组一个小批重试, 绝不重发整批; 全部落盘 audio/ 后重跑 plan-timings 再继续。", + "files": [] + }, + { + "id": 4, + "name": "asr-mismatch", + "prompt": "ASR 校验发现第 5 张转写里 '92%' 听成了 '九二', 数字对不上, 怎么处理?", + "expected_output": "改口播写法(如 '九成二')→ 只重做该段 TTS → 重跑 plan-timings → 删 build/frames/05 与 out/slide-05.mp4 → 该张重 capture + build-video, 不整片重做。若确认是同音字级微小差异可接受, 数字/产品名不一致必须返工。", + "files": [] + }, + { + "id": 5, + "name": "asset-gate-browser-route", + "prompt": "做一条介绍某中国 AI 公司的视频, 需要他们的 logo、CEO 照片和产品截图。素材这块你怎么弄?", + "expected_output": "按优先级执行: 官方渠道(brand kit/simple-icons/官方文档截图)优先; 缺的用内置浏览器或 Playwright 打开官网 inspect DOM 提取官方 CDN 资源(2026-09 实测中国 AI 公司 6/6 相关); image-downloader(Bing)默认不用(中文题材实测系统性返回无关图); 每个素材查合规(水印/商标语境/照片授权/截图出处), 落盘 assets/ 并逐条登记 MANIFEST.md; 素材清单+预览过 Gate 3, 明确问用户来源与授权是否 OK, 过了才写 HTML。禁止凭空造图; 无合适素材降级纯排版。", + "files": [] + }, + { + "id": 6, + "name": "subtitle-burn-and-srt", + "prompt": "成片需要有字幕(很多人在静音场景看), 还要能传抖音/B站。字幕怎么加?", + "expected_output": "不需要在 HTML 里写字幕: capture 默认把 timings.json 的 clauses 逐句烧进画面(底部居中胶囊, 显示窗=该句开口到下句开口, --no-subs 可关), build-video 同时输出 out/subs.srt 供平台上传。单句建议 ≤18 字(plan-timings 超限会警告)。Gate 5 用静音播放验收字幕可读性。", + "files": [] + }, + { + "id": 7, + "name": "voice-language-verification", + "prompt": "音色列表里选了个'新闻女声 Chinese (Mandarin)_News_Anchor', 标着普通话, 直接用可以吗?", + "expected_output": "不可以, 标签不可信(该音色 2026-09 实测输出粤语)。Phase 1 试听必须带 ASR 验音: 3 个候选各单条 TTS 同一句探针文案(含数字专名) → listen_audio 转写 → 检查简体/繁体、数字念对; 把实测语种标进试听清单给用户三选一。tts-and-timing.md 附实测音色表可作起点。", + "files": [] + }, + { + "id": 8, + "name": "bilingual-subtitles", + "prompt": "成片要中英双语字幕, 给海外同事看。怎么加?", + "expected_output": "不用改 HTML: script.json 每个 clause 加 text2(同一句的翻译, 不重排语序, ≤60 字符), capture 自动把字幕渲染成两行(中文主行+次行小字号), build-video 的 out/subs.srt 同步双行。plan-timings 对超长 text2 会预警。只做部分句子也可以。", + "files": [] + }, + { + "id": 9, + "name": "bgm-mix", + "prompt": "想给成片加个背景音乐, 别盖过人声。", + "expected_output": "用 connector__matrix__batch_text_to_music 生成无人声、可循环的 BGM 放 assets/bgm.mp3(或用户自备并确认授权), script.json 顶层加 bgm 配置; build-video 自动循环补满+淡入淡出+按 volume(默认 0.12)垫在人声下, 混音失败退回纯人声; ASR 校验仍走纯人声轨不受干扰。Gate 5 验人声清晰度。", + "files": [] + }, + { + "id": 10, + "name": "image-sourcing-sop", + "prompt": "配一张学校大门的实景图, 搜到的图主体都在画面边缘或者只有半截, 比例也不对。怎么办?", + "expected_output": "按 references/image-sources.md 的 SOP: 核心原则是'先重搜全貌图, 别硬裁硬用'。query 加正向词(全景/全貌/正面/远景)排除词(特写/近景/一角/半截); 下载前缩略图筛(主体居中/比例接近/无水印无无关logo/≥1200px), 不合适就换; 拿到图跑 prep-image.mjs --check; 图片一律套 .img-frame, 用 --img-pos 控制主体可见区或 .contain 留白, 不要裸放 img 或硬裁(裁切硬限: 裁掉≤20%、不放大补边); 都不行就换题材(数字卡/大字/引言)。", + "files": [] + }, + { + "id": 11, + "name": "vertical-canvas", + "prompt": "同一条内容还要出一版竖屏的, 发给视频号。要改多少东西?", + "expected_output": "只改 script.json 的 width:1080 / height:1920 —— 渲染管线自动注入 --stage-w/--stage-h, 字幕几何(位置/宽度/字号)跟随画布自动收窄, 不用改代码。但版式要重排: 横屏左右分栏改上下堆叠, 内容边距 ~90px, 标题降到 --fs-h2 量级, 每屏信息量减一档(3条→2条), 图片框用 3/4 或 1/1。竖版注意事项见 authoring.md。", + "files": [] + }, + { + "id": 12, + "name": "stage-delay-covered-by-shorthand", + "prompt": "成片里有些元素一开页就入场了, 另一些却按口播节奏入场, 时序很乱。是 plan-timings 估错了吗?", + "expected_output": "不像估算问题(估算偏差通常 <0.3s)。典型根因是 tokens.css 里入场延迟被覆盖: [data-stage=N] 的 animation-delay 与 .fx-* 的 animation 简写同为 (0,1,0) 优先级, 简写更靠后会把延迟重置为 0 —— 于是只写了内联 calc(var(--t2)+…) 的元素时序正确, 纯靠属性声明的元素 0 秒入场。修法: 用新版 tokens.css(延迟走 shorthand 的变量槽 --fx-delay), 不要把延迟写成独立的 animation-delay。先抽成片帧确认, 再决定要不要动算法。", + "files": [] + }, + { + "id": 13, + "name": "non-mcode-environment-via-mmx", + "prompt": "我在 Claude Code 里, 没有 mcode 的 connector。这个技能还能跑吗? TTS 和 ASR 怎么办?", + "expected_output": "能跑 —— 脚本层(截图/渲染/合成/校验/配图)完全环境无关, 只有 TTS/音乐/ASR 依赖平台。TTS 用 mmx-cli: mmx speech synthesize --text ... --voice --speed 1.0 --out audio/01.mp3(音色 mmx speech voices); ASR 用技能自带 node scripts/asr.mjs <项目>(同一把 API Key 直调 /v1/speech_to_text, 自动比对 checklist, 数字/繁体字不符判 ✗); 音乐生成 mmx-cli 没有 → 用户自备或跳过; 配图可用 mmx image(仅限抽象图, 禁生成 logo/截图/真人头像)。首次配置 npm i -g mmx-cli + mmx auth login, 401 时设 region。纪律不变: 时长实测、字幕来自 clauses、音色要试听验语种、Gate 不跳。", + "files": [] + }, + { + "id": 14, + "name": "asr-rest-and-timing-verify", + "prompt": "ASR 校验怎么跑? 我想顺便确认动画到底有没有对上口播。", + "expected_output": "build-video --asr 先按句切出 asr/part--.mp3; 然后 MINIMAX_API_KEY=sk-xxx node scripts/asr.mjs <项目> 直调 REST 转写, 自动与 checklist 预期文本比对并回填(数字/繁体字不符 → ✗ 非 0 退出; 超 500s/50MB 自动转码)。要确认对轴: node scripts/asr.mjs <项目> --verify-timing 用接口的字级时间戳(verbose_json + timestamp_level=word)实测每句开口, 与 timings.json 的 clauses[].start 对比; 偏差大就校准后重渲染。也可先用 check-timing.mjs(静音法, 离线免费)。", + "files": [] + }, + { + "id": 15, + "name": "english-narration", + "prompt": "这条视频要发给海外同事, 口播改成英语。要改哪些地方?", + "expected_output": "开工对齐时就该问清语言(铁律 4 的语言行: 中文/英语/粤语/其他)。改动四处: ① script.json 设 lang:'en'; ② 口播稿改写英语并按英文基准控量 —— plan-timings 自动切换(约 14 字符/秒、常见 9–18、字幕单行 42 字符, 每张约 14–26 词); ③ 音色换成 English_* 前缀(Chinese (Mandarin)_* 配英文会出怪腔调), 做完用 asr.mjs --language en 验语种; ④ 若开双语, 主行=英语口播、text2 放中文。排版、脚本、渲染流程都不用动; 数字写法反过来(口播 nine hundred million, 画面 900M)。", + "files": [] + }, + { + "id": 16, + "name": "subtitle-readability-verification", + "prompt": "字幕到底怎么验收? 我怕深色主题下看不清。", + "expected_output": "三件套缺一不可: ① 数值关 —— node scripts/check-theme.mjs <项目> 算\"字幕文字 vs 胶囊合成到背景后\"的对比度, <4.5:1 直接不通过(深色主题若不足, 覆写 --sub-bg 更深 + --sub-ring 分隔); ② 帧关 —— 字幕只在成片出现(still 会 finish 掉), 抽 motion 帧 crop 底部条带看: 是否出现/是否被裁/是否与页码重叠(capture 在缺 clauses 时会告警, 不会静默出无字幕片); ③ 静音关(Gate 5)—— 关声看整片, 只靠字幕能否看懂。" + }, + { + "id": 17, + "name": "custom-brand-accent", + "prompt": "客户给了品牌色 #C2410C, 能用上吗?", + "expected_output": "能: 主题主色是令牌。在项目自己的 slides/tokens.css 末尾追加 [data-theme=\"...\"] 覆写 --accent/--accent-ink/--accent-2/--accent-3/--grad(改项目副本不动技能模板), 然后必须跑 check-theme.mjs 验对比度 —— accent-ink 对 accent ≥3:1 才可接受(实测 #C2410C+白字 5.18:1 通过; 浅色如 #FFB84D+白字只有 1.72:1 会被判 ✗ 并退出码 1)。深色主题另看 --sub-bg 字幕胶囊是否仍分得开。" + }, + { + "id": 18, + "name": "undefined-css-variable-invisible-text", + "prompt": "成片里那个大数字 42% 完全看不见, 但 HTML 里明明写了, 也不报错。为什么?", + "expected_output": "未定义 CSS 变量: background:linear-gradient(...,var(--coral-a),var(--coral-b)) 里有个变量没定义 → 整条声明计算为 none; 该元素又用了 -webkit-text-fill-color:transparent(渐变数字的标准写法) → 文字彻底隐形且无任何报错。常见诱因是从外部单页 HTML 抄配色, 变量名不在 tokens.css 里。修法: 项目 tokens.css 补定义, 或写 var(--coral-a, #ED3366) 带默认值; 之后跑 node scripts/check-slides.mjs <项目> 复验(它会静态抓出所有未定义且无 fallback 的变量)。" + }, + { + "id": 19, + "name": "broken-image-and-svg-under-file", + "prompt": "架构图的 SVG 在截图里显示成 broken image, 是 file:// 的兼容性问题吗?", + "expected_output": "不是协议问题 —— 实测 file:// 下 正常加载(400px), PNG 同样正常。broken 的真因通常是 SVG 文件自身: XML 有误、依赖外部资源或 Web 字体(inline 到 img 时被禁)、缺 width/height、或下载失败存成了 HTML。建议一律 inline 进 HTML(彻底绕开)。防线有两道: check-slides.mjs 静态查图片路径缺失/外链并提示 SVG 建议 inline; capture 渲染时会点名哪张图没加载出来(以前是静默出 broken 图标)。" + }, + { + "id": 21, + "name": "research-hard-rules", + "prompt": "这条片子里有个数字\"九亿周活\", 我直接写进口播稿行不行?", + "expected_output": "先过 references/research.md 的四条硬规则: ① 关键数字至少 2 个独立来源(只有一个来源就改限定措辞\"官方口径为…\"或降级为约数); ② 优先一手(官方公告/财报/技术报告/政府统计), 二手转述要回溯原文; ③ 标注口径与日期(周活≠月活, 年化≠单季, 币种要写清); ④ 查不到出处或无法判定 → 进 research/notes.md 的\"不确定项\", 绝不进口播稿。数字类断言还要注意时效(优先 12 个月内)与\"最快/第一\"这类需要新来源的表述。Gate 0 让用户确认数字/名称/口径。", + "files": [] + } + ] +} diff --git a/skills/html2video-for-mcode/references/authoring.md b/skills/html2video-for-mcode/references/authoring.md new file mode 100644 index 00000000..144a247c --- /dev/null +++ b/skills/html2video-for-mcode/references/authoring.md @@ -0,0 +1,307 @@ +# HTML 设计与内容量规范 + +## 核心原则:每张 slide 是"一句主张 + 一组展开" + +成片最常见的两个败笔,都在authoring阶段就能拦住: + +1. **只有标题没有展开** —— 观众 7 秒只看到一行大字,信息量为零。 +2. **文字一次性全糊上屏** —— 没有节奏,口播念到后半句时视觉早已无话可说。 + +解法是强制的三层结构:**标题层(stage 1)先出 → 展开层(stage 2)在口播展开句开口时入 → 视觉锚点(stage 3,可选)压轴**。口播与画面是同一句话的两个声部。 + +## 内容量表(硬规则) + +中文口播 ≈ 4.8 字/秒(speed 1.0),每张目标 6–10s。**英语按 ~14 字符/秒(约 150 词/分)**——下表字数是中文口径;英语项目按"词"折半看:每张 14–26 词、硬上限 34 词(plan-timings 会按 `lang` 自动切换基准与预警区间)。 + +| 版式 | 用途 | 画面必含(缺一即违规) | 口播字数 | clauses 结构 | +|---|---|---|---|---| +| title-hero | 开场主张 | 大标题 + 副题一行 + logo/主题角标 | 12–20 | 1 句 | +| statement | 单点断言 | 断言大字(s1) + 支撑小字(s2) + 视觉锚点(s3 可选) | 18–28 | 2 句 | +| bullets | 并列要点 | 标题(s1) + 3–4 条要点,每条 ≤14 字(s2) | 25–40 | 2 句 | +| compare | 前后对比 | 标题(s1) + 左右两栏各 ≥1 项 + 分隔(s2) | 22–35 | 2 句 | +| data-viz | 数据说服 | 标题(s1) + ≥2 个数字卡/图表(s2) | 25–40 | 2 句 | +| code | 演示调用 | 标题(s1) + 代码块 ≥3 行 + 高亮注释(s2) | 25–40 | 2 句 | +| quote | 引语转场 | 引语大字(s1) + 署名/头衔(s2) | 20–32 | 2 句 | +| closing | 收束 | 一句收束 + logo/CTA | 12–20 | 1 句 | + +判定规则(写给执行者,自查用): + +- 除 title-hero/closing 外,**画面视觉块 ≥2 个且分属不同 stage**;"光标题 + 页码"直接打回。 +- **强同步原则(2026-09 实测教训):每个 stage 的视觉锚点(大数字/关键词/主体图)必须写在触发它的那句口播里,不要放在上一句。**反例:口播第一句就是"三亿人在用",但"3 亿"数字卡挂在 stage 2——观众听到"三亿"时画面没反应,数字卡进场时口播已讲到下一句,体感就是"动画对不上"。正例:第一句只铺垫("它有三个你可能用过的产品"),"3 亿"作为第二句开头,数字卡挂 stage 2 → 声画同时砸出"3 亿",强同步。 +- 口播 clauses 与 stage 的映射:**stage k 的视觉在 clause k 开口时入场**。stage 数 ≈ clause 数;多出的视觉层用 script.json 的 `stageTimes` 显式给时刻。 +- 单张口播硬上限 60 字;超了说明这张在干两张的活,拆。字幕场景下单句建议 ≤18 字(capture 烧录字幕单行展示);双语时第二行(text2)≤60 字符。 +- 数字、专有名词在口播里用中文读法(“九亿”而非“900000000”),画面上才出现阿拉伯数字。(英语项目相反:口播写英文数字读法 “nine hundred million”,画面用 900M。) +- 双语字幕(可选):每句给 `text2` 即自动两行(主行中文 + 次行小字号);text2 是同一句的翻译,不要重排语序。 + +## 分步入场系统(怎么写 HTML) + +tokens.css 已内置,规则只有四条: + +1. 要入场的块:`data-stage="1|2|3"` + fx 工具类(`fx-up/fx-fade/fx-grow/fx-draw`)。 +2. **不写任何延迟秒数**。`--t1/--t2/--t3` 由渲染管线按 TTS 实测时长注入;浏览器里直接打开时用 tokens 里的占位默认值(0/0.8/2.0s)。 +3. 同层错峰(如三条 bullets 依次出现):内联 `style="animation-delay:calc(var(--t2) + 150ms)"`,第二块 +300ms,以此类推。 +4. 氛围动画(`fx-pulse` 等无限循环)只能用于装饰(光晕、呼吸点),**不加 data-stage、不承载信息**——它们不参与时长计算,静止截图中可能停在任意相位。 + +禁令:入场效果不要用 transition(管线逐帧 seek 不到 transition 状态);不要用 JS 定时器编排(setTimeout 驱动的分层,帧步进模式下不会发生);字体不要引外部 Google Fonts(离线环境直接方框),用 tokens.css 的系统字体栈。 + +### 写 HTML 的三条硬检查(写完立刻跑 `node <技能>/scripts/check-slides.mjs <项目>`) + +这三条都是 2026-09 实测踩过的静默故障 —— 画面明显不对,但流水线一路报成功: + +1. **变量必须先定义,或带 fallback**。`background: linear-gradient(135deg, var(--coral-a), var(--coral-b))` 里只要有一个变量没定义,整条声明计算为 `none`;若该元素又用了 `-webkit-text-fill-color: transparent`(渐变数字的常规写法),**文字会彻底隐形且不报错**。从别的单页 HTML 抄配色时最容易踩:抄来的变量名(如 `--coral-*`)不在 tokens.css 里。要么在项目 tokens.css 补定义,要么写 `var(--coral-a, #ED3366)` 给默认值。 +2. **图片用相对路径且文件必须存在**;`.svg` 建议 **inline 进 HTML**。实测 file:// 下 `` 能正常加载(400px),所以 broken 图标通常不是协议问题,而是 SVG 本身:XML 有误、依赖外部资源/Web 字体、缺 width/height、或下载失败存成了 HTML。inline 一次解决全部。 +3. **不要把外部单页的配色/变量整套抄进来**。可抄版式结构,配色必须落到 tokens 变量(`--accent` / `--good` / `--grad` …);硬编码 hex/rgb 换主题时会串色。 + +## 主题与视觉基调 + +``: + +- **a · 米白+暖橙**(默认,通用商务):bg `#FAFAF7`,accent `#FF5B2E`,衬线大标题。讲述、观点、人物向。 +- **b · 深色+绿**(科技感):bg `#0E0F12`,accent `#10A37F`,高对比数据。产品、AI、开发者向。 +- **c · 黑白+蓝**(极简高端):bg `#FFFFFF`,accent `#1F6FEB`。财务、报告、严肃向。 + +字号纪律:一张 slide 上最多两个层级同屏(标题 + 一个次级);正文 ≥30px,再小就是给审查者找茬。安全边距:内容离边缘 ≥96px(brand/slide-num 除外)。 + +## 素材获取(Phase 3 · Gate 3) + +流程:列需求清单(哪几张要图、要什么)→ 按下面的优先级取材 → 合规自查 → 落盘 `assets/` 并登记 `MANIFEST.md` → **Gate 3 给用户过**。 + +**获取优先级(逐级降级,不硬找):** + +1. **官方渠道(首选)**:logo 用官方 brand kit 或 simple-icons(可直接 curl);产品截图截官方 help/文档;数据用官方报告。合规性最强,事实性题材尽量停在这级。 +2. **内置浏览器 / Playwright 访问官网提取(2026-09 实测推荐)**:用 agent 的内置浏览器或 Playwright 打开官网/官方新闻页,inspect DOM 提取 ``/`` 资源(校验 src 是官方 CDN 域名再下载)。实测中国 AI 公司题材 6/6 全相关,远好于搜索引擎图搜。开工对齐时用户选了"只用官方素材/纯排版"则整级跳过。 +3. **纯排版降级(永远可用的兜底)**:大字 + 数字卡 + 配色 + 版式本身的表现力。宁缺毋假——没有合适素材就用这级,画面依然成立。 + +> ⚠ **image-downloader(Bing 搜图)默认不用。** 2026-09-17 实测:中文 AI 公司题材 5/5 关键词返回的全部是无关图(Bing 端返回"猜你喜欢"推荐卡,脚本正则照单全收)。除非题材是英文大众品牌且愿意逐张人工筛,否则不要走这条路。 + +**合规自查(每个素材过一遍):** + +- 无水印。有水印的候选:换一张,或裁到水印外;裁不掉就不用。 +- 商标/logo 只在"谈论该品牌"的合理引用语境使用,不做装饰滥用。 +- 照片须来自可授权来源(官方新闻图、CC 授权、免费图库);查不到授权的明星/人物照宁可用纯文字引语卡。 +- 截图注明出处(官方文档名 + 日期)。 + +**登记与使用:** + +- `assets/MANIFEST.md` 每个素材一行:文件名 / 内容 / 来源 URL 或渠道 / 许可。Gate 3 连同素材预览一起给用户,明确问"来源与授权没问题吗"。 +- **禁止凭空生成 logo、截图、头像、二维码;禁止带水印图入素材。** +- **图片必须套框,禁止裸放 ``**:用 `.img-frame`(比例与裁切归框)/ `.img-frame.contain`(截图、图表、带文字的图必须用,不裁切居中留白)/ `--img-ratio`(框比例,不必强求 16:9)/ `--img-pos: top|center|bottom`(控制主体可见区,**替代硬裁的首选**)/ `.img-scrim`(压暗垫白字)/ `.img-cap`(图注,写在框外)/ `.img-tag`(角标)。选图与裁切的完整 SOP 见 `references/image-sources.md`。 +- 图片入 HTML 用相对路径 `../assets/xxx.png`;capture 会等图片加载完(每张上限 4s,加载不动就跳过——所以素材必须先落本地,绝不引外链图)。 + +## 版式片段(可抄) + +以下片段都基于 tokens.css,只列 `
` 内部结构;外层 `.stage`、`.brand`、`.slide-num` 照 `_template.html`。 + +**bullets** +```html +
+

三大产品线

+
    +
  • ChatGPT — 对话
  • +
  • GPT 系列 — 模型
  • +
  • Sora — 视频生成
  • +
+
+``` + +**compare** +```html +
+

三年,高中生 → 研究员

+
+
+

2023 · GPT-3.5

会聊天,常出错

+
+
+

今天 · GPT-5.6

可托付研究级任务

+
+
+
+``` + +**data-viz** +```html +
+

九亿人每周在用

+
+
+
9亿
周活跃用户
+
+
+
$25B
年化营收
+
+
+
92%
财富500强渗透
+
+
+
+``` + +**code** +```html +
+

一行调用

+
const answer = await openai.responses.create({
+  model: "gpt-5.6",
+  input: "帮我总结这份报告",
+});
+console.log(answer.output_text);
+
+``` + +**quote** +```html +
+
“我们想打造的是比人类更聪明的工具,而不是替代人类。”
+

— Sam Altman, OpenAI CEO

+
+``` + +**title-hero / closing**:大标题居中 + 一行副题/CTA,单 stage 即可;closing 可加 logo `fx-grow`。 + +布局 CSS(`.layout/.cols/.cards/.num/.cap` 等)每张 slide 内联在 ` + + +
+ A · ${topic || '主题名'} +
+

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 — 语种必须与音色匹配, 且影响语速基准与字幕行宽校验。', + topic, + lang: "zh", + voice: 'Chinese (Mandarin)_Gentleman', + speed: { default: 1.0, first: 0.95, last: 0.95 }, + 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: '' }] }, + ], +}; + +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 3 终态预览 + node scripts/capture.mjs "${dir.replace(/\\/g, '/')}" --mode motion # 动画帧 + node scripts/build-video.mjs "${dir.replace(/\\/g, '/')}" --asr`); diff --git a/skills/html2video-for-mcode/scripts/plan-timings.mjs b/skills/html2video-for-mcode/scripts/plan-timings.mjs new file mode 100644 index 00000000..dec19768 --- /dev/null +++ b/skills/html2video-for-mcode/scripts/plan-timings.mjs @@ -0,0 +1,102 @@ +#!/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 } 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')); +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; + +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 = path.join(dir, 'audio', s.audio ?? `${s.id}.mp3`); + 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 重估`); + 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(path.join(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⁻¹) → build/timings.json`); +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/skills/html2video-for-mcode/scripts/prep-image.mjs b/skills/html2video-for-mcode/scripts/prep-image.mjs new file mode 100644 index 00000000..ef80d05a --- /dev/null +++ b/skills/html2video-for-mcode/scripts/prep-image.mjs @@ -0,0 +1,95 @@ +#!/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; }; + +const FFMPEG = requireTool('ffmpeg'); +const FFPROBE = requireTool('ffprobe'); +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]'); 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/skills/html2video-for-mcode/scripts/tools.mjs b/skills/html2video-for-mcode/scripts/tools.mjs new file mode 100644 index 00000000..79570aee --- /dev/null +++ b/skills/html2video-for-mcode/scripts/tools.mjs @@ -0,0 +1,99 @@ +// html2video-for-mcode · ffmpeg/ffprobe 探测: PATH → node_modules → 常见安装位置 +// 找不到时给可诊断的提示, 而不是让下游脚本报莫名其妙的错。 +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const IS_WIN = process.platform === 'win32'; +const EXE = IS_WIN ? '.exe' : ''; + +function onPath(name) { + const r = spawnSync(name + EXE, ['-version'], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && r.stdout ? name + EXE : null; +} + +const SELF_DIR = path.dirname(fileURLToPath(import.meta.url)); // Node 20.11 以下没有 import.meta.dirname, 用 fileURLToPath 保兼容 + +function candidatePaths(name) { + const dirs = []; + // 调用方项目目录(若给了)与技能自身位置的两个上层(仓库根/项目根都可能装了 node_modules) + const anchors = [process.env.KIT_PROJECT_DIR, SELF_DIR, path.resolve(SELF_DIR, '..'), path.resolve(SELF_DIR, '../..')] + .filter(Boolean); + for (const base of anchors) { + dirs.push(path.join(base, 'node_modules', 'ffmpeg-static')); + dirs.push(path.join(base, 'node_modules', 'ffprobe-static', 'bin', process.platform, process.arch)); + } + if (IS_WIN) { + const la = process.env.LOCALAPPDATA; + if (la) { + dirs.push(path.join(la, 'Microsoft', 'WinGet', 'Links')); + dirs.push(path.join(la, 'scoop', 'shims')); + } + dirs.push('C:\\ffmpeg\\bin'); + } + return dirs.map(d => path.join(d, name + EXE)); +} + +export function findTool(name, projectDir) { + try { const p = onPath(name); if (p) return p; } catch { /* not on PATH */ } + if (projectDir) { + const local = [ + path.join(projectDir, 'node_modules', 'ffmpeg-static', name + EXE), + path.join(projectDir, 'node_modules', 'ffprobe-static', 'bin', process.platform, process.arch, name + EXE), + ]; + for (const p of local) if (fs.existsSync(p)) return p; + } + for (const p of candidatePaths(name)) if (fs.existsSync(p)) return p; + return null; +} + +export function requireTool(name, projectDir) { + const p = findTool(name, projectDir); + if (!p) { + console.error([ + `✗ 找不到 ${name}。按顺序排查:`, + ` 1. 系统 PATH 上没有 ${name}`, + ` 2. 项目/仓库 node_modules 里没有 (试试: npm i ffmpeg-static ffprobe-static)`, + ` 3. 常见安装位置 (winget Links / scoop / C:\\ffmpeg\\bin) 也没有`, + ` 最快解法: winget install Gyan.FFmpeg (Windows) · brew install ffmpeg (macOS) · apt install ffmpeg (Linux)`, + ].join('\n')); + process.exit(2); // 2 = 环境错误, 与 1 (业务错误) 区分 + } + return p; +} + +// ── Node 包解析(playwright) ────────────────────────────────────── +// 技能通常装在 ~/.claude/skills/ 或 ~/.openclaw/skills/ —— 不在项目树里, +// 而 Node 的 import 是按"脚本所在位置"向上找 node_modules 的, 于是会出现 +// "项目里明明装了 playwright 却报找不到"。这里按多个锚点依次尝试。 +import { createRequire } from 'node:module'; +import { pathToFileURL, fileURLToPath } from 'node:url'; + +const PKG_ANCHORS = () => { + const list = [process.env.KIT_PROJECT_DIR, process.cwd()]; + // 全局 npm root(全局装的 playwright 也能用) + for (const npm of IS_WIN ? ['npm.cmd', 'npm'] : ['npm']) { + try { + const r = spawnSync(npm, ['root', '-g'], { encoding: 'utf8', windowsHide: true }); + if (r.status === 0 && r.stdout) { list.push(r.stdout.trim()); break; } + } catch { /* ignore */ } + } + return [...new Set(list.filter(Boolean))]; +}; + +export async function loadPackage(name, { projectDir } = {}) { + // CJS 包(如 playwright)动态 import 后命名导出可能拿不到, 统一解包出真实模块对象 + const unwrap = mod => (mod && (mod.chromium || mod.default?.chromium)) ? (mod.chromium ? mod : mod.default) : mod; + // 1) 脚本自身位置(技能装在项目内, 或全局 node_modules 可被解析时) + try { const m = unwrap(await import(name)); if (m?.default !== undefined || m) return m; } catch { /* 继续找 */ } + // 2) 项目目录 / 调用目录 / 全局 npm root, 逐个用 createRequire 解析真实入口 + const anchors = [...new Set([projectDir, ...PKG_ANCHORS()].filter(Boolean))]; + for (const base of anchors) { + try { + const req = createRequire(path.join(base, 'package.json')); + const entry = req.resolve(name); + return unwrap(await import(pathToFileURL(entry).href)); + } catch { /* 试下一个 */ } + } + return null; +} From 756ffb6c96889eb05e50bc9045f7a76fcea61efb Mon Sep 17 00:00:00 2001 From: Hahaha Date: Thu, 17 Sep 2026 23:25:19 +0800 Subject: [PATCH 03/24] fix: place plugin files under plugins/Wzdhehe/ (previous commit wrote them to the repo root) Add Plugin: html2video-for-mcode Narrated-video pipeline: HTML slides with staged entrance animations, TTS voiceover measured with ffprobe, burned-in subtitles, deterministic frame-stepping capture, ffmpeg assembly, and ASR verification. - 11 Node scripts, no build step; works with mcode connectors or mmx-cli - 13 themes, 17 layout recipes, image framing primitives - Subtitles confined to their own windows (no overlap); 16:9 and 9:16 canvases - Research phase documented: source grading, cross-verification rules, notes template, plus how to fetch official-site / press-release text (SPA rendering, PDF-first numbers) - Discloses dependencies, accounts, network destinations and data handling (bilingual README) - Validation: node scripts/validate.mjs -> OK (exit 0) --- .claude-plugin/plugin.json | 8 - CHANGELOG.md | 46 -- LICENSE | 21 - README.md | 154 ------ README.zh-CN.md | 127 ----- THIRD-PARTY-NOTICES.md | 61 --- plugin.json | 23 - .../skills/html2video-for-mcode/SKILL.md | 1 + .../references/image-sources.md | 2 + .../references/research.md | 36 +- skills/html2video-for-mcode/SKILL.md | 250 --------- skills/html2video-for-mcode/evals/evals.json | 148 ------ .../references/authoring.md | 307 ----------- .../references/image-sources.md | 107 ---- .../html2video-for-mcode/references/render.md | 130 ----- .../references/research.md | 114 ----- .../references/tts-and-timing.md | 159 ------ skills/html2video-for-mcode/scripts/asr.mjs | 226 --------- .../scripts/build-video.mjs | 205 -------- .../html2video-for-mcode/scripts/capture.mjs | 250 --------- .../scripts/check-slides.mjs | 117 ----- .../scripts/check-theme.mjs | 93 ---- .../scripts/check-timing.mjs | 125 ----- .../scripts/fetch-official-images.mjs | 132 ----- .../scripts/init-project.mjs | 480 ------------------ .../scripts/plan-timings.mjs | 102 ---- .../scripts/prep-image.mjs | 95 ---- skills/html2video-for-mcode/scripts/tools.mjs | 99 ---- 28 files changed, 38 insertions(+), 3580 deletions(-) delete mode 100644 .claude-plugin/plugin.json delete mode 100644 CHANGELOG.md delete mode 100644 LICENSE delete mode 100644 README.md delete mode 100644 README.zh-CN.md delete mode 100644 THIRD-PARTY-NOTICES.md delete mode 100644 plugin.json delete mode 100644 skills/html2video-for-mcode/SKILL.md delete mode 100644 skills/html2video-for-mcode/evals/evals.json delete mode 100644 skills/html2video-for-mcode/references/authoring.md delete mode 100644 skills/html2video-for-mcode/references/image-sources.md delete mode 100644 skills/html2video-for-mcode/references/render.md delete mode 100644 skills/html2video-for-mcode/references/research.md delete mode 100644 skills/html2video-for-mcode/references/tts-and-timing.md delete mode 100644 skills/html2video-for-mcode/scripts/asr.mjs delete mode 100644 skills/html2video-for-mcode/scripts/build-video.mjs delete mode 100644 skills/html2video-for-mcode/scripts/capture.mjs delete mode 100644 skills/html2video-for-mcode/scripts/check-slides.mjs delete mode 100644 skills/html2video-for-mcode/scripts/check-theme.mjs delete mode 100644 skills/html2video-for-mcode/scripts/check-timing.mjs delete mode 100644 skills/html2video-for-mcode/scripts/fetch-official-images.mjs delete mode 100644 skills/html2video-for-mcode/scripts/init-project.mjs delete mode 100644 skills/html2video-for-mcode/scripts/plan-timings.mjs delete mode 100644 skills/html2video-for-mcode/scripts/prep-image.mjs delete mode 100644 skills/html2video-for-mcode/scripts/tools.mjs diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json deleted file mode 100644 index 7298ffd1..00000000 --- a/.claude-plugin/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "html2video-for-mcode", - "version": "1.0.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/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 732f2ca9..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,46 +0,0 @@ -# Changelog - -## 1.0.0 — 2026-09-17 - -首个公开版本。 - -**流水线**:开工对齐(语言/风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 → HTML 分步入场 → 逐帧渲染 → ASR 反向校验,共 7 阶段 6 个确认闸门。 - -**脚本(11 个,纯 Node,无构建步骤)** - -| 脚本 | 作用 | -|---|---| -| `init-project.mjs` | 生成项目骨架(目录 + tokens.css + slide 模板 + script.json 契约) | -| `plan-timings.mjs` | ffprobe 实测每段 TTS → 时长、每层入场时刻、每句开口时刻 | -| `check-timing.mjs` | 静音检测实测每句真实开口,与估算对比并可校准 | -| `check-theme.mjs` | 全部主题的 WCAG 对比度闸门(正文/次级/字幕/强调色) | -| `check-slides.mjs` | 渲染前静态检查(未定义变量/图片缺失/外链/data-stage 未配动画/硬编码颜色) | -| `capture.mjs` | 终态截图或逐帧步进捕获,字幕默认烧录 | -| `build-video.mjs` | 编码 → 拼接 → 音轨对位 → BGM 混音 → 合成 → 自检 + SRT | -| `asr.mjs` | ASR 转写与脚本比对(直调 REST),支持字级时间戳核对开口时刻 | -| `prep-image.mjs` | 配图检查与受限裁切(裁掉面积上限 20%) | -| `fetch-official-images.mjs` | 从官方站点列取并下载候选素材图 | -| `tools.mjs` | ffmpeg/ffprobe 与 Node 包的多锚点探测 | - -**设计系统**:13 套主题、17 种版式、图片框原语(`.img-frame`)、14 个入场/氛围动画、分步入场与错峰容器。 - -**语言**:中文普通话 / 英语 / 粤语,字数与语速基准、字幕行宽、ASR 语言校验随语言切换。 - -**画布**:1920×1080 横屏与 1080×1920 竖版(字幕几何随画布比例自适应)。 - -**字幕**:单语或双语烧录,另出 `out/subs.srt`。 - -**声音**:BGM 垫底(可选,自动循环与淡入淡出)、ASR 反向校验(音色语种、数字与专名一致性)。 - -**资料搜集**:`references/research.md` —— 来源四级分级、四条硬规则(多源交叉验证/一手优先/标注口径日期/不确定不进脚本)、query 设计、多源矛盾处理、notes 模板、各环境搜索工具差异(含 mmx search 10 条上限)。 - -**两套工具链**:mcode 沙箱用 platform connectors;其他 Agent 环境用 mmx-cli(配 TTS)与本仓 `asr.mjs`(配 ASR)。 - -**已修复的静默故障(均加了闸门)** - -- 入场延迟被 `animation` 简写覆盖 → 元素在 0 秒入场(改用变量槽传递延迟) -- 未定义 CSS 变量 + 透明文字填充 → 文字完全隐形(check-slides 静态拦截) -- 图片加载失败 → 只出 broken 图标而无报错(capture 运行期点名) -- 缺少 clauses → 静默出无字幕片(capture 告警) -- 字幕窗口重叠 → 相邻两句同时可见, 看起来像"重影/错字"(淡出改为在窗口内归零, 并加窗口自检) -- 主题覆盖被忽略、同名选择器只取首个块(check-theme 合并语义修正) diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 73c5531e..00000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Wzdhehe - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md deleted file mode 100644 index 8bd94f36..00000000 --- a/README.md +++ /dev/null @@ -1,154 +0,0 @@ -**English** | [中文](README.zh-CN.md) - -# HTML 2 Video for mcode - -Turn a topic, an outline, or a finished script into a **narrated MP4**: HTML slides with staged -entrance animations, a TTS voiceover, burned-in subtitles, and an ASR pass that verifies the -voiceover actually says what the script says. - -Built for MiniMax Code (mcode), and runnable in any agent host through `mmx-cli`. - -## What the user gets - -Ask in plain language, get a publishable video: - -> 帮我把这份大纲做成一条 60 秒的中文口播视频:三张关键数字、结尾一句行动号召,用深色科技主题,加中文字幕。 - -Result: - -``` -my-video/ -├── slides/ 8 HTML slides + tokens.css (13 themes, 17 layout recipes) -├── audio/ 8 TTS clips -├── build/timings.json measured durations + per-layer entrance times -├── preview/*.png terminal-state frames -└── out/ - ├── final.mp4 ★ the deliverable (1920×1080 or 1080×1920, H.264 + AAC) - ├── subs.srt subtitles for platform upload - └── slide-*.mp4 per-slide segments -``` - -## Pipeline - -One Skill drives an 11-script pipeline (`skills/html2video-for-mcode/scripts/`): - -| Stage | What happens | -|---|---| -| 1. Kickoff alignment | Ask about language (Chinese / English / Cantonese), style & brand color, subtitles (none / single / bilingual), canvas (16:9 or 9:16), duration, voice, asset boundaries | -| 2. Research | Fact-check the topic and record sources before writing | -| 3. Script | Per-slide narration split into clauses; every slide must have a title layer and a detail layer | -| 4. TTS | Voice synthesis (mcode connector, or `mmx-cli` elsewhere), then measure every clip with ffprobe | -| 5. Timing | Derive each slide's duration and each animation's entrance time from the **measured** audio — nothing is hand-written | -| 6. Assets | Official sources first, image framing primitives, compliance manifest | -| 7. HTML | Staged entrance animations bound to the measured timings; layout recipes for 17 slide types | -| 8. Render | Deterministic frame-stepping capture (animations land in the video), ffmpeg assembly with subtitles, optional background music | -| 9. Verify | ASR transcription compared against the script; contrast, theme, and static-slide gates | - -## Design decisions worth knowing - -- **Nothing about timing is hand-written.** Every slide's duration and every animation's entrance - time comes from the measured TTS audio, so "the voiceover finished but the picture is still - waiting" cannot happen by construction. -- **Every slide has a title layer and a detail layer** on separate animation stages, so a slide is - never just a big title with nothing to look at. -- **Animations land in the video.** Capture steps frames deterministically instead of - screen-recording, so entrance animations are actually rendered rather than frozen. -- **Rendering is gated.** A static check refuses to render slides with undefined CSS variables, - missing images, external resources, or entrance animations without an animation class — the - failure modes that otherwise ship a video that looks broken while every script reports success. - -## Install - -**As a plugin (MiniMax Code):** add `plugins/Wzdhehe/html2video-for-mcode` from this repository, or -install it from the community catalog once merged. - -**As a standalone skill (any AgentSkills host):** - -```bash -cp -r html2video-for-mcode ~/.claude/skills/ # or ~/.openclaw/skills/ -# project-level -cp -r html2video-for-mcode /.claude/skills/ -# or from GitHub -npx skills add Wzdhehe/html2video-for-mcode -``` - -Then install the two dependencies **in your video project** (not in the skill directory): - -```bash -cd -npm i playwright && npx playwright install chromium -# ffmpeg: winget install Gyan.FFmpeg / brew install ffmpeg / apt install ffmpeg -# or: npm i ffmpeg-static ffprobe-static -``` - -## Requirements - -- **Node.js 18+** (plain ESM, no build step). -- **ffmpeg / ffprobe** on `PATH`, or `ffmpeg-static` / `ffprobe-static` in the project. Scripts - probe `PATH → project node_modules → skill parents → common install locations`. -- **Playwright Chromium** for capture. Install it in the video project — scripts resolve it from - the project directory, the working directory, or the global npm root. -- **Voice synthesis**, one of: mcode platform connectors, `mmx-cli` - (`npm i -g mmx-cli && mmx auth login --api-key sk-...`), or your own TTS written to - `audio/.mp3`. -- Optional: `MINIMAX_API_KEY` for `scripts/asr.mjs`, which transcribes the voiceover and compares - it against the script (numbers, proper nouns, and spoken language). - -## Quick start - -```bash -node /scripts/init-project.mjs ./my-video --topic "My topic" -# fill research/notes.md and script.json (clauses = one line of narration each) -# synthesize audio/01.mp3 … audio/08.mp3 -node /scripts/plan-timings.mjs ./my-video # measure audio → timings.json -node /scripts/check-slides.mjs ./my-video # static gate before rendering -node /scripts/capture.mjs ./my-video --mode motion -node /scripts/build-video.mjs ./my-video --asr -``` - -`SKILL.md` carries the full workflow (7 phases, 6 confirmation gates); `references/` holds the -authoring rules, asset-sourcing SOP, TTS/timing notes, and rendering internals. - -## Supported platforms - -Windows, macOS, and Linux. All scripts are Node ESM and shell-agnostic. On Windows prefer Git -Bash / WSL over PowerShell (complex argument lists with non-ASCII paths can misbehave there); -ffmpeg and Chromium paths are auto-detected. - -## Network access - -Nothing is contacted unless you invoke the step that needs it: - -- `scripts/asr.mjs` — HTTPS `POST` to `https://api.minimaxi.com/v1/speech_to_text` - (or `https://api.minimax.io` when `MINIMAX_REGION=global`). Only when you run it. -- `scripts/fetch-official-images.mjs` — opens the URL **you** pass (an official site or a local - `file://` page) to list and download candidate images. -- Voice synthesis happens through mcode connectors or `mmx-cli`, which contact MiniMax. -- Everything else (timing, static checks, capture, encoding, theme validation) is fully offline. - -No telemetry, no analytics, no hidden endpoints, no installers, no native binaries. - -## Data use - -- The narration text you write is sent to the speech service you chose; the produced audio is sent - to the ASR service only if you run `scripts/asr.mjs`. -- Assets you fetch are downloaded into your project's `assets/` directory and must be recorded in - `assets/MANIFEST.md` with source and license. -- Everything else stays on disk inside your project directory. The Skill writes only inside the - project directory you pass to it. -- No credentials are stored or embedded: the ASR script reads a key from `MINIMAX_API_KEY` or - `--api-key` at runtime and never writes it anywhere. - -## Troubleshooting - -`SKILL.md` ends with a symptom → cause → fix table covering the failures this pipeline has actually -hit: silent tails after the voiceover, slides with nothing on them, elements entering at 0 seconds, -invisible text from undefined CSS variables, broken images, subtitles washed out on dark themes, -wrong voice language, and mismatched concat durations. - -## License - -MIT — see `LICENSE`. Portions of the design system (10 themes, image-frame primitives, several -entrance animations) are adapted from -[html-ppt-skill](https://github.com/lewislulu/html-ppt-skill) (MIT, Copyright (c) 2026 lewis); the -full notice and the upstream MIT text are in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md). diff --git a/README.zh-CN.md b/README.zh-CN.md deleted file mode 100644 index 8e8cd0ad..00000000 --- a/README.zh-CN.md +++ /dev/null @@ -1,127 +0,0 @@ -[English](README.md) | **中文** - -# HTML 2 Video for mcode - -把一句主题、一份大纲或一篇定稿脚本,变成**带口播的成片 MP4**:HTML 幻灯片(分步入场动画)+ TTS 配音 + 烧录字幕,并用 ASR 反向校验"配音是否真的念的是脚本里的话"。 - -为 MiniMax Code(mcode)定制,也能在其他 Agent 环境里通过 `mmx-cli` 运行。 - -## 用户能得到什么 - -用大白话提需求,拿到一条可直接发布的视频: - -> 帮我把这份大纲做成一条 60 秒的中文口播视频:三张关键数字、结尾一句行动号召,用深色科技主题,加中文字幕。 - -产物: - -``` -my-video/ -├── slides/ 8 张 HTML + tokens.css(13 套主题、17 种版式配方) -├── audio/ 8 段 TTS 音频 -├── build/timings.json 实测时长 + 每个视觉层的入场时刻 -├── preview/*.png 终态预览帧 -└── out/ - ├── final.mp4 ★ 交付成片(1920×1080 或 1080×1920,H.264 + AAC) - ├── subs.srt 供平台上传的字幕 - └── slide-*.mp4 逐张分段 -``` - -## 流水线 - -一个技能驱动 11 个脚本(`skills/html2video-for-mcode/scripts/`): - -| 阶段 | 做什么 | -|---|---| -| 1. 开工对齐 | 询问语言(中文/英语/粤语)、风格与品牌色、字幕(不要/单语/双语)、画布(16:9 或 9:16)、时长、音色、素材边界 | -| 2. 信息搜集 | 事实性题材先核查并记录来源,再动笔 | -| 3. 脚本 | 每张 slide 的口播拆成逐句 clauses;每张必须有"标题层 + 展开层" | -| 4. TTS | 配音合成(mcode connector,其他环境用 `mmx-cli`),随后用 ffprobe 实测每段时长 | -| 5. 对时 | 每张时长、每层入场时刻**全部由实测音频推出**,不手写任何秒数 | -| 6. 配图 | 官方素材优先、图片框原语、合规清单登记 | -| 7. HTML | 分步入场动画绑定实测时刻;17 种版式配方 | -| 8. 渲染 | 确定性逐帧步进捕获(动画真正进视频)、ffmpeg 合成与字幕、可选 BGM | -| 9. 校验 | ASR 转写与脚本比对;对比度、主题、渲染前静态闸门 | - -## 几个关键设计 - -- **时序不靠手写**:每张时长与每个动画的入场时刻都来自实测音频,所以"配音念完了画面还在等"在结构上就不可能发生。 -- **每张都有标题层与展开层**,分属不同动画 stage,不会出现"只有一行大字"的页面。 -- **动画真的进视频**:捕获用逐帧步进而不是录屏,入场动画是渲染出来的,不是冻结在终态。 -- **渲染有闸门**:静态检查会拒绝未定义 CSS 变量、图片缺失、外链资源、入场动画缺动画类的页面 —— 这些正是"视频看着坏了但每个脚本都报成功"的元凶。 - -## 安装 - -**作为插件(MiniMax Code)**:使用本仓库的 `plugins/Wzdhehe/html2video-for-mcode`,合并后也可从社区目录安装。 - -**作为独立技能(任意 AgentSkills 宿主)**: - -```bash -cp -r html2video-for-mcode ~/.claude/skills/ # 或 ~/.openclaw/skills/ -# 项目级安装 -cp -r html2video-for-mcode <你的项目>/.claude/skills/ -# 或直接从 GitHub 安装 -npx skills add Wzdhehe/html2video-for-mcode -``` - -然后把两个依赖装到**你的视频项目里**(不是技能目录里): - -```bash -cd <你的视频项目> -npm i playwright && npx playwright install chromium -# ffmpeg: winget install Gyan.FFmpeg / brew install ffmpeg / apt install ffmpeg -# 或: npm i ffmpeg-static ffprobe-static -``` - -## 依赖要求 - -- **Node.js 18+**(纯 ESM,无构建步骤)。 -- **ffmpeg / ffprobe**:在 `PATH` 上,或项目里装 `ffmpeg-static` / `ffprobe-static`。脚本按 `PATH → 项目 node_modules → 技能上两级 → 常见安装位置` 探测。 -- **Playwright Chromium**:截图用。装在视频项目里即可 —— 脚本会从项目目录、工作目录、npm 全局逐个解析。 -- **配音**:三选一 —— mcode 平台 connector、`mmx-cli`(`npm i -g mmx-cli && mmx auth login --api-key sk-...`)、或你自己的 TTS(把音频写到 `audio/.mp3`)。 -- 可选:`MINIMAX_API_KEY`,用于 `scripts/asr.mjs` 把配音转写回来与脚本比对(数字、专名、语种)。 - -## 快速开始 - -```bash -node /scripts/init-project.mjs ./my-video --topic "我的主题" -# 填 research/notes.md 与 script.json(clauses = 每句口播一行) -# 合成 audio/01.mp3 … audio/08.mp3 -node /scripts/plan-timings.mjs ./my-video # 实测音频 → timings.json -node /scripts/check-slides.mjs ./my-video # 渲染前静态闸门 -node /scripts/capture.mjs ./my-video --mode motion -node /scripts/build-video.mjs ./my-video --asr -``` - -工作流全文(7 阶段、6 个确认闸门)在 `SKILL.md`;`references/` 放着编写规范、配图 SOP、TTS/对时说明与渲染内幕。 - -## 支持平台 - -Windows / macOS / Linux。脚本全部是 Node ESM,不依赖特定 shell。Windows 上建议用 Git Bash 或 WSL 而非 PowerShell(非 ASCII 路径 + 复杂参数组合容易出问题);ffmpeg 与 Chromium 路径自动探测。 - -## 网络访问 - -**只有你主动执行的那一步才会联网**: - -- `scripts/asr.mjs` —— HTTPS `POST` 到 `https://api.minimaxi.com/v1/speech_to_text`(海外套餐设 `MINIMAX_REGION=global` 时走 `https://api.minimax.io`)。仅在你运行时。 -- `scripts/fetch-official-images.mjs` —— 打开**你传入的**网址(官方网站或本地 `file://` 页面)以列出并下载候选配图。 -- 配音合成经由 mcode connector 或 `mmx-cli`,它们会访问 MiniMax。 -- 其余全部离线:对时、静态检查、截图、编码、主题对比度校验。 - -无遥测、无埋点、无隐藏端点、无安装器、无原生二进制。 - -## 数据使用 - -- 你写的口播文本会送到你选择的语音服务;产物音频只在你运行 `scripts/asr.mjs` 时才送到 ASR 服务。 -- 抓取的素材下载到项目的 `assets/` 目录,并需在 `assets/MANIFEST.md` 登记来源与许可。 -- 其余数据全部留在你传入的项目目录内 —— 技能只往这个目录里写东西。 -- **不存储、不内嵌任何凭据**:ASR 脚本运行时从 `MINIMAX_API_KEY` 或 `--api-key` 读取,且从不写出到任何文件。 - -## 排错 - -`SKILL.md` 末尾有一张"症状 → 原因 → 处置"表,覆盖这条流水线真实踩过的坑:配音念完画面还在等、页面只有标题、元素在 0 秒就入场、未定义 CSS 变量导致文字隐形、图片 broken、深色主题字幕糊底、音色语种不对、拼接后时长不符。 - -## 许可 - -MIT —— 见 `LICENSE`。设计系统的一部分(10 套主题、图片框原语、若干入场动画)改编自 -[html-ppt-skill](https://github.com/lewislulu/html-ppt-skill)(MIT,Copyright (c) 2026 lewis); -完整声明与上游 MIT 原文见 [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md)。 diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md deleted file mode 100644 index db353ffd..00000000 --- a/THIRD-PARTY-NOTICES.md +++ /dev/null @@ -1,61 +0,0 @@ -# 第三方组件与许可声明 - -本技能包含改编自第三方开源项目的内容。按 MIT 许可要求,原版权声明与许可原文如下。 - ---- - -## html-ppt-skill - -- **来源**:https://github.com/lewislulu/html-ppt-skill -- **版权**:Copyright (c) 2026 lewis -- **许可**:MIT -- **引入日期**:2026-09-17 -- **改编范围**(均经改写以适配本技能的单页静态渲染与音画同步体系): - - `scripts/init-project.mjs` 生成的 `slides/tokens.css`: - - 10 套命名主题(`minimal-white` / `swiss-grid` / `corporate-clean` / `editorial-serif` / - `magazine-bold` / `tokyo-night` / `catppuccin-mocha` / `nord` / `xiaohongshu-white` / `soft-pastel`) - 的配色令牌 —— 变量名已映射为本技能的命名(`--surface`→`--panel`、`--text-1/2/3`→`--fg/--muted/--fg-3`、 - `--border`→`--line` 等),外链字体(Playfair Display / Space Grotesk 等)已替换为本地字体栈 - - 图片框原语 `.img-frame` / `.contain` / `.fill` / `.img-scrim` / `.img-cap` / `.img-tag` 及其 - `--img-ratio` / `--img-pos` 变量 - - 部分入场动画的 keyframes(`fx-blur` `fx-rise` `fx-pop` `fx-spotlight` `fx-ripple` `fx-glitch` - `fx-shimmer` `fx-kenburns` `fx-stagger`)—— 触发机制已由"切页时 JS 重新触发"改为 - "`animation-delay: var(--tN)` 绝对时间",以配合逐帧步进渲染 - - 排版角色与容器类 `.kicker` / `.eyebrow` / `.lede` / `.card`(含三变体)/ `.pill` / `.divider-accent` - - `references/authoring.md` 的部分版式骨架改写自其 `templates/single-page/` 下的同名版本 - - `references/image-sources.md` 的图片框使用规范参照其 `.img-frame` 设计意图 - -**未引入的部分**(与其设计不兼容或有额外依赖):`runtime.js`(键盘导航/演讲者模式/概览)、 -`assets/animations/fx/` 下的 20 个 canvas 特效(rAF 驱动,无法被逐帧 seek)、 -`fonts.css` 与全部 Google Fonts 外链、Chart.js / highlight.js CDN 依赖、`@media print` 分页逻辑。 - -### MIT 许可原文 - -``` -MIT License - -Copyright (c) 2026 lewis - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - ---- - -**商用说明**:MIT 许可允许商业使用、修改、再分发与再许可,唯一义务是保留上述版权与许可声明。 -本技能生成的最终产物(视频、字幕、封面等)属于使用者自己的创作,不继承上述许可义务。 diff --git a/plugin.json b/plugin.json deleted file mode 100644 index 7af3f7f6..00000000 --- a/plugin.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "name": "html2video-for-mcode", - "version": "1.0.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.", - "author": { - "name": "Wzdhehe", - "url": "https://github.com/Wzdhehe" - }, - "homepage": "https://github.com/Wzdhehe/html2video-for-mcode", - "repository": "https://github.com/MiniMax-AI/MiniMax-Code-Plugins", - "license": "MIT", - "keywords": [ - "video", - "html-to-video", - "slides", - "tts", - "voiceover", - "subtitles", - "ffmpeg", - "playwright" - ] -} diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md index 6c963410..988e0533 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md @@ -103,6 +103,7 @@ Phase 6 交付 - 判断标准很简单:成片里会出现具体**数字、日期、名称、引语或归属关系** → 必须搜集。通用/抒情/创意题材可跳过,但"看起来像事实"的句子仍要核实。 - **四条硬规则**:① 关键数字**至少 2 个独立来源**(只有一个就用限定措辞或降级为约数);② **一手优先**(官方公告/财报/技术报告/政府统计),二手转述要回溯原文;③ 标注**口径与日期**(年化还是单季?周活还是月活?币种?);④ **查不到出处或无法判定的,进"不确定项",绝不进口播稿**。 +- **正文怎么取**:先 `web_fetch`;SPA/JS 渲染的官网与投资者关系页常只返回空壳,改用**内置浏览器或 Playwright 渲染后取正文**(最小命令见 references/research.md);招股书/财报是 PDF,以 PDF 原文数字为准。新闻稿与媒体转述冲突时以官方原文为准。 - 输出 `research/notes.md`:每条含 来源 URL + 口径日期 + 等级 + 第二来源;另列"不确定项"与"不该进脚本的内容"。 - **Gate 0**:事实清单给用户过 —— 重点让用户确认**数字、名称与口径**。 diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/image-sources.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/image-sources.md index 29df66ba..378ba00e 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/image-sources.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/image-sources.md @@ -16,6 +16,8 @@ 抽象信息(数字、对比、引用)**不需要图**:直接用版式本身(数字卡/大字/引语)做信息层,这是最省事也最高级的解法。 +> 提示:取图的同一个页面**也能取正文** —— 官网/新闻稿多为 JS 渲染,`web_fetch` 只拿到空壳时,用内置浏览器或 Playwright 渲染后取 `innerText`(命令见 `references/research.md` 的"抓取方法")。 + ## 2. 搜图阶段的筛选(下载前,必走) **query 设计** diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/research.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/research.md index 2a252534..345cca50 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/research.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/research.md @@ -52,7 +52,41 @@ | 其他宿主 | `mmx search "关键词"` / `mmx text chat` | **返回上限约 10 条、无分页** —— 适合"找一手入口",深入核实仍需抓页面或打开官方页 | | 通用兜底 | 内置浏览器打开官方页 | 与配图路径 B 同一手法;官方页永远最可靠 | -## notes.md 怎么写(给 Gate 0 用) +## 抓取方法:官网 / 新闻稿 / 招股书怎么真正拿到手 + +搜索工具只负责"找到入口",**正文要自己取**。按页面的渲染方式选方法: + +| 页面类型 | 方法 | 说明 | +|---|---|---| +| 静态页 / 文档 / 公告页 | `web_fetch ` | 直接拿正文,最快 | +| **SPA / JS 渲染页**(很多企业官网、投资者关系页) | **内置浏览器或 Playwright 渲染后取正文** | `web_fetch` 常只拿到空壳 HTML(有壳无文)。技能本来就依赖 Playwright,直接复用: | + +```bash +# 渲染后取正文文本(SPA/懒加载官网通用) +node -e "const {chromium}=require('playwright');(async()=>{ + const b=await chromium.launch(); const p=await b.newPage(); + await p.goto(process.argv[1],{waitUntil:'domcontentloaded'}); + await p.waitForTimeout(2500); // 等异步内容 + console.log(await p.evaluate(()=>document.body.innerText)); + await b.close();})()" "https://官网/新闻中心/某条" +``` + +- 取**图片/图表**用同一个页面:`node <技能>/scripts/fetch-official-images.mjs `(它已打开并渲染页面,列候选图) +- **PDF**(招股书、财报、年报、白皮书):直接下载,再从 PDF 提文本;**数字以 PDF 原文为准**,不要用媒体转述的版本 +- 需要登录/验证码的页面:不要在自动化里硬闯,换官方公开页或让用户提供文件 + +## 新闻稿 / 公告的取用纪律 + +**去哪找**:`/news`、`/newsroom`、`/press`、`/investor`、`/ir`、`/announcements`;中文公司常在"新闻中心 / 投资者关系 / 公告"栏目。交易所公告(如港交所 HKEXnews、巨潮资讯)比公司官网更权威。 + +**四条纪律** + +1. **认发布主体**:官方原文(公司/交易所/机构)≠ 媒体转述。冲突时**以官方原文为准**。 +2. **认发布日期与口径**:新闻稿里的数字几乎都带口径("截至 2026 年 6 月""年化""未经审计")→ **原样抄口径**,别简写成裸数字。 +3. **警惕标题夸大**:"首个/最大/颠覆"多出现在媒体标题而非原文;写进口播前回原文核对是否真有这个说法。 +4. **转发稿要溯源**:同一篇通稿被多家转载时,找到最初发布方;引用时引原文而不是转载页。 + + ```markdown ## 核心事实 diff --git a/skills/html2video-for-mcode/SKILL.md b/skills/html2video-for-mcode/SKILL.md deleted file mode 100644 index 988e0533..00000000 --- a/skills/html2video-for-mcode/SKILL.md +++ /dev/null @@ -1,250 +0,0 @@ ---- -name: html2video-for-mcode -description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻灯片 + TTS + ffmpeg 渲染),为 mcode 环境定制、也可在其他 Agent 环境用 mmx-cli 跑通。当用户想把内容做成视频、html 转 mp4、幻灯片口播视频、slides video、narrated video、一分钟介绍视频、抖音/视频号竖版视频时触发;也当症状出现时触发——TTS 念完留白过长、画面只有标题没有细节展开、部分元素 0 秒就入场、入场动画没渲染进视频、音画不同步、图片主体被裁到画面外、字幕糊在深色背景上、拼接后总时长不对、字体闪烁或方框。覆盖全流程:开工对齐(风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 SOP → HTML 分步入场 → 逐帧渲染 → ASR 反向校验。工具分两套:mcode 沙箱用 connector__matrix__*(TTS/ASR/音乐),其他环境用 mmx-cli(注意:mmx-cli 无 ASR 与音乐生成,见文末环境对照表)。 ---- - -# HTML 2 Video for mcode:脚本 → 口播成片 - -把一个主题/大纲变成一条可发布的 MP4(默认 1920×1080,可切 1080×1920 竖版):HTML 幻灯片(分步入场动画)+ 中文 TTS 口播 + ffmpeg 组装 + ASR 反向校验。 - -## 四条铁律(违反任何一条,产出必然返工) - -1. **所有时长只来自 ffprobe 实测,永远不手写。** 每张 slide 的时长 = 该段 TTS 实测时长 + 尾部留白(默认 0.8s)。不估、不凑整、不写死。 -2. **TTS 先于 HTML。** 先出音频、实测时长、算好每个视觉层的入场时刻,再写 HTML。动画延迟全部通过 CSS 变量 `--t1/--t2/--t3` 注入,HTML 里不写死秒数。这是消灭"念完留白过长"和"音画不同步"的根本手段。 -3. **每个 Gate 等用户确认,不跳步。** Gate 清单见下文工作流。 -4. **开工先对齐,不要闷头开跑。** 第一次响应就把下面这批问题一次问清(用户没答的项可用默认值,但**必须先问**),答案即后续所有 Gate 的验收基线: - -| 要问的 | 选项 / 默认 | -|---|---| -| 主题与受众 | 题材 + 给谁看(决定版式与信息密度) | -| **语言(必问)** | 口播说什么语言:**中文普通话 `zh`**(默认)/ **英语 `en`** / 粤语 `yue` / 其他 BCP-47。这项决定 4 件事:① 口播稿用哪种语言写;② **音色必须匹配语种**(中文用 `Chinese (Mandarin)_*`、英语用 `English_*`,错配会出怪腔调,写完必须 ASR 验语种);③ 字数/语速基准(中文字/秒 vs 英文词/秒,plan-timings 自动切换);④ ASR 识别语言头(zh 强制普通话,能识破粤语) | -| **风格与配色** | 先按受众给 2–3 个候选主题让用户挑(商务 `minimal-white`/`swiss-grid`/`corporate-clean`;编辑杂志 `editorial-serif`/`magazine-bold`;科技深色 `tokyo-night`/`catppuccin-mocha`/`nord`;消费生活 `xiaohongshu-white`/`soft-pastel`)。**再问一句配色偏好**:直接用主题自带主色,还是有品牌色要指定(给了就按 authoring.md 的"自定义主色"覆写 `--accent` 并跑 check-theme 验对比度) | -| **字幕** | 不要字幕 / 单语(**与口播同语言**,默认)/ **双语**(主行=口播语言,次行 `text2` = 另一语言,如中文口播配英文字幕)——决定要不要写 text2、要不要 `--no-subs` | -| **画布与平台** | 横屏 1920×1080(默认,适合 B站/官网)或竖版 1080×1920(抖音/视频号/小红书);竖版要换堆叠版式 | -| 时长 | 默认 ~60s(6–10s/张 × 8 张);短视频平台可压到 30s | -| 音色 | 温润男声 / 干练女声 / 其他;给出 3 个候选试听(见 tts-and-timing.md) | -| 素材边界 | 允许网络取官方图 / 只用官方素材 / 纯排版不出图 | - -## 目录与工具 - -技能自带四个脚本(直接以本技能目录为路径调用,项目目录作为参数,无需复制): - -| 脚本 | 作用 | -|---|---| -| `scripts/init-project.mjs <项目目录>` | 生成项目骨架:目录 + tokens.css + slide 模板 + script.json 契约 | -| `scripts/plan-timings.mjs <项目目录>` | ffprobe 实测每段 TTS → 每张时长、各 stage 入场时刻、**每句 clauses 时刻** → `build/timings.json` | -| `scripts/check-timing.mjs <项目目录> [--calibrate]` | 静音检测实测每句真实开口, 与估算对比;`--calibrate` 按实测校准 timings 后重渲染 | -| `scripts/check-theme.mjs <项目目录>` | 校验全部主题的 WCAG 对比度(正文/次级/字幕/accent-ink), 不达标退出码 1;新增主题必须过闸 | -| `scripts/prep-image.mjs --check <图...>` / `--crop [--ratio 16:9] [--anchor ...]` | 配图 SOP 的执行辅助:查尺寸与裁切风险;按锚点裁切(强制"裁掉 ≤20%、不放大补边") | -| `scripts/capture.mjs <项目目录> [--mode still\|motion] [--no-subs]` | Playwright 截图。still=终态单帧;motion=逐帧步进入场动画。**字幕默认烧录**(内容取自 clauses),`--no-subs` 关闭 | -| `scripts/build-video.mjs <项目目录> [--asr]` | 编码每张 → 拼接 → 音轨对位 → 合成 → 自检 + 出 `out/subs.srt`;`--asr` **按句**切分音频 + 校验清单 | - -环境要求:Node 18+(脚本用 fileURLToPath 保兼容, 不依赖 Node 20.11 的 import.meta.dirname)、`npm i playwright && npx playwright install chromium`(项目目录内)。ffmpeg/ffprobe 自动探测:PATH → node_modules(ffmpeg-static/ffprobe-static)→ 常见安装位置,找不到会给逐条诊断而不是莫名报错。 - -## 安装到其他 Agent 环境 - -本技能就是「一个文件夹 + 根目录 SKILL.md」的标准形态(frontmatter 的 `name` / `description` 已按规范写好),放到对应技能目录即可被识别: - -```bash -# Claude Code(个人级)/ OpenClaw / 其他兼容 AgentSkills 的工具 -cp -r html2video-for-mcode ~/.claude/skills/ # 或 ~/.openclaw/skills/ -# 项目级安装 -cp -r html2video-for-mcode <你的项目>/.claude/skills/ -# 或者把它推到一个 git 仓库后走 skills CLI -npx skills add --skill html2video-for-mcode -``` - -装完只需再补两件**依赖**(装在你的视频项目里,不是技能目录里): - -```bash -cd <你的视频项目目录> -npm i playwright && npx playwright install chromium # 截图用 -# ffmpeg 任选其一: winget install Gyan.FFmpeg / brew install ffmpeg / apt install ffmpeg -# 或: npm i ffmpeg-static ffprobe-static -``` - -**为什么技能装在别处也能跑**:`scripts/tools.mjs` 按「技能自身位置 → 项目目录 → 调用时的工作目录 → npm 全局」逐个锚点解析 `playwright`;ffmpeg/ffprobe 也是四级探测(PATH → 项目 node_modules → 技能上两级 → 常见安装位置)。所以"技能在 `~/.claude/skills/`、依赖装在项目里"是受支持的用法(已实测:技能放到项目树之外仍能出图)。 - -## 运行环境:两套工具链(同一套脚本,只换工具源) - -脚本层(截图 / 渲染 / 合成 / 校验 / 配图)完全环境无关;**只有 TTS、音乐、ASR 三件事依赖平台能力**。 - -| 能力 | mcode 沙箱(首选) | 其他 Agent 环境(Claude Code / OpenClaw / Cursor 等) | -|---|---|---| -| TTS 合成 | `mcode-tools connector call connector__matrix__batch_text_to_audio --args '{...}'`(≤10 条/批,主用);单条试音用 `connector__matrix__synthesize_speech` | `mmx speech synthesize --text "第一句口播。" --voice --speed 1.0 --out audio/01.mp3`;音色列表 `mmx speech voices` | -| 结果落盘 | `get_asset_url ` → 下载到 `audio/.mp3` | `--out` 直接写盘 | -| BGM 音乐 | `connector__matrix__batch_text_to_music`(≤5 条/批) | ⚠ **mmx-cli 无音乐生成** → 让用户提供音乐文件(确认授权后登记 MANIFEST),或跳过 BGM | -| ASR 反向校验 | `mcode-tools upload_temp_url` + `connector__matrix__listen_audio` | **`node scripts/asr.mjs <项目目录>`** —— 用同一把 API Key 直调 REST(`/v1/speech_to_text`),不依赖 mcode、也不用装 whisper;**会自动与 checklist 的预期文本比对并回填,数字/繁体字(粤语)不符直接判 ✗**。想用字级时间戳实测句开口:`--verify-timing` | -| 素材配图 | 内置浏览器 inspect 官网 DOM(首选)/ 官方 brand kit | 同上;抽象配图可用 `mmx image generate --prompt "..." --aspect-ratio 16:9 --n 3`(**仅限抽象概念图,禁止生成 logo / 截图 / 真人头像**),再按 `image-sources.md` 登记 | -| 调研 | `web_search` / `web_fetch` | `mmx search "关键词"` / `mmx text chat` | - -**mmx-cli 首次配置**(非 mcode 环境):`npm install -g mmx-cli` → `mmx auth login --api-key sk-xxx` → `mmx quota` 验证。401 多半是 region 不匹配:`mmx config set --key region --value cn|global`。脚本侧用 `MINIMAX_API_KEY`(必给)与 `MINIMAX_REGION=cn|global`(可选)对齐同一套身份。 - -**纪律不因环境而变**:时长仍由 ffprobe 实测、字幕仍来自 `clauses[]`、音色仍要试听并验语种(走上面的 asr.mjs)、Gate 一个都不跳。 - -## 工作流(7 阶段 · 6 Gate) - -``` -开工对齐(铁律 4, 不设 Gate 但必须先做) -Phase 0 信息搜集 → Gate 0 事实清单 -Phase 1 脚本设计 → Gate 1 逐张口播稿 -Phase 2 TTS + 对时 → Gate 2 试听 + 时长表 -Phase 3 素材收集 → Gate 3 素材清单 + 预览 + 合规确认 -Phase 4 HTML → Gate 4 终态截图 -Phase 5 渲染 + ASR → Gate 5 成片 -Phase 6 交付 -``` - -**顺序是脚手架,不是建议。** 口播稿定了才做 TTS;TTS 时长实测了才动素材和 HTML;素材清单过了 Gate 3 才写进页面;HTML 过了终态截图才渲染。改了口播稿 = 从 Phase 2 重跑(TTS 便宜,重做不贵;带着旧时长硬改才是灾难)。每个 Gate 向用户呈现"验收物清单"里明确的东西,没收到 OK 绝不前进——即使看起来显然,也要确认。 - -### Phase 0 · 信息搜集(条件执行) - -**完整方法见 `references/research.md`**(来源分级、query 设计、矛盾处理、notes 模板)。要点: - -- 判断标准很简单:成片里会出现具体**数字、日期、名称、引语或归属关系** → 必须搜集。通用/抒情/创意题材可跳过,但"看起来像事实"的句子仍要核实。 -- **四条硬规则**:① 关键数字**至少 2 个独立来源**(只有一个就用限定措辞或降级为约数);② **一手优先**(官方公告/财报/技术报告/政府统计),二手转述要回溯原文;③ 标注**口径与日期**(年化还是单季?周活还是月活?币种?);④ **查不到出处或无法判定的,进"不确定项",绝不进口播稿**。 -- **正文怎么取**:先 `web_fetch`;SPA/JS 渲染的官网与投资者关系页常只返回空壳,改用**内置浏览器或 Playwright 渲染后取正文**(最小命令见 references/research.md);招股书/财报是 PDF,以 PDF 原文数字为准。新闻稿与媒体转述冲突时以官方原文为准。 -- 输出 `research/notes.md`:每条含 来源 URL + 口径日期 + 等级 + 第二来源;另列"不确定项"与"不该进脚本的内容"。 -- **Gate 0**:事实清单给用户过 —— 重点让用户确认**数字、名称与口径**。 - -### Phase 1 · 脚本设计(内容量在这里控制) - -在 `script.json` 里逐张填写(契约文件,后续所有脚本都读它): - -```json -{ - "topic": "OpenAI 一分钟", "voice": "Chinese (Mandarin)_Gentleman", - "speed": {"default": 1.0, "first": 0.95, "last": 0.95}, - "fps": 30, "width": 1920, "height": 1080, - "bgm": "assets/bgm.mp3", - "slides": [ - {"id": "03", "layout": "bullets", "html": "03-lines.html", "audio": "03.mp3", - "title": "三大产品线", - "clauses": [ - {"stage": 1, "text": "三大产品线。"}, - {"stage": 2, "text": "ChatGPT 对话,GPT 系列模型,Sora 视频生成。", - "text2": "Chat, models, video generation."} - ]} - ] -} -``` - -可选字段:`clauses[].text2` = 双语字幕第二行(不写则纯中文字幕);顶层 `bgm` = `"assets/bgm.mp3"` 或 `{file, volume:0.12, fadeIn:1.5, fadeOut:2.5}`(写了由 build-video 自动循环+淡入淡出垫底)。 - -**内容量硬规则**(详细版见 `references/authoring.md`): - -- 中文口播 ≈ 4.8 字/秒。每张目标 6–10 秒 → 口播 25–48 字;首尾张 12–20 字。单张硬上限 60 字,超了拆两张。 -- **每张(除首尾)必须同时有"标题层 + 展开层",映射到不同 stage。只有标题、没有展开 = 违规,打回。** 口播也一样:每张至少两句——引入句(配标题层)+ 展开句(配细节层)。 -- `clauses` 的每个元素是一句口播,`stage` 声明"这句开口时,哪个视觉层该出现"。stage 数 ≈ clause 数,一一对应。 -- 版式共 17 种(原 8 + 补充 9:kpi-grid / stat-highlight / table / timeline / roadmap / comparison / flow-diagram / terminal / big-quote),每种画面必含项与字数区间见 `references/authoring.md` 的版式表。 -- 开工对齐时若用户要**双语字幕**,这里就要给每句写 `text2`(同句翻译,不重排语序,≤60 字符)。 - -**Gate 1**:逐张口播稿 + 版式分配给用户过。**未逐张 OK 不进 Phase 2。** - -### Phase 2 · TTS + 实测对时 - -TTS 走哪套工具见上文"运行环境"对照表(mcode 用 `connector__matrix__*`,其他环境用 `mmx speech synthesize`);命令模板与重试纪律见 `references/tts-and-timing.md`。要点:批量 ≤10 条,部分失败 sleep 10–30s 后**只重试失败项**;首尾张 speed 0.95(但 ≤10 字的短句保持 1.0,见该文件实测坑)。产物落 `audio/.mp3`。 - -然后对时: - -```bash -node <技能目录>/scripts/plan-timings.mjs <项目目录> -``` - -它会:ffprobe 每段实测时长 → 每张时长 = 实测 + 尾部留白(默认 0.8s,可在 slide 的 `tail` 字段调:紧凑 0.4 / 舒缓 1.2)→ 每个 stage 的入场时刻 = 该句口播按字数占比估算的开口时刻 − 0.2s(视觉略提前于语音,观感同步)→ 每句的开口时刻/时长写入 `clauses[]`(字幕、ASR 按句切分、对时校准共用)→ 写 `build/timings.json`,并输出警告(语速异常、超 15s、末 stage 离收尾太近、单句超 18 字字幕会换行)。 - -可选但推荐(尤其用户反馈过"音画不同步"时):`node <技能目录>/scripts/check-timing.mjs <项目目录>` 用静音检测实测每句真实开口,输出"估算 vs 实测"对比表;偏差大就 `--calibrate` 校准后删 `build/frames/` 重渲染。注意"体感不同步"也常是设计错位——大数字/主体图必须挂在**提到它的那句**的 stage(强同步原则,见 authoring.md)。 - -**Gate 2**:8 段音频试听 + plan-timings 的时长表给用户过。 - -### Phase 3 · 素材收集(合规在这里把关) - -按 `references/image-sources.md` 的配图 SOP 执行(核心原则:**主体不全/半截/比例差的图,先重搜全貌图,别硬裁硬用**),要点: - -1. 列需求清单(哪几张要图、要什么)。 -2. **优先级:官方渠道(brand kit / simple-icons / 官方文档截图)→ 内置浏览器或 Playwright 打开官网 inspect DOM 取官方资源(实测成功率最高)→ Wikipedia CC → 纯排版降级(数字卡 + 大字 + 引言,不用图)。** 旧 image-downloader(Bing)默认不用:中文冷门题材实测 5/5 返回无关图。 -3. 搜图加正向词(全景/全貌/正面/远景),下载前用缩略图筛:主体居中、比例接近、无水印无无关 logo、≥1200px;一张不合适就换,不凑合。 -4. 拿到图先跑 `node <技能>/scripts/prep-image.mjs --check <图>` 看尺寸与裁切风险;主体贴边就换图。 -5. 每张素材查合规:无水印、商标仅限合理引用语境、照片须可授权来源、截图引用官方文档并注明。 -6. 落盘 `assets/`,每个素材一行登记 `assets/MANIFEST.md`(内容/来源/许可)。 -7. **禁止凭空生成 logo、截图、头像、二维码;禁止带水印图直接入素材;禁止裸放 ``(必须套 `.img-frame`)。** - -**Gate 3**:素材清单表 + 每张素材的预览(缩略图/说明)给用户过,明确问一句"素材来源与授权没问题吗"。**用户过完才写 HTML。** - -### Phase 4 · HTML(分步入场在这里实现) - -每张一个文件,放 `slides/`,文件名与 script.json 的 `html` 字段一致。约定: - -- 根元素 `` 选主题(初版 `a|b|c`,另有 `minimal-white / swiss-grid / corporate-clean / editorial-serif / magazine-bold / tokyo-night / catppuccin-mocha / nord / xiaohongshu-white / soft-pastel`;选主题速查与对比度校验见 authoring.md);引 `tokens.css`;画面容器 `.stage`,含 `.brand` 角标与 `.slide-num` 页码。 -- **每个要入场的块加 `data-stage="1|2|3"` + 一个 fx 工具类**(`fx-up/fx-fade/fx-grow/fx-blur/fx-rise/fx-pop/fx-spotlight/fx-ripple/fx-glitch/fx-draw`;氛围类 `fx-pulse/fx-shimmer/fx-kenburns` 不加 data-stage);延迟不用写——管线按 `timings.json` 注入 `--t1/--t2/--t3`。同层错峰用容器 `.fx-stagger`(基准 `style="--stagger-base:var(--t3)"`)或内联 `style="animation-delay:calc(var(--t2) + 150ms)"`。 -- ⚠ **`data-stage` 必须与 fx 类同时用**(只有属性没有动画类会永远停在 opacity:0);延迟实现见 authoring.md 的"stage 延迟的实现原理",改动画时不要手写 `animation-delay: var(--tN)`。 -- 图片一律套 `.img-frame`(`.contain` 给截图/图表;`--img-ratio` 定比例;`--img-pos` 保主体;图注 `.img-cap` 写在框外)。 -- 氛围动画(无限循环的呼吸/漂浮)允许,但不能承载信息、不加 `data-stage`。 -- 禁用 transition 做入场(截图管线 seek 不到),只用 `@keyframes`。禁外部 Google Fonts(离线不稳),用系统字体栈(tokens.css 已配 CJK fallback)。 -- 素材只用 Gate 3 已确认的 `assets/` 清单,不新增未审素材。 -- ⚠ **写完 8 张后先跑静态检查再截图**:`node <技能>/scripts/check-slides.mjs <项目目录>` —— 抓未定义 CSS 变量(会导致文字隐形)、图片缺失/外链资源、data-stage 没配 fx 类、硬编码颜色。有 ✗ 就别截图,画面对但"看不见"是最难查的。 - -写完终态预览(最快路径,给 Gate 4 看): - -```bash -node <技能目录>/scripts/capture.mjs <项目目录> --mode still -``` - -**Gate 4**:`preview/.png` 逐张给用户过(看版式、字压、素材,不看动画时序——时序由 timings 保证)。 - -### Phase 5 · 渲染 + ASR 校验 - -```bash -node <技能目录>/scripts/capture.mjs <项目目录> --mode motion # 入场动画逐帧进视频 -node <技能目录>/scripts/build-video.mjs <项目目录> --asr -``` - -- `--mode motion`:逐帧步进(暂停全部动画 → 逐帧 seek → 截图 → 编码),动画窗口逐帧渲染、静止段自动补尾帧,时长精确。**字幕默认烧录**(内容取自 clauses、显示窗=该句开口到下句开口,画面底部居中,still 预览里不显示、成片里才有;`--no-subs` 关闭)。成本约 50–200ms/帧,8 张 × 30fps 约 3–6 分钟,预算进超时。赶时间可用 still 模式出片(动画不进视频,只有淡入淡出)。 -- build-video 自动:每张编码(统一参数)→ concat 拼接(时长漂移自动回退重编码)→ 音轨按每张实测时长 `apad` 对位 → **BGM 垫底(配了 bgm 才走:循环补满、淡入淡出、人声优先;混音失败自动退回纯人声)** → mux → ffprobe 时长校验 + 全量解码自检,不过关退出码非 0;同时输出 `out/subs.srt`(与烧录字幕同源同窗,中英双语按 clauses 的 text2 自动两行,供平台上传)。 -- `--asr`:**按句**切出 `asr/part--.mp3` + 生成 `asr/checklist.md`。转写与预期文本比对:数字、年份、产品名必须一致;同音字可容忍。**若某段转写混入上一句的开头,说明那句实际开口比估算晚——跑 check-timing 校准。** - - mcode:`mcode-tools upload_temp_url` 上传后交 `connector__matrix__listen_audio`。 - - **其他环境**:`MINIMAX_API_KEY=sk-xxx node scripts/asr.mjs <项目目录>` —— 直调 REST(同一把 Key),自动比对并回填 checklist;失败项(数字不符/繁体字)会以非 0 退出码报出。想拿更准的开口时刻:`--verify-timing`。 - - 不过关的 slide:改口播或重做该段 TTS → 重跑 plan-timings → 该张重渲染(帧目录删掉对应张即可)。 - -**Gate 5**:成片 `out/final.mp4` + ASR 校验表给用户过,含字幕可读性检查(静音播放一遍,字幕能否撑起理解)与 BGM 电平(人声是否始终清晰)。 - -### Phase 6 · 交付 - -``` -out/final.mp4 # 主交付 -out/slide-*.mp4 # 单段(可单独发布) -build/audio-timeline.wav # 对位后音轨 -preview/*.png slides/*.html slides/tokens.css -audio/*.mp3 assets/(含 MANIFEST.md) research/notes.md asr/(校验记录) -``` - -## 参考文件(按需读,别全读) - -- `references/authoring.md` — 17 种版式规范(每种画面必含项)+ 内容量表 + 入场系统用法(含 stage 延迟实现原理)+ 主题速查 + 竖版说明 + 可抄的 HTML 片段 -- `references/research.md` — 资料搜集(来源分级、交叉验证硬规则、query 设计、矛盾处理、notes 模板) -- `references/image-sources.md` — 配图与素材 SOP(三条取图路径、query 正/反词、两级筛选、图片框用法、裁切硬限制、视觉验证三件套、常见题材索引) -- `references/tts-and-timing.md` — mcode TTS connector 命令、重试纪律、对时算法、留白与语速调校、实测音色表 -- `references/render.md` — 渲染原理(为什么逐帧步进、字体怎么等)、字幕系统、BGM 混音、ffmpeg 手工命令、排错表 -- `THIRD-PARTY-NOTICES.md` — 第三方组件许可声明(10 套主题与部分 CSS 原语改编自 html-ppt-skill,MIT) - -## 常见症状 → 一句话诊断 - -| 症状 | 根因 | 动作 | -|---|---|---| -| TTS 念完画面还停很久 | 时长是估的不是实测,或 tail 过大 | 重跑 plan-timings;调该张 `tail` | -| 画面只有标题没有展开 | 违反内容量硬规则 | 补展开层 + 对应 clause,回 Phase 1 | -| 动画没进视频 | 用了 still 模式 | motion 模式重出 | -| 音画不同步(体感) | 三选一:手写了动画延迟 / 估算偏差 / 视觉锚点挂错句(强同步原则) | 先删手写延迟;跑 check-timing 拿实测数据,偏差大就 --calibrate;锚点错句则调 stage 映射 | -| 音色语种不对(粤语/繁体) | 平台 voice 标签错位,名称不可信 | Phase 1 试听必须带 ASR 验音(见 tts-and-timing.md 实测音色表) | -| 成片没字幕 | 用了 --no-subs 或 clauses 缺失 | capture 默认烧录;确认 timings.json 有 clauses | -| 要双语字幕 | — | clause 加 `text2`,画面两行 + SRT 双行自动出;第二行建议 ≤60 字符 | -| 想要背景音乐 | — | 顶层 `bgm` 配置,build-video 自动循环+淡入淡出垫底(默认音量 0.12);ASR 校验仍走纯人声轨 | -| 拼接后总时长不对 | 混用不同编码器参数的段 | 全部段由 build-video 统一编码;已自动回退重编码 | -| 中文方框 | 系统无 CJK 字体 | Linux 装 fonts-noto-cjk;或改用已装字体 | -| 报"找不到 ffprobe/ffmpeg" | 二进制不在 PATH | 脚本已自动探测 PATH→node_modules→常见位置;装 ffmpeg-static 或 winget install Gyan.FFmpeg | -| **一部分元素 0 秒就入场、一部分按时序** | 旧版 tokens.css 的延迟被 `.fx-*` 简写覆盖 | 换用新版 tokens.css(延迟走 `--fx-delay`);原理见 authoring.md | -| 图片主体被裁到画面外 / 图片撑破版式 | 裸放 ``,或 cover 配错比例 | 套 `.img-frame` + `--img-pos` 保主体;截图类改 `.contain`;主体贴边按 SOP 重搜图 | -| 字幕在深色主题下糊在背景里 | 主题没覆写字幕钩子 | 该主题加 `--sub-bg`(更深)+ `--sub-ring: 1px solid rgba(255,255,255,.16)`;跑 check-theme 验 | -| 要出竖版(抖音/视频号) | — | `script.json` 设 `width:1080, height:1920`,版式改堆叠(见 authoring.md 竖版章节) | -| 数字/文字明明写了却看不见 | 未定义 CSS 变量 + `-webkit-text-fill-color: transparent`,整条 background 失效 | 跑 `check-slides.mjs` 定位,补定义或写 `var(--x, 默认值)` | -| 图片显示 broken 图标 | 文件缺失,或 SVG 本身有问题(XML 错/依赖外部资源/缺尺寸) | `check-slides.mjs` 查路径;SVG 改 inline 进 HTML;capture 也会在渲染时点名哪张没加载 | diff --git a/skills/html2video-for-mcode/evals/evals.json b/skills/html2video-for-mcode/evals/evals.json deleted file mode 100644 index fb49c529..00000000 --- a/skills/html2video-for-mcode/evals/evals.json +++ /dev/null @@ -1,148 +0,0 @@ -{ - "skill_name": "html2video-for-mcode", - "evals": [ - { - "id": 0, - "name": "full-run-from-outline", - "prompt": "帮我把这份大纲做成一条一分钟的中文口播视频,1920×1080:介绍 OpenAI——十年时间、三大产品线、九亿周活、25B 年化营收、92% 财富500强渗透、一句 Altman 引语收尾。", - "expected_output": "走完整工作流且顺序正确: 事实性题材先搜集过 Gate 0 → 逐张填 script.json(内容量硬规则: 每张标题层+展开层, 口播 25-48 字)过 Gate 1 → TTS 落 audio/ → plan-timings 实测时长过 Gate 2 → 按 authoring.md 写 8 张 HTML(data-stage+fx-*, 不写死延迟) → capture still 过 Gate 3 → capture motion + build-video --asr → ASR 逐段比对过 Gate 4。全程不手写任何时长数字。", - "files": [] - }, - { - "id": 1, - "name": "long-tail-silence", - "prompt": "之前用别的方法做的幻灯片视频有个毛病:每页 TTS 念完以后画面还要呆等三四秒才切页,很拖。你这套能解决吗?", - "expected_output": "解释根因是时长靠估算而非实测。给出本流水线的解法: TTS 先行 + ffprobe 实测 + slide 时长=实测+tail(默认0.8s), tail 可按紧凑/舒缓调 0.4/1.2; 并说明 stage 入场时刻按字数占比从实测时长推出, 音画同源。", - "files": [] - }, - { - "id": 2, - "name": "title-only-slide", - "prompt": "我看你出的第 3 张只有一个大标题,口播念了一整句,画面什么都没有,能不能这页先出标题,然后跟着口播出几条细节?", - "expected_output": "判定为违反内容量硬规则(画面视觉块≥2 且分属不同 stage)。修正: 补展开层(如 bullets 三条)挂 data-stage=2 + fx-up, 口播拆成引入句(clause stage=1)+展开句(clause stage=2), 入场时刻由 plan-timings 按展开句开口时间自动注入, 不手写延迟。", - "files": [] - }, - { - "id": 3, - "name": "tts-partial-failure", - "prompt": "batch_text_to_audio 返回 8 条里成功 5 条失败 3 条, 报 rate limit, 怎么重试?", - "expected_output": "sleep 10-30s 后只把失败的 3 条重新组一个小批重试, 绝不重发整批; 全部落盘 audio/ 后重跑 plan-timings 再继续。", - "files": [] - }, - { - "id": 4, - "name": "asr-mismatch", - "prompt": "ASR 校验发现第 5 张转写里 '92%' 听成了 '九二', 数字对不上, 怎么处理?", - "expected_output": "改口播写法(如 '九成二')→ 只重做该段 TTS → 重跑 plan-timings → 删 build/frames/05 与 out/slide-05.mp4 → 该张重 capture + build-video, 不整片重做。若确认是同音字级微小差异可接受, 数字/产品名不一致必须返工。", - "files": [] - }, - { - "id": 5, - "name": "asset-gate-browser-route", - "prompt": "做一条介绍某中国 AI 公司的视频, 需要他们的 logo、CEO 照片和产品截图。素材这块你怎么弄?", - "expected_output": "按优先级执行: 官方渠道(brand kit/simple-icons/官方文档截图)优先; 缺的用内置浏览器或 Playwright 打开官网 inspect DOM 提取官方 CDN 资源(2026-09 实测中国 AI 公司 6/6 相关); image-downloader(Bing)默认不用(中文题材实测系统性返回无关图); 每个素材查合规(水印/商标语境/照片授权/截图出处), 落盘 assets/ 并逐条登记 MANIFEST.md; 素材清单+预览过 Gate 3, 明确问用户来源与授权是否 OK, 过了才写 HTML。禁止凭空造图; 无合适素材降级纯排版。", - "files": [] - }, - { - "id": 6, - "name": "subtitle-burn-and-srt", - "prompt": "成片需要有字幕(很多人在静音场景看), 还要能传抖音/B站。字幕怎么加?", - "expected_output": "不需要在 HTML 里写字幕: capture 默认把 timings.json 的 clauses 逐句烧进画面(底部居中胶囊, 显示窗=该句开口到下句开口, --no-subs 可关), build-video 同时输出 out/subs.srt 供平台上传。单句建议 ≤18 字(plan-timings 超限会警告)。Gate 5 用静音播放验收字幕可读性。", - "files": [] - }, - { - "id": 7, - "name": "voice-language-verification", - "prompt": "音色列表里选了个'新闻女声 Chinese (Mandarin)_News_Anchor', 标着普通话, 直接用可以吗?", - "expected_output": "不可以, 标签不可信(该音色 2026-09 实测输出粤语)。Phase 1 试听必须带 ASR 验音: 3 个候选各单条 TTS 同一句探针文案(含数字专名) → listen_audio 转写 → 检查简体/繁体、数字念对; 把实测语种标进试听清单给用户三选一。tts-and-timing.md 附实测音色表可作起点。", - "files": [] - }, - { - "id": 8, - "name": "bilingual-subtitles", - "prompt": "成片要中英双语字幕, 给海外同事看。怎么加?", - "expected_output": "不用改 HTML: script.json 每个 clause 加 text2(同一句的翻译, 不重排语序, ≤60 字符), capture 自动把字幕渲染成两行(中文主行+次行小字号), build-video 的 out/subs.srt 同步双行。plan-timings 对超长 text2 会预警。只做部分句子也可以。", - "files": [] - }, - { - "id": 9, - "name": "bgm-mix", - "prompt": "想给成片加个背景音乐, 别盖过人声。", - "expected_output": "用 connector__matrix__batch_text_to_music 生成无人声、可循环的 BGM 放 assets/bgm.mp3(或用户自备并确认授权), script.json 顶层加 bgm 配置; build-video 自动循环补满+淡入淡出+按 volume(默认 0.12)垫在人声下, 混音失败退回纯人声; ASR 校验仍走纯人声轨不受干扰。Gate 5 验人声清晰度。", - "files": [] - }, - { - "id": 10, - "name": "image-sourcing-sop", - "prompt": "配一张学校大门的实景图, 搜到的图主体都在画面边缘或者只有半截, 比例也不对。怎么办?", - "expected_output": "按 references/image-sources.md 的 SOP: 核心原则是'先重搜全貌图, 别硬裁硬用'。query 加正向词(全景/全貌/正面/远景)排除词(特写/近景/一角/半截); 下载前缩略图筛(主体居中/比例接近/无水印无无关logo/≥1200px), 不合适就换; 拿到图跑 prep-image.mjs --check; 图片一律套 .img-frame, 用 --img-pos 控制主体可见区或 .contain 留白, 不要裸放 img 或硬裁(裁切硬限: 裁掉≤20%、不放大补边); 都不行就换题材(数字卡/大字/引言)。", - "files": [] - }, - { - "id": 11, - "name": "vertical-canvas", - "prompt": "同一条内容还要出一版竖屏的, 发给视频号。要改多少东西?", - "expected_output": "只改 script.json 的 width:1080 / height:1920 —— 渲染管线自动注入 --stage-w/--stage-h, 字幕几何(位置/宽度/字号)跟随画布自动收窄, 不用改代码。但版式要重排: 横屏左右分栏改上下堆叠, 内容边距 ~90px, 标题降到 --fs-h2 量级, 每屏信息量减一档(3条→2条), 图片框用 3/4 或 1/1。竖版注意事项见 authoring.md。", - "files": [] - }, - { - "id": 12, - "name": "stage-delay-covered-by-shorthand", - "prompt": "成片里有些元素一开页就入场了, 另一些却按口播节奏入场, 时序很乱。是 plan-timings 估错了吗?", - "expected_output": "不像估算问题(估算偏差通常 <0.3s)。典型根因是 tokens.css 里入场延迟被覆盖: [data-stage=N] 的 animation-delay 与 .fx-* 的 animation 简写同为 (0,1,0) 优先级, 简写更靠后会把延迟重置为 0 —— 于是只写了内联 calc(var(--t2)+…) 的元素时序正确, 纯靠属性声明的元素 0 秒入场。修法: 用新版 tokens.css(延迟走 shorthand 的变量槽 --fx-delay), 不要把延迟写成独立的 animation-delay。先抽成片帧确认, 再决定要不要动算法。", - "files": [] - }, - { - "id": 13, - "name": "non-mcode-environment-via-mmx", - "prompt": "我在 Claude Code 里, 没有 mcode 的 connector。这个技能还能跑吗? TTS 和 ASR 怎么办?", - "expected_output": "能跑 —— 脚本层(截图/渲染/合成/校验/配图)完全环境无关, 只有 TTS/音乐/ASR 依赖平台。TTS 用 mmx-cli: mmx speech synthesize --text ... --voice --speed 1.0 --out audio/01.mp3(音色 mmx speech voices); ASR 用技能自带 node scripts/asr.mjs <项目>(同一把 API Key 直调 /v1/speech_to_text, 自动比对 checklist, 数字/繁体字不符判 ✗); 音乐生成 mmx-cli 没有 → 用户自备或跳过; 配图可用 mmx image(仅限抽象图, 禁生成 logo/截图/真人头像)。首次配置 npm i -g mmx-cli + mmx auth login, 401 时设 region。纪律不变: 时长实测、字幕来自 clauses、音色要试听验语种、Gate 不跳。", - "files": [] - }, - { - "id": 14, - "name": "asr-rest-and-timing-verify", - "prompt": "ASR 校验怎么跑? 我想顺便确认动画到底有没有对上口播。", - "expected_output": "build-video --asr 先按句切出 asr/part--.mp3; 然后 MINIMAX_API_KEY=sk-xxx node scripts/asr.mjs <项目> 直调 REST 转写, 自动与 checklist 预期文本比对并回填(数字/繁体字不符 → ✗ 非 0 退出; 超 500s/50MB 自动转码)。要确认对轴: node scripts/asr.mjs <项目> --verify-timing 用接口的字级时间戳(verbose_json + timestamp_level=word)实测每句开口, 与 timings.json 的 clauses[].start 对比; 偏差大就校准后重渲染。也可先用 check-timing.mjs(静音法, 离线免费)。", - "files": [] - }, - { - "id": 15, - "name": "english-narration", - "prompt": "这条视频要发给海外同事, 口播改成英语。要改哪些地方?", - "expected_output": "开工对齐时就该问清语言(铁律 4 的语言行: 中文/英语/粤语/其他)。改动四处: ① script.json 设 lang:'en'; ② 口播稿改写英语并按英文基准控量 —— plan-timings 自动切换(约 14 字符/秒、常见 9–18、字幕单行 42 字符, 每张约 14–26 词); ③ 音色换成 English_* 前缀(Chinese (Mandarin)_* 配英文会出怪腔调), 做完用 asr.mjs --language en 验语种; ④ 若开双语, 主行=英语口播、text2 放中文。排版、脚本、渲染流程都不用动; 数字写法反过来(口播 nine hundred million, 画面 900M)。", - "files": [] - }, - { - "id": 16, - "name": "subtitle-readability-verification", - "prompt": "字幕到底怎么验收? 我怕深色主题下看不清。", - "expected_output": "三件套缺一不可: ① 数值关 —— node scripts/check-theme.mjs <项目> 算\"字幕文字 vs 胶囊合成到背景后\"的对比度, <4.5:1 直接不通过(深色主题若不足, 覆写 --sub-bg 更深 + --sub-ring 分隔); ② 帧关 —— 字幕只在成片出现(still 会 finish 掉), 抽 motion 帧 crop 底部条带看: 是否出现/是否被裁/是否与页码重叠(capture 在缺 clauses 时会告警, 不会静默出无字幕片); ③ 静音关(Gate 5)—— 关声看整片, 只靠字幕能否看懂。" - }, - { - "id": 17, - "name": "custom-brand-accent", - "prompt": "客户给了品牌色 #C2410C, 能用上吗?", - "expected_output": "能: 主题主色是令牌。在项目自己的 slides/tokens.css 末尾追加 [data-theme=\"...\"] 覆写 --accent/--accent-ink/--accent-2/--accent-3/--grad(改项目副本不动技能模板), 然后必须跑 check-theme.mjs 验对比度 —— accent-ink 对 accent ≥3:1 才可接受(实测 #C2410C+白字 5.18:1 通过; 浅色如 #FFB84D+白字只有 1.72:1 会被判 ✗ 并退出码 1)。深色主题另看 --sub-bg 字幕胶囊是否仍分得开。" - }, - { - "id": 18, - "name": "undefined-css-variable-invisible-text", - "prompt": "成片里那个大数字 42% 完全看不见, 但 HTML 里明明写了, 也不报错。为什么?", - "expected_output": "未定义 CSS 变量: background:linear-gradient(...,var(--coral-a),var(--coral-b)) 里有个变量没定义 → 整条声明计算为 none; 该元素又用了 -webkit-text-fill-color:transparent(渐变数字的标准写法) → 文字彻底隐形且无任何报错。常见诱因是从外部单页 HTML 抄配色, 变量名不在 tokens.css 里。修法: 项目 tokens.css 补定义, 或写 var(--coral-a, #ED3366) 带默认值; 之后跑 node scripts/check-slides.mjs <项目> 复验(它会静态抓出所有未定义且无 fallback 的变量)。" - }, - { - "id": 19, - "name": "broken-image-and-svg-under-file", - "prompt": "架构图的 SVG 在截图里显示成 broken image, 是 file:// 的兼容性问题吗?", - "expected_output": "不是协议问题 —— 实测 file:// 下 正常加载(400px), PNG 同样正常。broken 的真因通常是 SVG 文件自身: XML 有误、依赖外部资源或 Web 字体(inline 到 img 时被禁)、缺 width/height、或下载失败存成了 HTML。建议一律 inline 进 HTML(彻底绕开)。防线有两道: check-slides.mjs 静态查图片路径缺失/外链并提示 SVG 建议 inline; capture 渲染时会点名哪张图没加载出来(以前是静默出 broken 图标)。" - }, - { - "id": 21, - "name": "research-hard-rules", - "prompt": "这条片子里有个数字\"九亿周活\", 我直接写进口播稿行不行?", - "expected_output": "先过 references/research.md 的四条硬规则: ① 关键数字至少 2 个独立来源(只有一个来源就改限定措辞\"官方口径为…\"或降级为约数); ② 优先一手(官方公告/财报/技术报告/政府统计), 二手转述要回溯原文; ③ 标注口径与日期(周活≠月活, 年化≠单季, 币种要写清); ④ 查不到出处或无法判定 → 进 research/notes.md 的\"不确定项\", 绝不进口播稿。数字类断言还要注意时效(优先 12 个月内)与\"最快/第一\"这类需要新来源的表述。Gate 0 让用户确认数字/名称/口径。", - "files": [] - } - ] -} diff --git a/skills/html2video-for-mcode/references/authoring.md b/skills/html2video-for-mcode/references/authoring.md deleted file mode 100644 index 144a247c..00000000 --- a/skills/html2video-for-mcode/references/authoring.md +++ /dev/null @@ -1,307 +0,0 @@ -# HTML 设计与内容量规范 - -## 核心原则:每张 slide 是"一句主张 + 一组展开" - -成片最常见的两个败笔,都在authoring阶段就能拦住: - -1. **只有标题没有展开** —— 观众 7 秒只看到一行大字,信息量为零。 -2. **文字一次性全糊上屏** —— 没有节奏,口播念到后半句时视觉早已无话可说。 - -解法是强制的三层结构:**标题层(stage 1)先出 → 展开层(stage 2)在口播展开句开口时入 → 视觉锚点(stage 3,可选)压轴**。口播与画面是同一句话的两个声部。 - -## 内容量表(硬规则) - -中文口播 ≈ 4.8 字/秒(speed 1.0),每张目标 6–10s。**英语按 ~14 字符/秒(约 150 词/分)**——下表字数是中文口径;英语项目按"词"折半看:每张 14–26 词、硬上限 34 词(plan-timings 会按 `lang` 自动切换基准与预警区间)。 - -| 版式 | 用途 | 画面必含(缺一即违规) | 口播字数 | clauses 结构 | -|---|---|---|---|---| -| title-hero | 开场主张 | 大标题 + 副题一行 + logo/主题角标 | 12–20 | 1 句 | -| statement | 单点断言 | 断言大字(s1) + 支撑小字(s2) + 视觉锚点(s3 可选) | 18–28 | 2 句 | -| bullets | 并列要点 | 标题(s1) + 3–4 条要点,每条 ≤14 字(s2) | 25–40 | 2 句 | -| compare | 前后对比 | 标题(s1) + 左右两栏各 ≥1 项 + 分隔(s2) | 22–35 | 2 句 | -| data-viz | 数据说服 | 标题(s1) + ≥2 个数字卡/图表(s2) | 25–40 | 2 句 | -| code | 演示调用 | 标题(s1) + 代码块 ≥3 行 + 高亮注释(s2) | 25–40 | 2 句 | -| quote | 引语转场 | 引语大字(s1) + 署名/头衔(s2) | 20–32 | 2 句 | -| closing | 收束 | 一句收束 + logo/CTA | 12–20 | 1 句 | - -判定规则(写给执行者,自查用): - -- 除 title-hero/closing 外,**画面视觉块 ≥2 个且分属不同 stage**;"光标题 + 页码"直接打回。 -- **强同步原则(2026-09 实测教训):每个 stage 的视觉锚点(大数字/关键词/主体图)必须写在触发它的那句口播里,不要放在上一句。**反例:口播第一句就是"三亿人在用",但"3 亿"数字卡挂在 stage 2——观众听到"三亿"时画面没反应,数字卡进场时口播已讲到下一句,体感就是"动画对不上"。正例:第一句只铺垫("它有三个你可能用过的产品"),"3 亿"作为第二句开头,数字卡挂 stage 2 → 声画同时砸出"3 亿",强同步。 -- 口播 clauses 与 stage 的映射:**stage k 的视觉在 clause k 开口时入场**。stage 数 ≈ clause 数;多出的视觉层用 script.json 的 `stageTimes` 显式给时刻。 -- 单张口播硬上限 60 字;超了说明这张在干两张的活,拆。字幕场景下单句建议 ≤18 字(capture 烧录字幕单行展示);双语时第二行(text2)≤60 字符。 -- 数字、专有名词在口播里用中文读法(“九亿”而非“900000000”),画面上才出现阿拉伯数字。(英语项目相反:口播写英文数字读法 “nine hundred million”,画面用 900M。) -- 双语字幕(可选):每句给 `text2` 即自动两行(主行中文 + 次行小字号);text2 是同一句的翻译,不要重排语序。 - -## 分步入场系统(怎么写 HTML) - -tokens.css 已内置,规则只有四条: - -1. 要入场的块:`data-stage="1|2|3"` + fx 工具类(`fx-up/fx-fade/fx-grow/fx-draw`)。 -2. **不写任何延迟秒数**。`--t1/--t2/--t3` 由渲染管线按 TTS 实测时长注入;浏览器里直接打开时用 tokens 里的占位默认值(0/0.8/2.0s)。 -3. 同层错峰(如三条 bullets 依次出现):内联 `style="animation-delay:calc(var(--t2) + 150ms)"`,第二块 +300ms,以此类推。 -4. 氛围动画(`fx-pulse` 等无限循环)只能用于装饰(光晕、呼吸点),**不加 data-stage、不承载信息**——它们不参与时长计算,静止截图中可能停在任意相位。 - -禁令:入场效果不要用 transition(管线逐帧 seek 不到 transition 状态);不要用 JS 定时器编排(setTimeout 驱动的分层,帧步进模式下不会发生);字体不要引外部 Google Fonts(离线环境直接方框),用 tokens.css 的系统字体栈。 - -### 写 HTML 的三条硬检查(写完立刻跑 `node <技能>/scripts/check-slides.mjs <项目>`) - -这三条都是 2026-09 实测踩过的静默故障 —— 画面明显不对,但流水线一路报成功: - -1. **变量必须先定义,或带 fallback**。`background: linear-gradient(135deg, var(--coral-a), var(--coral-b))` 里只要有一个变量没定义,整条声明计算为 `none`;若该元素又用了 `-webkit-text-fill-color: transparent`(渐变数字的常规写法),**文字会彻底隐形且不报错**。从别的单页 HTML 抄配色时最容易踩:抄来的变量名(如 `--coral-*`)不在 tokens.css 里。要么在项目 tokens.css 补定义,要么写 `var(--coral-a, #ED3366)` 给默认值。 -2. **图片用相对路径且文件必须存在**;`.svg` 建议 **inline 进 HTML**。实测 file:// 下 `` 能正常加载(400px),所以 broken 图标通常不是协议问题,而是 SVG 本身:XML 有误、依赖外部资源/Web 字体、缺 width/height、或下载失败存成了 HTML。inline 一次解决全部。 -3. **不要把外部单页的配色/变量整套抄进来**。可抄版式结构,配色必须落到 tokens 变量(`--accent` / `--good` / `--grad` …);硬编码 hex/rgb 换主题时会串色。 - -## 主题与视觉基调 - -``: - -- **a · 米白+暖橙**(默认,通用商务):bg `#FAFAF7`,accent `#FF5B2E`,衬线大标题。讲述、观点、人物向。 -- **b · 深色+绿**(科技感):bg `#0E0F12`,accent `#10A37F`,高对比数据。产品、AI、开发者向。 -- **c · 黑白+蓝**(极简高端):bg `#FFFFFF`,accent `#1F6FEB`。财务、报告、严肃向。 - -字号纪律:一张 slide 上最多两个层级同屏(标题 + 一个次级);正文 ≥30px,再小就是给审查者找茬。安全边距:内容离边缘 ≥96px(brand/slide-num 除外)。 - -## 素材获取(Phase 3 · Gate 3) - -流程:列需求清单(哪几张要图、要什么)→ 按下面的优先级取材 → 合规自查 → 落盘 `assets/` 并登记 `MANIFEST.md` → **Gate 3 给用户过**。 - -**获取优先级(逐级降级,不硬找):** - -1. **官方渠道(首选)**:logo 用官方 brand kit 或 simple-icons(可直接 curl);产品截图截官方 help/文档;数据用官方报告。合规性最强,事实性题材尽量停在这级。 -2. **内置浏览器 / Playwright 访问官网提取(2026-09 实测推荐)**:用 agent 的内置浏览器或 Playwright 打开官网/官方新闻页,inspect DOM 提取 ``/`` 资源(校验 src 是官方 CDN 域名再下载)。实测中国 AI 公司题材 6/6 全相关,远好于搜索引擎图搜。开工对齐时用户选了"只用官方素材/纯排版"则整级跳过。 -3. **纯排版降级(永远可用的兜底)**:大字 + 数字卡 + 配色 + 版式本身的表现力。宁缺毋假——没有合适素材就用这级,画面依然成立。 - -> ⚠ **image-downloader(Bing 搜图)默认不用。** 2026-09-17 实测:中文 AI 公司题材 5/5 关键词返回的全部是无关图(Bing 端返回"猜你喜欢"推荐卡,脚本正则照单全收)。除非题材是英文大众品牌且愿意逐张人工筛,否则不要走这条路。 - -**合规自查(每个素材过一遍):** - -- 无水印。有水印的候选:换一张,或裁到水印外;裁不掉就不用。 -- 商标/logo 只在"谈论该品牌"的合理引用语境使用,不做装饰滥用。 -- 照片须来自可授权来源(官方新闻图、CC 授权、免费图库);查不到授权的明星/人物照宁可用纯文字引语卡。 -- 截图注明出处(官方文档名 + 日期)。 - -**登记与使用:** - -- `assets/MANIFEST.md` 每个素材一行:文件名 / 内容 / 来源 URL 或渠道 / 许可。Gate 3 连同素材预览一起给用户,明确问"来源与授权没问题吗"。 -- **禁止凭空生成 logo、截图、头像、二维码;禁止带水印图入素材。** -- **图片必须套框,禁止裸放 ``**:用 `.img-frame`(比例与裁切归框)/ `.img-frame.contain`(截图、图表、带文字的图必须用,不裁切居中留白)/ `--img-ratio`(框比例,不必强求 16:9)/ `--img-pos: top|center|bottom`(控制主体可见区,**替代硬裁的首选**)/ `.img-scrim`(压暗垫白字)/ `.img-cap`(图注,写在框外)/ `.img-tag`(角标)。选图与裁切的完整 SOP 见 `references/image-sources.md`。 -- 图片入 HTML 用相对路径 `../assets/xxx.png`;capture 会等图片加载完(每张上限 4s,加载不动就跳过——所以素材必须先落本地,绝不引外链图)。 - -## 版式片段(可抄) - -以下片段都基于 tokens.css,只列 `
` 内部结构;外层 `.stage`、`.brand`、`.slide-num` 照 `_template.html`。 - -**bullets** -```html -
-

三大产品线

-
    -
  • ChatGPT — 对话
  • -
  • GPT 系列 — 模型
  • -
  • Sora — 视频生成
  • -
-
-``` - -**compare** -```html -
-

三年,高中生 → 研究员

-
-
-

2023 · GPT-3.5

会聊天,常出错

-
-
-

今天 · GPT-5.6

可托付研究级任务

-
-
-
-``` - -**data-viz** -```html -
-

九亿人每周在用

-
-
-
9亿
周活跃用户
-
-
-
$25B
年化营收
-
-
-
92%
财富500强渗透
-
-
-
-``` - -**code** -```html -
-

一行调用

-
const answer = await openai.responses.create({
-  model: "gpt-5.6",
-  input: "帮我总结这份报告",
-});
-console.log(answer.output_text);
-
-``` - -**quote** -```html -
-
“我们想打造的是比人类更聪明的工具,而不是替代人类。”
-

— Sam Altman, OpenAI CEO

-
-``` - -**title-hero / closing**:大标题居中 + 一行副题/CTA,单 stage 即可;closing 可加 logo `fx-grow`。 - -布局 CSS(`.layout/.cols/.cards/.num/.cap` 等)每张 slide 内联在 ` - - -
- A · ${topic || '主题名'} -
-

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 — 语种必须与音色匹配, 且影响语速基准与字幕行宽校验。', - topic, - lang: "zh", - voice: 'Chinese (Mandarin)_Gentleman', - speed: { default: 1.0, first: 0.95, last: 0.95 }, - 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: '' }] }, - ], -}; - -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 3 终态预览 - node scripts/capture.mjs "${dir.replace(/\\/g, '/')}" --mode motion # 动画帧 - node scripts/build-video.mjs "${dir.replace(/\\/g, '/')}" --asr`); diff --git a/skills/html2video-for-mcode/scripts/plan-timings.mjs b/skills/html2video-for-mcode/scripts/plan-timings.mjs deleted file mode 100644 index dec19768..00000000 --- a/skills/html2video-for-mcode/scripts/plan-timings.mjs +++ /dev/null @@ -1,102 +0,0 @@ -#!/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 } 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')); -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; - -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 = path.join(dir, 'audio', s.audio ?? `${s.id}.mp3`); - 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 重估`); - 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(path.join(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⁻¹) → build/timings.json`); -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/skills/html2video-for-mcode/scripts/prep-image.mjs b/skills/html2video-for-mcode/scripts/prep-image.mjs deleted file mode 100644 index ef80d05a..00000000 --- a/skills/html2video-for-mcode/scripts/prep-image.mjs +++ /dev/null @@ -1,95 +0,0 @@ -#!/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; }; - -const FFMPEG = requireTool('ffmpeg'); -const FFPROBE = requireTool('ffprobe'); -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]'); 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/skills/html2video-for-mcode/scripts/tools.mjs b/skills/html2video-for-mcode/scripts/tools.mjs deleted file mode 100644 index 79570aee..00000000 --- a/skills/html2video-for-mcode/scripts/tools.mjs +++ /dev/null @@ -1,99 +0,0 @@ -// html2video-for-mcode · ffmpeg/ffprobe 探测: PATH → node_modules → 常见安装位置 -// 找不到时给可诊断的提示, 而不是让下游脚本报莫名其妙的错。 -import fs from 'node:fs'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -const IS_WIN = process.platform === 'win32'; -const EXE = IS_WIN ? '.exe' : ''; - -function onPath(name) { - const r = spawnSync(name + EXE, ['-version'], { encoding: 'utf8', windowsHide: true }); - return r.status === 0 && r.stdout ? name + EXE : null; -} - -const SELF_DIR = path.dirname(fileURLToPath(import.meta.url)); // Node 20.11 以下没有 import.meta.dirname, 用 fileURLToPath 保兼容 - -function candidatePaths(name) { - const dirs = []; - // 调用方项目目录(若给了)与技能自身位置的两个上层(仓库根/项目根都可能装了 node_modules) - const anchors = [process.env.KIT_PROJECT_DIR, SELF_DIR, path.resolve(SELF_DIR, '..'), path.resolve(SELF_DIR, '../..')] - .filter(Boolean); - for (const base of anchors) { - dirs.push(path.join(base, 'node_modules', 'ffmpeg-static')); - dirs.push(path.join(base, 'node_modules', 'ffprobe-static', 'bin', process.platform, process.arch)); - } - if (IS_WIN) { - const la = process.env.LOCALAPPDATA; - if (la) { - dirs.push(path.join(la, 'Microsoft', 'WinGet', 'Links')); - dirs.push(path.join(la, 'scoop', 'shims')); - } - dirs.push('C:\\ffmpeg\\bin'); - } - return dirs.map(d => path.join(d, name + EXE)); -} - -export function findTool(name, projectDir) { - try { const p = onPath(name); if (p) return p; } catch { /* not on PATH */ } - if (projectDir) { - const local = [ - path.join(projectDir, 'node_modules', 'ffmpeg-static', name + EXE), - path.join(projectDir, 'node_modules', 'ffprobe-static', 'bin', process.platform, process.arch, name + EXE), - ]; - for (const p of local) if (fs.existsSync(p)) return p; - } - for (const p of candidatePaths(name)) if (fs.existsSync(p)) return p; - return null; -} - -export function requireTool(name, projectDir) { - const p = findTool(name, projectDir); - if (!p) { - console.error([ - `✗ 找不到 ${name}。按顺序排查:`, - ` 1. 系统 PATH 上没有 ${name}`, - ` 2. 项目/仓库 node_modules 里没有 (试试: npm i ffmpeg-static ffprobe-static)`, - ` 3. 常见安装位置 (winget Links / scoop / C:\\ffmpeg\\bin) 也没有`, - ` 最快解法: winget install Gyan.FFmpeg (Windows) · brew install ffmpeg (macOS) · apt install ffmpeg (Linux)`, - ].join('\n')); - process.exit(2); // 2 = 环境错误, 与 1 (业务错误) 区分 - } - return p; -} - -// ── Node 包解析(playwright) ────────────────────────────────────── -// 技能通常装在 ~/.claude/skills/ 或 ~/.openclaw/skills/ —— 不在项目树里, -// 而 Node 的 import 是按"脚本所在位置"向上找 node_modules 的, 于是会出现 -// "项目里明明装了 playwright 却报找不到"。这里按多个锚点依次尝试。 -import { createRequire } from 'node:module'; -import { pathToFileURL, fileURLToPath } from 'node:url'; - -const PKG_ANCHORS = () => { - const list = [process.env.KIT_PROJECT_DIR, process.cwd()]; - // 全局 npm root(全局装的 playwright 也能用) - for (const npm of IS_WIN ? ['npm.cmd', 'npm'] : ['npm']) { - try { - const r = spawnSync(npm, ['root', '-g'], { encoding: 'utf8', windowsHide: true }); - if (r.status === 0 && r.stdout) { list.push(r.stdout.trim()); break; } - } catch { /* ignore */ } - } - return [...new Set(list.filter(Boolean))]; -}; - -export async function loadPackage(name, { projectDir } = {}) { - // CJS 包(如 playwright)动态 import 后命名导出可能拿不到, 统一解包出真实模块对象 - const unwrap = mod => (mod && (mod.chromium || mod.default?.chromium)) ? (mod.chromium ? mod : mod.default) : mod; - // 1) 脚本自身位置(技能装在项目内, 或全局 node_modules 可被解析时) - try { const m = unwrap(await import(name)); if (m?.default !== undefined || m) return m; } catch { /* 继续找 */ } - // 2) 项目目录 / 调用目录 / 全局 npm root, 逐个用 createRequire 解析真实入口 - const anchors = [...new Set([projectDir, ...PKG_ANCHORS()].filter(Boolean))]; - for (const base of anchors) { - try { - const req = createRequire(path.join(base, 'package.json')); - const entry = req.resolve(name); - return unwrap(await import(pathToFileURL(entry).href)); - } catch { /* 试下一个 */ } - } - return null; -} From 11bd0c7170a4da946ce0cfb4a293bb4efc916ec3 Mon Sep 17 00:00:00 2001 From: Hahaha Date: Thu, 17 Sep 2026 23:25:53 +0800 Subject: [PATCH 04/24] chore: restore repo-root file to upstream content (my earlier commit overwrote it) --- README.md | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..37e4c6cb --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +

+ MiniMax Code Plugins — one folder, one pull request, a new agent superpower +

+ +

+ 简体中文 · + Contribute · + Plugin contract · + Security +

+ +

+ Build status + Agent Plugins 1.0 + Apache-2.0 license + Pull requests welcome +

+ +## One folder is the release + +MiniMax Code Plugins is the community home for Agent Plugins that run in MiniMax Code. Put a +portable Plugin under `plugins//`, open a pull request, and let CI check +the package users will actually install. + +```text +fork → create → build → check → pull request → discover +``` + +No second repository. No catalog JSON. No commit pin to copy. Your Plugin source, docs, review, and +history live together. + +## Ship your first Plugin + +```bash +git clone https://github.com//MiniMax-Code-Plugins.git +cd MiniMax-Code-Plugins +npm install +npm run create -- /my-first-plugin +``` + +The scaffold gives you a Skill-first Plugin: + +```text +plugins//my-first-plugin/ +├── plugin.json +├── README.md +├── LICENSE +└── skills/ + └── my-first-plugin/ + └── SKILL.md +``` + +Replace every `TODO`, then run: + +```bash +npm run check +``` + +If it passes, open one pull request for that Plugin. Start with +[`CONTRIBUTING.md`](CONTRIBUTING.md) when you want the full review checklist. + +## What can a Plugin add? + +### Skills + +Package reusable instructions, workflows, and domain knowledge. Skills are the fastest path from a +good prompt pattern to a capability anyone can install. + +### MCP servers + +Connect MiniMax Code to local tools or remote services with `stdio`, `streamable-http`, or `sse`. +Dependencies, accounts, network destinations, and data handling must be visible before install. + +### Both + +Use a Skill to teach the workflow and MCP to provide the tools. The portable package stays small: + +```text +plugin-root/ +├── plugin.json +├── mcp.json # optional +└── skills/ # optional +``` + +This repository is for **Agent capabilities**. TUI Extensions are a separate system and are not +loaded from this package format. + +## The gate is simple + +A contribution must: + +- live at `plugins//`; +- include `plugin.json`, `README.md`, and `LICENSE`; +- expose at least one valid Skill or MCP server; +- document a copyable example, requirements, network access, and data use; +- contain no secrets, private endpoints, hidden telemetry, native binaries, or symlinks; +- pass `npm run check` and human review. + +Passing review means the Plugin is available as community software. It is not a MiniMax endorsement +or a complete security audit. Read the source and requested capabilities before installing. + +## Explore the project + +- [`plugins/`](plugins/) — community Plugin source +- [`examples/hello-mcode`](examples/hello-mcode/) — smallest Skill Plugin +- [`examples/hello-mcode-mcp`](examples/hello-mcode-mcp/) — dependency-free stdio MCP +- [`docs/plugin-compatibility.md`](docs/plugin-compatibility.md) — exact supported contract +- [`docs/security-model.md`](docs/security-model.md) — validation and trust model +- [`docs/architecture.md`](docs/architecture.md) — hosted contribution architecture +- [`GOVERNANCE.md`](GOVERNANCE.md) — decisions and maintainer responsibilities + +## Community preview + +The contract is intentionally narrow while MiniMax Code's public Plugin surface stabilizes. Hooks, +custom Agents, Commands, LSP, Apps, generic OAuth, and TUI Extensions are not advertised as current +Agent Plugin capabilities. + +Bring one useful capability. Make the example undeniable. Ship it in one pull request. + +## License + +Repository tooling and documentation use Apache-2.0. Every hosted Plugin includes and declares its +own open-source license. From 96f0458a51e95bdba99a444e891f4a3926934a0c Mon Sep 17 00:00:00 2001 From: Hahaha Date: Thu, 17 Sep 2026 23:25:58 +0800 Subject: [PATCH 05/24] chore: restore repo-root file to upstream content (my earlier commit overwrote it) --- README.zh-CN.md | 118 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 README.zh-CN.md diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..b77b9a4a --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,118 @@ +

+ MiniMax Code Plugins:一个目录、一个 PR,给 Agent 一项新能力 +

+ +

+ English · + 贡献指南 · + Plugin 契约 · + 安全 +

+ +

+ 构建状态 + Agent Plugins 1.0 + Apache-2.0 License + 欢迎提交 PR +

+ +## 一个目录,就是一个发布单元 + +MiniMax Code Plugins 是 MiniMax Code Agent Plugin 的社区入口。把 Plugin 放进 +`plugins//`,提交一个 PR,CI 会直接检查用户最终安装的那份代码。 + +```text +Fork → 创建 → 开发 → 校验 → Pull Request → 被发现 +``` + +不用另建仓库,不用写 Catalog JSON,也不用手抄 commit SHA。源码、文档、Review 和修改历史都在 +一个地方。 + +## 30 秒创建第一个 Plugin + +```bash +git clone https://github.com/<你的用户名>/MiniMax-Code-Plugins.git +cd MiniMax-Code-Plugins +npm install +npm run create -- <你的用户名>/my-first-plugin +``` + +脚手架会生成一个 Skill-first Plugin: + +```text +plugins/<你的用户名>/my-first-plugin/ +├── plugin.json +├── README.md +├── LICENSE +└── skills/ + └── my-first-plugin/ + └── SKILL.md +``` + +替换全部 `TODO`,然后运行: + +```bash +npm run check +``` + +通过后,为这个 Plugin 提交一个 PR。完整 Review 要求见 +[`CONTRIBUTING.md`](CONTRIBUTING.md)。 + +## Plugin 能给 Agent 加什么? + +### Skills + +把可复用的指令、工作流和领域知识打包。一个验证过的提示词方法,可以直接变成任何人都能安装的能力。 + +### MCP Servers + +通过 `stdio`、`streamable-http` 或 `sse` 连接本地工具和远程服务。依赖、账号、网络目标和数据处理必须 +在安装前说清楚。 + +### Skill + MCP + +Skill 教会 Agent 怎么做,MCP 给它真正的工具。可移植包结构保持简单: + +```text +plugin-root/ +├── plugin.json +├── mcp.json # 可选 +└── skills/ # 可选 +``` + +这个仓库只承接 **Agent 能力**。TUI Extension 是另一套独立扩展体系,不使用这里的包格式和加载流程。 + +## 门槛也很简单 + +一个贡献必须: + +- 位于 `plugins//`; +- 包含 `plugin.json`、`README.md` 和 `LICENSE`; +- 至少提供一个有效的 Skill 或 MCP Server; +- 写清示例、依赖、网络访问和数据用途; +- 不包含密钥、私有地址、隐藏遥测、原生二进制或 symlink; +- 通过 `npm run check` 和人工 Review。 + +通过 Review 代表它可以作为社区软件被发现,不代表 MiniMax 背书或已经完成完整安全审计。安装前仍需阅读 +源码和能力声明。 + +## 逛逛这个仓库 + +- [`plugins/`](plugins/):社区 Plugin 源码 +- [`examples/hello-mcode`](examples/hello-mcode/):最小 Skill Plugin +- [`examples/hello-mcode-mcp`](examples/hello-mcode-mcp/):零依赖 stdio MCP +- [`docs/plugin-compatibility.md`](docs/plugin-compatibility.md):当前支持的精确契约 +- [`docs/security-model.md`](docs/security-model.md):校验与信任模型 +- [`docs/architecture.md`](docs/architecture.md):中央托管架构 +- [`GOVERNANCE.md`](GOVERNANCE.md):决策与维护者职责 + +## Community Preview + +MiniMax Code 的公开 Plugin 能力仍在稳定中,所以首版契约刻意保持克制。Hooks、自定义 Agent、Commands、 +LSP、Apps、通用 OAuth 和 TUI Extension 暂不作为当前 Agent Plugin 能力宣传。 + +带来一个真的有用的能力,给出一个无法误解的示例,然后用一个 PR 把它发布出来。 + +## License + +仓库工具和文档使用 Apache-2.0。每个托管 Plugin 都必须包含并声明自己的开源 License。 From 701a14abdd3470b7a2c52f89befafbf6be63d0ea Mon Sep 17 00:00:00 2001 From: Hahaha Date: Thu, 17 Sep 2026 23:26:02 +0800 Subject: [PATCH 06/24] chore: restore repo-root file to upstream content (my earlier commit overwrote it) --- LICENSE | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..125be1b8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright 2026 MCode Plugins contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From 737ee96bf217e0e22dd6f7d7891ea8c10cb74649 Mon Sep 17 00:00:00 2001 From: Hahaha Date: Thu, 17 Sep 2026 23:36:47 +0800 Subject: [PATCH 07/24] =?UTF-8?q?v1.0.1:=20=E9=A2=86=E5=9F=9F=E4=B8=8E?= =?UTF-8?q?=E5=90=88=E8=A7=84(=E5=8F=97=E7=9B=91=E7=AE=A1=E9=A2=98?= =?UTF-8?q?=E6=9D=90)=E2=80=94=E2=80=94=E5=85=8D=E8=B4=A3=E5=A3=B0?= =?UTF-8?q?=E6=98=8E/=E6=95=B0=E5=AD=97=E5=8F=A3=E5=BE=84/=E6=B6=A8?= =?UTF-8?q?=E8=B7=8C=E8=89=B2=20+=20=E6=95=B4=E7=89=87=E7=BA=A7=E9=A2=86?= =?UTF-8?q?=E5=9F=9F=E8=87=AA=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.claude-plugin/plugin.json | 2 +- .../Wzdhehe/html2video-for-mcode/CHANGELOG.md | 10 +++ .../Wzdhehe/html2video-for-mcode/plugin.json | 2 +- .../skills/html2video-for-mcode/SKILL.md | 12 ++- .../html2video-for-mcode/evals/evals.json | 14 ++++ .../references/authoring.md | 12 ++- .../references/compliance.md | 83 +++++++++++++++++++ .../references/research.md | 5 ++ .../scripts/check-slides.mjs | 24 ++++++ .../scripts/init-project.mjs | 10 +++ 10 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/compliance.md diff --git a/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json index 7298ffd1..e9eac207 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json +++ b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "html2video-for-mcode", - "version": "1.0.0", + "version": "1.0.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 index 732f2ca9..23d5b828 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md +++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.1 — 2026-09-17 + +**新增:领域与合规(受监管题材)** + +- `references/compliance.md`(新)— 领域确认问法(题材 + 受众 + 是否受监管)、财经口播三条红线(不给操作建议 / 不预测价格 / 不编数据图形)、数字三要件(**口径 + 币种 + 时点**)、涨跌色按受众翻转(A 股/港股 = 红涨绿跌)、免责声明写法与位置(`.disclaimer`,停留 ≥3s,口播不念也不进字幕)、医疗 / 法律 / 政务 / 广告法要点、Gate 收尾检查清单 +- **开工对齐**第一批问题从"主题与受众"升级为"**主题与领域**":受监管题材(财经投研 / 医疗健康 / 法律 / 政务政策 / 营销效果宣称)必须多问一句"要不要免责声明与数据出处标注"(默认要),并把结论记进 Gate 0 与 `research/notes.md` +- `tokens.css` 新增 `--up` / `--down` 涨跌专用令牌(默认 = `--good` / `--bad`)与 `.disclaimer` / `.disclaimer-box` 原语(纯附加,旧项目与既有主题不受影响) +- `check-slides.mjs` 新增**整片级领域自查**:命中多个财经/投研关键词却没有免责或出处行 → 给出提示(提示而非错误;用户已明确不要免责可忽略) +- `research.md` 增"先确认领域"与财经/投研题材坑(同比≠环比、GAAP≠非GAAP、把旧时点当"目前");`authoring.md` 增免责声明片段、kpi-grid 改用 `var(--up)/var(--down)`、主题速查增财经行;`evals` 增 2 条(财经开局确认、涨跌色按受众) + ## 1.0.0 — 2026-09-17 首个公开版本。 diff --git a/plugins/Wzdhehe/html2video-for-mcode/plugin.json b/plugins/Wzdhehe/html2video-for-mcode/plugin.json index 7af3f7f6..edc53b4f 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/plugin.json +++ b/plugins/Wzdhehe/html2video-for-mcode/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "html2video-for-mcode", - "version": "1.0.0", + "version": "1.0.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.", "author": { "name": "Wzdhehe", diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md index 988e0533..231474f7 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md @@ -1,6 +1,6 @@ --- name: html2video-for-mcode -description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻灯片 + TTS + ffmpeg 渲染),为 mcode 环境定制、也可在其他 Agent 环境用 mmx-cli 跑通。当用户想把内容做成视频、html 转 mp4、幻灯片口播视频、slides video、narrated video、一分钟介绍视频、抖音/视频号竖版视频时触发;也当症状出现时触发——TTS 念完留白过长、画面只有标题没有细节展开、部分元素 0 秒就入场、入场动画没渲染进视频、音画不同步、图片主体被裁到画面外、字幕糊在深色背景上、拼接后总时长不对、字体闪烁或方框。覆盖全流程:开工对齐(风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 SOP → HTML 分步入场 → 逐帧渲染 → ASR 反向校验。工具分两套:mcode 沙箱用 connector__matrix__*(TTS/ASR/音乐),其他环境用 mmx-cli(注意:mmx-cli 无 ASR 与音乐生成,见文末环境对照表)。 +description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻灯片 + TTS + ffmpeg 渲染),为 mcode 环境定制、也可在其他 Agent 环境用 mmx-cli 跑通。当用户想把内容做成视频、html 转 mp4、幻灯片口播视频、slides video、narrated video、一分钟介绍视频、抖音/视频号竖版视频时触发;财经/财报/投研/医疗/政策等**受监管题材**的视频同样触发(需要免责声明与数据口径处理)。也当症状出现时触发——TTS 念完留白过长、画面只有标题没有细节展开、部分元素 0 秒就入场、入场动画没渲染进视频、音画不同步、图片主体被裁到画面外、字幕糊在深色背景上、拼接后总时长不对、字体闪烁或方框、财经片忘了写免责声明或涨跌色用反。覆盖全流程:开工对齐(领域与免责/风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 SOP → HTML 分步入场 → 逐帧渲染 → ASR 反向校验。工具分两套:mcode 沙箱用 connector__matrix__*(TTS/ASR/音乐),其他环境用 mmx-cli(注意:mmx-cli 无 ASR 与音乐生成,见文末环境对照表)。 --- # HTML 2 Video for mcode:脚本 → 口播成片 @@ -16,7 +16,7 @@ description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻 | 要问的 | 选项 / 默认 | |---|---| -| 主题与受众 | 题材 + 给谁看(决定版式与信息密度) | +| **主题与领域** | 题材 + 给谁看 + **属于哪个领域**(财经/商业 · 科技/产品 · 科普/教育 · 品牌/营销 · 文化/历史 · 生活/消费 · 政务/政策),领域决定版式与信息密度;**落在受监管领域(财经投研 / 医疗健康 / 法律 / 政务政策 / 营销效果宣称)必须再问一句"要不要免责声明与数据出处标注"**(默认要)。这一问定三件事:结尾要不要 `.disclaimer` 行、涨跌色要不要按受众翻转、数字要不要带"截至"日期 —— 详见 `references/compliance.md` | | **语言(必问)** | 口播说什么语言:**中文普通话 `zh`**(默认)/ **英语 `en`** / 粤语 `yue` / 其他 BCP-47。这项决定 4 件事:① 口播稿用哪种语言写;② **音色必须匹配语种**(中文用 `Chinese (Mandarin)_*`、英语用 `English_*`,错配会出怪腔调,写完必须 ASR 验语种);③ 字数/语速基准(中文字/秒 vs 英文词/秒,plan-timings 自动切换);④ ASR 识别语言头(zh 强制普通话,能识破粤语) | | **风格与配色** | 先按受众给 2–3 个候选主题让用户挑(商务 `minimal-white`/`swiss-grid`/`corporate-clean`;编辑杂志 `editorial-serif`/`magazine-bold`;科技深色 `tokyo-night`/`catppuccin-mocha`/`nord`;消费生活 `xiaohongshu-white`/`soft-pastel`)。**再问一句配色偏好**:直接用主题自带主色,还是有品牌色要指定(给了就按 authoring.md 的"自定义主色"覆写 `--accent` 并跑 check-theme 验对比度) | | **字幕** | 不要字幕 / 单语(**与口播同语言**,默认)/ **双语**(主行=口播语言,次行 `text2` = 另一语言,如中文口播配英文字幕)——决定要不要写 text2、要不要 `--no-subs` | @@ -104,8 +104,9 @@ Phase 6 交付 - 判断标准很简单:成片里会出现具体**数字、日期、名称、引语或归属关系** → 必须搜集。通用/抒情/创意题材可跳过,但"看起来像事实"的句子仍要核实。 - **四条硬规则**:① 关键数字**至少 2 个独立来源**(只有一个就用限定措辞或降级为约数);② **一手优先**(官方公告/财报/技术报告/政府统计),二手转述要回溯原文;③ 标注**口径与日期**(年化还是单季?周活还是月活?币种?);④ **查不到出处或无法判定的,进"不确定项",绝不进口播稿**。 - **正文怎么取**:先 `web_fetch`;SPA/JS 渲染的官网与投资者关系页常只返回空壳,改用**内置浏览器或 Playwright 渲染后取正文**(最小命令见 references/research.md);招股书/财报是 PDF,以 PDF 原文数字为准。新闻稿与媒体转述冲突时以官方原文为准。 -- 输出 `research/notes.md`:每条含 来源 URL + 口径日期 + 等级 + 第二来源;另列"不确定项"与"不该进脚本的内容"。 -- **Gate 0**:事实清单给用户过 —— 重点让用户确认**数字、名称与口径**。 +- **受监管领域**(财经/医疗/法律/政策/营销宣称 → 读 `references/compliance.md`)另加三道约束:① 财经数字必须带**口径 + 币种 + 时点**("二季度营收"而不是"目前营收");② 口播**不给操作建议、不预测价格、不用绝对化用语**;③ 免责声明与数据出处(要不要、怎么写、放哪)在 Gate 0 就跟用户敲定,不留到成片阶段返工。 +- 输出 `research/notes.md`:每条含 来源 URL + 口径日期 + 等级 + 第二来源;另列"不确定项"与"不该进脚本的内容";受监管题材再记一行**领域 + 免责口径的确认结果**。 +- **Gate 0**:事实清单给用户过 —— 重点让用户确认**数字、名称与口径**;受监管题材把免责声明、数据时点、涨跌色一起确认掉。 ### Phase 1 · 脚本设计(内容量在这里控制) @@ -221,6 +222,7 @@ audio/*.mp3 assets/(含 MANIFEST.md) research/notes.md asr/(校验记录) ## 参考文件(按需读,别全读) - `references/authoring.md` — 17 种版式规范(每种画面必含项)+ 内容量表 + 入场系统用法(含 stage 延迟实现原理)+ 主题速查 + 竖版说明 + 可抄的 HTML 片段 +- `references/compliance.md` — 领域与合规(受监管题材必读):领域确认问法、财经口播红线、数字三要件、涨跌色按受众翻转、免责声明写法与位置、医疗/法律/广告法要点、收尾检查清单 - `references/research.md` — 资料搜集(来源分级、交叉验证硬规则、query 设计、矛盾处理、notes 模板) - `references/image-sources.md` — 配图与素材 SOP(三条取图路径、query 正/反词、两级筛选、图片框用法、裁切硬限制、视觉验证三件套、常见题材索引) - `references/tts-and-timing.md` — mcode TTS connector 命令、重试纪律、对时算法、留白与语速调校、实测音色表 @@ -248,3 +250,5 @@ audio/*.mp3 assets/(含 MANIFEST.md) research/notes.md asr/(校验记录) | 要出竖版(抖音/视频号) | — | `script.json` 设 `width:1080, height:1920`,版式改堆叠(见 authoring.md 竖版章节) | | 数字/文字明明写了却看不见 | 未定义 CSS 变量 + `-webkit-text-fill-color: transparent`,整条 background 失效 | 跑 `check-slides.mjs` 定位,补定义或写 `var(--x, 默认值)` | | 图片显示 broken 图标 | 文件缺失,或 SVG 本身有问题(XML 错/依赖外部资源/缺尺寸) | `check-slides.mjs` 查路径;SVG 改 inline 进 HTML;capture 也会在渲染时点名哪张没加载 | +| 财经片没免责声明 / 涨跌色反了 / 数字被质疑口径 | 开工没确认领域,默认色与默认措辞直接用了 | 读 `references/compliance.md`:结尾补 `.disclaimer` 行(停留 ≥3s)、指标卡改 `var(--up)/var(--down)` 并按受众市场翻转、每个数字补口径+币种+时点 | +| 不知道要不要写免责声明 / 算不算受监管 | 领域没确认 | 开工对齐第一批问题里问"领域 + 要不要免责";财经投研、医疗、法律、政策、营销效果宣称默认要。`check-slides.mjs` 命中财经关键词却没见免责行时会提示 | diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json index fb49c529..cdc1447c 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json @@ -143,6 +143,20 @@ "prompt": "这条片子里有个数字\"九亿周活\", 我直接写进口播稿行不行?", "expected_output": "先过 references/research.md 的四条硬规则: ① 关键数字至少 2 个独立来源(只有一个来源就改限定措辞\"官方口径为…\"或降级为约数); ② 优先一手(官方公告/财报/技术报告/政府统计), 二手转述要回溯原文; ③ 标注口径与日期(周活≠月活, 年化≠单季, 币种要写清); ④ 查不到出处或无法判定 → 进 research/notes.md 的\"不确定项\", 绝不进口播稿。数字类断言还要注意时效(优先 12 个月内)与\"最快/第一\"这类需要新来源的表述。Gate 0 让用户确认数字/名称/口径。", "files": [] + }, + { + "id": 22, + "name": "regulated-domain-kickoff", + "prompt": "帮我做一条港股上市公司二季度财报的 60 秒口播视频, 中文, 重点讲营收和净利的变化。", + "expected_output": "开工对齐除了常规项(语言/风格/字幕/画布/音色/素材边界), 必须先确认领域并问免责: 题材落在财经/投研 → 读 references/compliance.md, 问\"要不要免责声明与数据出处标注\"(默认要), 并按受众确认涨跌色(A股/港股=红涨绿跌, 指标卡改用 var(--up)/var(--down) 并在项目 tokens.css 覆写, 而不是默认的绿涨红跌)。数字必须带三要件: 口径(同比/环比、单季/累计、GAAP/非GAAP)+ 币种(港元)+ 时点(财报披露日, 不能写\"目前营收\")。口播不给操作建议、不预测股价、不用绝对化用语。结尾用 .disclaimer 小字行(停留 ≥3s, 口播不念、不进字幕)。check-slides.mjs 命中财经关键词而全片没有免责行时会给整片级提示。", + "files": [] + }, + { + "id": 23, + "name": "finance-updown-color", + "prompt": "这条财经视频里我用绿色表示上涨、红色表示下跌, 可以吧?", + "expected_output": "要看受众市场。tokens.css 的语义色默认 --good 绿 / --bad 红, 即欧美读法(绿涨红跌); A 股/港股受众是红涨绿跌, 用反了整片观感颠倒。做法: 指标卡一律写 var(--up)/var(--down)(模板已定义 --up:var(--good)/--down:var(--bad)), 需要翻转时在项目 slides/tokens.css 末尾追加 :root{--up:#D92B2B;--down:#12A150}(旧项目缺这两个变量要补定义, check-slides 会报未定义)。同时在 Gate 3 素材确认时问清受众市场, 并把结论记进 research/notes.md。依据见 references/compliance.md。", + "files": [] } ] } diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md index 144a247c..46b1afe7 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md @@ -169,7 +169,7 @@ console.log(answer.output_text); | 版式 | 用途 | 画面必含(缺一即违规) | 口播字数 | clauses | |---|---|---|---|---| -| kpi-grid | 一组指标 | 标题(s1) + 3–4 张指标卡,带涨跌与语义色(s2) | 25–40 | 2 句 | +| kpi-grid | 一组指标 | 标题(s1) + 3–4 张指标卡,带涨跌与语义色(s2)。**涨跌色用 `var(--up)/var(--down)`,A 股/港股受众要按红涨绿跌覆写(见 compliance.md)** | 25–40 | 2 句 | | stat-highlight | 一个数字定生死 | 巨数字(≥200px,可用 .gradient-text)(s1) + 一句说明(s2) | 15–28 | 2 句 | | table | 多行对比 | 标题(s1) + ≥3 行数据表,数值右对齐(s2) | 25–40 | 2 句 | | timeline | 发展历程 | 标题(s1) + ≥4 个时间点(横轴 + 标签)(s2) | 25–40 | 2 句 | @@ -180,16 +180,21 @@ console.log(answer.output_text); | big-quote | 大引语转场 | 引语大字(s1) + 署名(s2) | 20–32 | 2 句 | ```html - +

REVENUE

1248K
-

↑ 38% YoY

+

↑ 38% YoY

RETENTION

74%

→ 持平

+ +

+ 本视频仅为信息分享,不构成任何投资建议。数据来源:公司 2026 年 Q2 财报(8 月披露),截至 2026-06-30。 +

+
92% @@ -260,6 +265,7 @@ console.log(answer.output_text); | 场景 | 主题 | |---|---| | 商务汇报 / 融资 / 财报 | `minimal-white` `swiss-grid` `corporate-clean`(另有初版 `a` 米白暖橙) | +| 财经 / 投研(受监管) | 同上三套(数据密度高、色彩克制);**选定后按 `compliance.md` 处理免责行与涨跌色,别只挑主题就开跑** | | 编辑 / 杂志 / 讲述 | `editorial-serif` `magazine-bold` | | 科技 / AI / 开发者(深色) | `tokyo-night` `catppuccin-mocha` `nord`(另有初版 `b` 深色科技绿) | | 消费 / 小红书 / 生活 | `xiaohongshu-white` `soft-pastel`(另有初版 `c` 极简蓝,白底通用) | diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/compliance.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/compliance.md new file mode 100644 index 00000000..dbcbab98 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/compliance.md @@ -0,0 +1,83 @@ +# 领域与合规(受监管题材必读) + +**什么时候读**:开工对齐时先确认这条片属于**哪个领域**。题材落在**受监管领域**——财经/投研、医疗健康、法律、政务政策、营销效果宣称——就把这一节读完再动笔。 + +它不是法律意见,而是把"这类片子必然被追问的几件事"变成流程里的检查项:免责行要不要出、数字要带什么、涨跌色往哪个方向用。这三件事错了,画面再漂亮也得返工。 + +## 1. 领域确认(开工对齐的第一批问题里就带这条) + +**要问的两个问题**(别自己默认,写进 Gate 0 与数字口径一起给用户确认): + +| 问 | 选项 / 默认 | +|---|---| +| 这条片属于什么领域? | 财经/商业 · 科技/产品 · 科普/教育 · 品牌/营销 · 文化/历史 · 生活/消费 · 政务/政策 —— 领域决定画面要带什么(免责行、数据出处、单位)、口播能说到哪一步、涨跌色怎么用 | +| 要不要免责声明与出处标注? | 受监管领域**默认要**;纯观点/品牌/生活类可以不要。"不要"也要用户明说,记进 Gate 0 基线与 `research/notes.md` | + +**为什么必须问**:免责声明是**内容属性**不是法律意见 —— 它不能免除责任,但缺了它在财经/医疗语境下几乎一定被要求补上,而且返工点在最贵的 Phase 5(成片)。 + +## 2. 财经 / 投研(最容易出事的一类) + +### 2.1 口播三条红线 + +1. **不给操作建议、不预测价格**。"建议关注/逢低买入/有望涨到/稳赚/翻倍空间"一律不写。要表达预期就归给主体:"某机构给出的目标价区间是…",并注明机构与发布日期。 +2. **不把观点说成事实**。"最受欢迎的基金""增长确定性高"必须带主语(公司自己说/机构评级)或改成可核查表述(见 `research.md` 硬规则 5)。 +3. **不编数字、不编图表**。K 线、财报截图、榜单一律来自官方来源;任何数据图形**禁止 AI 生成**(全局铁律,见 `image-sources.md`)。 + +### 2.2 每个数字必须带三样东西 + +| 要件 | 例 | +|---|---| +| **口径** | 年化 / 单季 / 累计;GAAP / 非 GAAP;同比 / 环比;复权口径 | +| **币种与单位** | 美元 / 人民币 / 港元;亿 / 万亿 | +| **时点或期间** | "截至 2026-06-30 收盘""2026 年 Q2 财报(8 月披露)" | + +财务数据有**披露滞后**:不要写"目前营收 X",要写"公司 8 月披露的二季度营收 X"。股价、汇率这类秒级数据若要出现,必须写"截至 YYYY-MM-DD 收盘"并标来源,同时**在免责行里点明数据时点**(视频会长期留存,数字必然过期)。 + +### 2.3 涨跌色:按受众翻转(我们的默认对 A 股受众是错的) + +`tokens.css` 的语义色默认 `--good` 绿 / `--bad` 红,即欧美读法(绿涨红跌)。**A 股/港股受众是红涨绿跌** —— 这一处用反,整片观感直接颠倒。做法: + +```css +/* 追加在项目 slides/tokens.css 末尾: A股/港股受众 = 红涨绿跌 */ +:root { --up: #D92B2B; --down: #12A150; } +``` + +- 指标卡一律写 **`var(--up)` / `var(--down)`**(别直接写 `var(--good)/var(--bad)`);模板已定义 `--up: var(--good); --down: var(--bad)`,旧项目缺这两个变量就把上面那行补上(`check-slides.mjs` 会报变量未定义)。 +- 美股 / 英语受众保持默认,不要翻转。 +- **Gate 3 素材确认时顺带问一句受众市场**,和涨跌色一起定。 + +### 2.4 免责声明怎么写、放哪 + +- **固定模板,别临场发挥**:"本视频仅为信息分享,不构成任何投资建议。数据来源:<来源>,截至 <日期>。"(医疗同理:不构成医疗建议,诊疗请咨询执业医师。) +- **位置**:`closing` 张用 `.disclaimer` 小字行(底部或右下),**停留 ≥3 秒**;长片可在 `title-hero` 或第一张数据页再加一条同样的小字。 +- **口播不念免责**(也不烧进字幕):合规文字由画面承载,口播保持节奏;用户坚持要念就照念,但仍不进字幕。 +- 免责声明**不替代核实**:数字错了,加十行免责也救不回来。 + +## 3. 医疗 / 健康 + +- 不宣称疗效、不做诊断、不推荐具体药品或治疗;要提就提"权威指南/说明书怎么写",并注明版本与日期。 +- 免责行:"本视频为科普信息,不构成医疗建议,具体诊疗请咨询执业医师。" +- 数据来源等级同样按财经标准(优先指南、药监局/药典、同行评议论文),**不接受自媒体转述**。 + +## 4. 法律 / 政务 / 政策 + +- 引法条要**原文 + 生效日期**;区分"现行有效"与"征求意见稿"。 +- 不做个案解读("你这情况可以这样主张"→ 改为"法条原文为…,个案需咨询执业律师")。 +- 政策/统计只引**官方发布**并注意修订说明(见 `research.md`)。 + +## 5. 品牌 / 营销(广告法) + +- **绝对化用语是雷区**:最/第一/顶级/唯一/国家级/100% 有效/永不 —— 除有权威依据或用户提供了依据文件,一律不用。 +- 效果宣称要有依据("效率提升 3 倍"→ 注明测试条件与来源),无依据就改定性。 +- 他人商标、肖像、代言关系**未经授权不用**;比较性宣称必须可核查且注明比较对象与口径。 + +## 6. 收尾检查清单(Gate 0 与 Gate 5 各过一遍) + +- [ ] 领域已确认;**用户明确说了要 / 不要免责声明**(记进 notes) +- [ ] 每个数字都带口径 + 币种 + 时点(财经) +- [ ] 无操作建议、无价格预测、无绝对化用语 +- [ ] 涨跌色与受众市场一致(A 股/港股 = 红涨绿跌,且用的是 `var(--up)/var(--down)`) +- [ ] 若确认要:免责行出现在画面、停留 ≥3s、不进字幕口播 +- [ ] 数据图形全部来自官方来源,无 AI 生成图 + +相关:`research.md`(来源分级与口径)、`authoring.md`(版式与 `.disclaimer` 片段)、`image-sources.md`(素材合规)。 diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/research.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/research.md index 345cca50..3c7c0096 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/research.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/research.md @@ -103,11 +103,16 @@ node -e "const {chromium}=require('playwright');(async()=>{ Gate 0 呈现时,**重点让用户确认数字、名称与口径**,而不是通读全表。 +## 先确认领域(受监管题材的额外动作) + +开工对齐时若题材属于**财经/投研、医疗健康、法律、政务政策、营销效果宣称**,除了本节的口径纪律,还要走 `compliance.md`:确认**要不要免责声明与数据出处标注**(默认要)、数字补齐"口径 + 币种 + 时点"三要件、涨跌色按受众市场(红涨绿跌 / 绿涨红跌)确定。这一步在 Gate 0 就要有结论,不要留到成片。 + ## 常见题材的坑 | 题材 | 坑 | 做法 | |---|---|---| | 公司 / 融资 / IPO | 媒体把"估值/营收/融资额"混用;币种与年化口径不明 | 认招股书或官方公告口径;写清币种与区间 | +| 财经 / 投研(股票、基金、宏观数据) | 把观点写成事实、拿旧时点当"目前"、口径混用(同比≠环比、GAAP≠非GAAP);涨跌色用错受众读法 | 每个数字带**口径 + 币种 + 时点**,股价类写"截至 YYYY-MM-DD 收盘";不给操作建议与价格预测;免责与涨跌色见 `compliance.md` | | 模型 / 技术指标 | 榜单版本不同,分数不可比 | 认官方技术报告 + 明确榜单名称与版本 | | 历史 / 人物 | 引语出处不明、二手转述走形 | 认权威传记/档案;引语必须有原文出处 | | 政策 / 统计 | 用旧版数据,忽略修订 | 认政府或国际组织原始发布,注意修订说明 | diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-slides.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-slides.mjs index 835f74f7..a5222e07 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-slides.mjs +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-slides.mjs @@ -10,6 +10,8 @@ // 3. 硬编码颜色(#hex / rgb() / hsl()) —— 换主题时会串色, 且说明配色没落到 token // 4. 外链资源(http/https 的 src/href) —— 离线沙箱会失败 + 引入 FOUT 风险 // 5. data-stage 没配 fx-* 类 —— 元素会永远停在 opacity:0(除非放进 .fx-stagger 容器) +// 6. 整片级领域自查 —— 命中多个财经/投研关键词却全片没有免责或出处行 → 提示(不是错误), +// 提醒确认领域与免责口径(见 references/compliance.md); 用户明确不要免责时可忽略 import fs from 'node:fs'; import path from 'node:path'; @@ -41,6 +43,7 @@ const RUNTIME_VARS = new Set([ const slides = script.slides.filter(s => !idsFilter || idsFilter.includes(s.id)); let errors = 0, warns = 0; const report = []; +const deckText = []; // 整片文本(HTML 去标签 + 口播稿), 用于第 6 项领域自查 for (const s of slides) { const file = path.join(slidesDir, s.html ?? `${s.id}.html`); @@ -52,6 +55,7 @@ for (const s of slides) { } const html = fs.readFileSync(file, 'utf8'); const localDefs = new Set([...html.matchAll(/(--[\w-]+)\s*:/g)].map(m => m[1])); + deckText.push({ id: s.id, text: html.replace(/<[^>]*>/g, ' ') }); // 1. 未定义变量(只报没有 fallback 的) for (const m of html.matchAll(/var\(\s*(--[\w-]+)\s*(,)?/g)) { @@ -99,6 +103,25 @@ for (const s of slides) { } } +// 6. 受监管领域自查(整片级): 像财经/投研内容却没见免责或出处标注 → 提示, 不是错误 +for (const s of slides) { + const cls = (s.clauses ?? []).map(c => `${c.text ?? ''} ${c.text2 ?? ''}`).join(' '); + if (cls.trim()) deckText.push({ id: s.id, text: cls }); +} +const FIN_WORDS = ['投资建议', '股价', '涨跌', '涨停', '跌停', '营收', '净利润', '毛利率', '市值', '市盈率', + '招股', '财报', '年报', '季报', '基金', '收益率', '汇率', 'A股', '港股', '美股', '募资', '估值', '分红']; +const hits = new Map(); // 词 → 首次出现的 slide id +for (const { id, text } of deckText) { + for (const w of FIN_WORDS) if (!hits.has(w) && text.includes(w)) hits.set(w, id); +} +const hasDisclaimerLine = deckText.some(({ text }) => /不构成|仅供参考|风险提示|免责/.test(text)); +const deckNotes = []; +if (hits.size >= 2 && !hasDisclaimerLine) { + const words = [...hits.keys()]; + deckNotes.push(`像财经/投研内容(命中 ${words.slice(0, 5).join('、')}${words.length > 5 ? ' 等' : ''}, 见 ${hits.get(words[0])} 等张), 但整片没找到免责/出处行 — 若题材受监管(财经/医疗/法律/政策/营销宣称), 结尾补 .disclaimer 行、确认涨跌色(A股=红涨绿跌)、并给数字补口径+币种+时点, 见 references/compliance.md; 用户已明确说不要免责则忽略本条`); + warns++; +} + if (!QUIET) { const bySlide = new Map(); for (const r of report) { if (!bySlide.has(r.id)) bySlide.set(r.id, []); bySlide.get(r.id).push(r); } @@ -108,6 +131,7 @@ if (!QUIET) { console.log(`${rs.some(r => r.level === 'error') ? '✗' : '⚠'} ${s.id}`); for (const r of rs) console.log(` ${r.level === 'error' ? '✗' : '⚠'} ${r.msg}`); } + for (const n of deckNotes) console.log(`⚠ 整片: ${n}`); } console.log(`\n静态检查: ${slides.length} 张 · ✗ ${errors} 项错误 / ⚠ ${warns} 项提示`); if (errors) { diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/init-project.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/init-project.mjs index 92e4cb38..23f84141 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/init-project.mjs +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/init-project.mjs @@ -42,6 +42,11 @@ const TOKENS_CSS = `/* html2video-for-mcode 设计令牌 · 由 init-project 生 /* ── 颜色 · 语义(数据页涨跌/正负) ── */ --good: #1AAF6C; --warn: #C98500; --bad: #C13A3A; + /* ── 涨跌专用(指标卡写 var(--up)/var(--down), 别直接写 good/bad): + 默认 = 欧美读法(绿涨红跌); A股/港股受众在项目 tokens.css 末尾覆写成红涨绿跌: + :root { --up: #D92B2B; --down: #12A150; } 见 references/compliance.md ── */ + --up: var(--good); --down: var(--bad); + /* ── 渐变 ── */ --grad: linear-gradient(135deg, #FF5B2E, #FF8A5C 55%, #D94A20); --grad-soft: linear-gradient(135deg, #FBF0E8, #F7E7DA); @@ -328,6 +333,11 @@ html, body { width: var(--stage-w, 1920px); height: var(--stage-h, 1080px); over background: rgba(10,12,20,.55); color: #fff; font-size: var(--fs-tiny); letter-spacing: .06em; } +/* ── 免责声明 / 出处标注行(受监管题材: 财经/医疗/法律/政策, 见 references/compliance.md) + 小字不抢视觉; 出现在 closing 张并停留 ≥3s; 口播不念、不进字幕; 加了它也不替代核实 */ +.disclaimer { font-size: var(--fs-tiny); line-height: 1.6; color: var(--fg-3); max-width: 1240px; } +.disclaimer-box { border-left: 2px solid var(--line-strong); padding-left: var(--sp-3); } + /* ── 分步入场系统 ────────────────────────────────────────────── 要入场的块: data-stage="1|2|3" + fx-* 工具类, 例:

大标题

From f015a8b8eb79619eb3911eb56ff5c2514f9cd45e Mon Sep 17 00:00:00 2001 From: Hahaha Date: Fri, 18 Sep 2026 13:46:42 +0800 Subject: [PATCH 08/24] =?UTF-8?q?v1.1.0:=20=E8=AF=84=E5=AE=A1=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D(=E8=B7=AF=E5=BE=84=E6=94=B6=E7=9B=91/=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=8B=92=E7=BB=9D/ASR=20=E7=AB=AF=E7=82=B9=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95/=E6=8A=93=E5=9B=BE=20SSRF=20=E6=94=B6?= =?UTF-8?q?=E7=B4=A7)+=2072=20=E4=BE=8B=20node:test=20=E5=8F=AF=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.claude-plugin/plugin.json | 2 +- .../Wzdhehe/html2video-for-mcode/CHANGELOG.md | 28 ++++ .../Wzdhehe/html2video-for-mcode/plugin.json | 2 +- .../skills/html2video-for-mcode/SKILL.md | 12 +- .../html2video-for-mcode/evals/evals.json | 7 + .../references/authoring.md | 153 ++++++++++++++++-- .../html2video-for-mcode/scripts/asr.mjs | 26 ++- .../scripts/build-video.mjs | 43 +++-- .../html2video-for-mcode/scripts/capture.mjs | 27 ++-- .../scripts/check-slides.mjs | 60 ++++++- .../scripts/check-timing.mjs | 9 +- .../scripts/fetch-official-images.mjs | 72 +++++++-- .../scripts/init-project.mjs | 43 ++++- .../scripts/plan-timings.mjs | 5 +- .../scripts/prep-image.mjs | 4 +- .../html2video-for-mcode/scripts/tools.mjs | 82 ++++++++++ .../scripts/url-policy.mjs | 122 ++++++++++++++ .../tests/endpoint-allowlist.test.mjs | 73 +++++++++ .../tests/fetch-policy.test.mjs | 89 ++++++++++ .../html2video-for-mcode/tests/helpers.mjs | 56 +++++++ .../tests/no-clobber.test.mjs | 89 ++++++++++ .../tests/render-smoke.test.mjs | 71 ++++++++ .../tests/safe-paths.test.mjs | 120 ++++++++++++++ 23 files changed, 1127 insertions(+), 68 deletions(-) create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/url-policy.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/endpoint-allowlist.test.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/fetch-policy.test.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/helpers.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/no-clobber.test.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/render-smoke.test.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/safe-paths.test.mjs diff --git a/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json index e9eac207..1f52d238 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json +++ b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "html2video-for-mcode", - "version": "1.0.1", + "version": "1.1.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 index 23d5b828..75a94463 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md +++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 1.1.0 — 2026-09-18 + +**安全边界(响应 PR #41 评审的五条 Request changes)** + +- **路径收监**:`script.json` 是 agent 可编辑文件,其 `slides[].id/html/audio` 与 `bgm.file` 此前被直接拼进文件路径。现于 `tools.mjs` 新增 `safeId / safeRel / inside / validateScriptPaths / validateTimingsIds`,六个消费脚本(capture / build-video / plan-timings / check-timing / check-slides / asr)在 `JSON.parse` 后立即校验:id 走白名单 `^[A-Za-z0-9_-]{1,64}$`,路径拒绝绝对路径、resolve 后必须落在项目目录内、并对符号链接做 realpath 复核。此前 `id="../../victim"` 可触发**项目目录外的递归删除**(`capture.mjs` 的 `rmSync(build/frames/, {recursive:true})`)。 +- **覆盖拒绝**:`init-project.mjs` 对已存在且非空的目录直接拒绝(列出将被覆写的 5 个生成文件),需显式 `--force`;`fetch-official-images.mjs` 的 `--out-dir` 默认收监在工作目录内、已存在文件不覆盖;`prep-image.mjs --crop` 输出已存在需 `--force`。顺带修 `--topic` 未转义即插入模板 HTML 的问题。 +- **ASR 端点白名单**:API Key 只发官方域(`api.minimaxi.com` / `api.minimax.io`);`--base-url` / `MINIMAX_BASE_URL` 指向其他地址一律硬拒绝,自建网关需显式 `--allow-any-endpoint`(打印醒目警告)。此前被偷换的环境变量可把 Key 发往任意端点。 +- **抓图 SSRF 收紧**:新增 `scripts/url-policy.mjs`(纯函数)。拦 loopback / 链路本地(含云元数据 169.254.169.254)/ 私网 / CGNAT / 无点主机名;只允许 http(s),`file://` 需显式 `--allow-file`;禁带 userinfo 的 URL;`maxRedirects:0` 手动跟重定向且**逐跳**复用同一策略;响应大小上限默认 30MB(`--max-mb`);落盘文件名清洗补 Windows 保留名。 +- **可执行测试**:新增 `tests/`(node:test,零依赖,从仓库根 `node --test` 自动发现 → 被 `npm run check` 真实执行)—— `safe-paths`(恶意 id/路径 + canary 完好性 + symlink 逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(白名单拒绝 + 本地假服务器收到 Bearer 假 Key 的正向证据)、`fetch-policy`(host/URL/重定向/文件名 44 例)、`render-smoke`(init → 静音音频 → plan-timings → check-slides → capture → build-video 全链出片)。共 72 例,本地全绿。另附 scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml`(path-filter 只在本插件变更时跑,装 ffmpeg + playwright 后真实执行,含渲染冒烟)。 + +**图表与动效** + +- `references/authoring.md` 新增 **纯 CSS/SVG 图表**章节:横向条形 / 柱状 / 环形(`conic-gradient`)/ 折线(inline SVG)/ 进度条五种画法与选型速查;三条底线 = 禁外链图表库(离线取不到且 canvas 动画逐帧 seek 不到)、数值必须来自已核实口径、禁止 AI 生图当图表。 +- 新增 `fx-grow-x` / `fx-grow-y`(条形从左、柱状从底生长),折线 `fx-draw` 描边画入;**动效可一键关**:根元素或任意容器加 `no-fx`,关掉后 motion 捕获自动退化为静态帧,时长与音画同步不变(实测动画终态 vs `no-fx` 帧 PSNR 51.7dB,画面一致)。 +- `timeline` 版式改为整轴淡入 + 时间点逐个错峰入场。 + +**静默故障(继续加闸门)** + +- **fx 关键帧不含 opacity → 元素永久隐形**:`[data-stage]` 的基础态是 `opacity:0`,靠动画抬回 1;只做 transform/描边的动画(`fx-grow-x/y`、既有的 `fx-draw`)不改 opacity 就会永远不可见。关键帧已补 `opacity:1`,并在 `check-slides.mjs` 新增 **5b 项**静态拦截(fx 类的关键帧未声明 opacity → ✗)。 +- **旧帧目录污染成片**:切 `no-fx` 或改用 still 后,上一轮 motion 的帧目录仍在,`build-video` 会优先用残留帧把过时动画混进成片;capture 现在在产出静态图的路径上主动作废该张帧目录。 +- **柱状图模板高度塌陷**:外层容器写 `align-items:flex-end` 会让列 wrapper 高度塌成内容高,柱子百分比高度变 0(静默不显示);模板改为默认 stretch + 列内 `justify-content:flex-end`,并在文档里写明这个坑。 + +**文档** + +- 开工对齐"主题与受众"→"**主题与领域**"(受监管题材必问免责声明与数据出处标注);新增 `references/compliance.md`(财经口播红线、数字三要件、涨跌色按受众翻转、免责声明写法、医疗/法律/广告法、Gate 清单);`tokens.css` 增 `--up/--down` 与 `.disclaimer`;`check-slides.mjs` 增整片级财经关键词自查。 +- **层的入场顺序不再固定为"标题先行"**:改为"每张至少两个信息层、分属不同 stage,顺序由强同步原则决定"(大数字先入 / 设问先出 / 图先入都合法),要拦的是"只有一个层"。 +- 补 `roadmap` 版式的 HTML 片段(此前表格里有、代码块缺失),并修 kpi-grid 片段使用未定义类 `.grid g4`(会静默竖排)的问题。 + ## 1.0.1 — 2026-09-17 **新增:领域与合规(受监管题材)** diff --git a/plugins/Wzdhehe/html2video-for-mcode/plugin.json b/plugins/Wzdhehe/html2video-for-mcode/plugin.json index edc53b4f..10478a2d 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/plugin.json +++ b/plugins/Wzdhehe/html2video-for-mcode/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "html2video-for-mcode", - "version": "1.0.1", + "version": "1.1.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.", "author": { "name": "Wzdhehe", diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md index 231474f7..1557c9b1 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md @@ -27,11 +27,11 @@ description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻 ## 目录与工具 -技能自带四个脚本(直接以本技能目录为路径调用,项目目录作为参数,无需复制): +技能自带 12 个脚本(直接以本技能目录为路径调用,项目目录作为参数,无需复制;`tests/` 下还有一套 node:test 安全与冒烟测试,从仓库根 `node --test` 自动发现): | 脚本 | 作用 | |---|---| -| `scripts/init-project.mjs <项目目录>` | 生成项目骨架:目录 + tokens.css + slide 模板 + script.json 契约 | +| `scripts/init-project.mjs <项目目录> [--force]` | 生成项目骨架:目录 + tokens.css + slide 模板 + script.json 契约。**目标目录非空时拒绝执行**(会重置 5 个生成文件),要重新初始化必须显式 `--force` | | `scripts/plan-timings.mjs <项目目录>` | ffprobe 实测每段 TTS → 每张时长、各 stage 入场时刻、**每句 clauses 时刻** → `build/timings.json` | | `scripts/check-timing.mjs <项目目录> [--calibrate]` | 静音检测实测每句真实开口, 与估算对比;`--calibrate` 按实测校准 timings 后重渲染 | | `scripts/check-theme.mjs <项目目录>` | 校验全部主题的 WCAG 对比度(正文/次级/字幕/accent-ink), 不达标退出码 1;新增主题必须过闸 | @@ -135,9 +135,9 @@ Phase 6 交付 **内容量硬规则**(详细版见 `references/authoring.md`): - 中文口播 ≈ 4.8 字/秒。每张目标 6–10 秒 → 口播 25–48 字;首尾张 12–20 字。单张硬上限 60 字,超了拆两张。 -- **每张(除首尾)必须同时有"标题层 + 展开层",映射到不同 stage。只有标题、没有展开 = 违规,打回。** 口播也一样:每张至少两句——引入句(配标题层)+ 展开句(配细节层)。 +- **每张(除首尾)必须至少两个信息层,映射到不同 stage —— 但层的先后顺序自由**(大数字可以先入、标题后出;设问先出、答案再出;由强同步原则决定:层挂在提到它的那句口播的 stage)。只有标题、没有展开 = 违规,打回。口播也一样:每张至少两句,分别推动不同的层。 - `clauses` 的每个元素是一句口播,`stage` 声明"这句开口时,哪个视觉层该出现"。stage 数 ≈ clause 数,一一对应。 -- 版式共 17 种(原 8 + 补充 9:kpi-grid / stat-highlight / table / timeline / roadmap / comparison / flow-diagram / terminal / big-quote),每种画面必含项与字数区间见 `references/authoring.md` 的版式表。 +- 版式共 17 种(原 8 + 补充 9:kpi-grid / stat-highlight / table / timeline / roadmap / comparison / flow-diagram / terminal / big-quote),每种画面必含项与字数区间见 `references/authoring.md` 的版式表;**图表(条形/柱状/环形/折线/进度)有纯 CSS/SVG 画法速查,不用外链图表库**。 - 开工对齐时若用户要**双语字幕**,这里就要给每句写 `text2`(同句翻译,不重排语序,≤60 字符)。 **Gate 1**:逐张口播稿 + 版式分配给用户过。**未逐张 OK 不进 Phase 2。** @@ -181,6 +181,7 @@ node <技能目录>/scripts/plan-timings.mjs <项目目录> - ⚠ **`data-stage` 必须与 fx 类同时用**(只有属性没有动画类会永远停在 opacity:0);延迟实现见 authoring.md 的"stage 延迟的实现原理",改动画时不要手写 `animation-delay: var(--tN)`。 - 图片一律套 `.img-frame`(`.contain` 给截图/图表;`--img-ratio` 定比例;`--img-pos` 保主体;图注 `.img-cap` 写在框外)。 - 氛围动画(无限循环的呼吸/漂浮)允许,但不能承载信息、不加 `data-stage`。 +- **动效可一键关**:`` 关整个项目,任何容器加 `no-fx` 关单张,单个元素不给 fx 类即静态。关掉后 motion 捕获自动退化为静态帧出片,时长与音画同步不受影响,字幕照常(详见 authoring.md"动效开关")。 - 禁用 transition 做入场(截图管线 seek 不到),只用 `@keyframes`。禁外部 Google Fonts(离线不稳),用系统字体栈(tokens.css 已配 CJK fallback)。 - 素材只用 Gate 3 已确认的 `assets/` 清单,不新增未审素材。 - ⚠ **写完 8 张后先跑静态检查再截图**:`node <技能>/scripts/check-slides.mjs <项目目录>` —— 抓未定义 CSS 变量(会导致文字隐形)、图片缺失/外链资源、data-stage 没配 fx 类、硬编码颜色。有 ✗ 就别截图,画面对但"看不见"是最难查的。 @@ -241,6 +242,7 @@ audio/*.mp3 assets/(含 MANIFEST.md) research/notes.md asr/(校验记录) | 成片没字幕 | 用了 --no-subs 或 clauses 缺失 | capture 默认烧录;确认 timings.json 有 clauses | | 要双语字幕 | — | clause 加 `text2`,画面两行 + SRT 双行自动出;第二行建议 ≤60 字符 | | 想要背景音乐 | — | 顶层 `bgm` 配置,build-video 自动循环+淡入淡出垫底(默认音量 0.12);ASR 校验仍走纯人声轨 | +| 赶时间 / 题材要克制,不要动画 | — | `` 一键关全部动效,或单张容器加 `no-fx`;出片自动走静态帧,时长不变(见 authoring.md) | | 拼接后总时长不对 | 混用不同编码器参数的段 | 全部段由 build-video 统一编码;已自动回退重编码 | | 中文方框 | 系统无 CJK 字体 | Linux 装 fonts-noto-cjk;或改用已装字体 | | 报"找不到 ffprobe/ffmpeg" | 二进制不在 PATH | 脚本已自动探测 PATH→node_modules→常见位置;装 ffmpeg-static 或 winget install Gyan.FFmpeg | @@ -249,6 +251,8 @@ audio/*.mp3 assets/(含 MANIFEST.md) research/notes.md asr/(校验记录) | 字幕在深色主题下糊在背景里 | 主题没覆写字幕钩子 | 该主题加 `--sub-bg`(更深)+ `--sub-ring: 1px solid rgba(255,255,255,.16)`;跑 check-theme 验 | | 要出竖版(抖音/视频号) | — | `script.json` 设 `width:1080, height:1920`,版式改堆叠(见 authoring.md 竖版章节) | | 数字/文字明明写了却看不见 | 未定义 CSS 变量 + `-webkit-text-fill-color: transparent`,整条 background 失效 | 跑 `check-slides.mjs` 定位,补定义或写 `var(--x, 默认值)` | +| 图表/折线/某个元素入场后完全不见 | 该 fx 类的关键帧没声明 opacity —— `[data-stage]` 基础态是 `opacity:0`,只做 transform/描边的动画抬不回来 | 在关键帧的 from/to 里补 `opacity:1`;`check-slides.mjs` 会直接报出来 | +| 切了 `no-fx` 画面反而更空 | 早期模板只关动画、没恢复 `[data-stage]` 的 `opacity:0` 基础态 | 用新版 tokens.css(no-fx 规则含 `opacity:1 !important`);截图脚本已同时作废旧帧目录 | | 图片显示 broken 图标 | 文件缺失,或 SVG 本身有问题(XML 错/依赖外部资源/缺尺寸) | `check-slides.mjs` 查路径;SVG 改 inline 进 HTML;capture 也会在渲染时点名哪张没加载 | | 财经片没免责声明 / 涨跌色反了 / 数字被质疑口径 | 开工没确认领域,默认色与默认措辞直接用了 | 读 `references/compliance.md`:结尾补 `.disclaimer` 行(停留 ≥3s)、指标卡改 `var(--up)/var(--down)` 并按受众市场翻转、每个数字补口径+币种+时点 | | 不知道要不要写免责声明 / 算不算受监管 | 领域没确认 | 开工对齐第一批问题里问"领域 + 要不要免责";财经投研、医疗、法律、政策、营销效果宣称默认要。`check-slides.mjs` 命中财经关键词却没见免责行时会提示 | diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json index cdc1447c..686b0fc8 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json @@ -157,6 +157,13 @@ "prompt": "这条财经视频里我用绿色表示上涨、红色表示下跌, 可以吧?", "expected_output": "要看受众市场。tokens.css 的语义色默认 --good 绿 / --bad 红, 即欧美读法(绿涨红跌); A 股/港股受众是红涨绿跌, 用反了整片观感颠倒。做法: 指标卡一律写 var(--up)/var(--down)(模板已定义 --up:var(--good)/--down:var(--bad)), 需要翻转时在项目 slides/tokens.css 末尾追加 :root{--up:#D92B2B;--down:#12A150}(旧项目缺这两个变量要补定义, check-slides 会报未定义)。同时在 Gate 3 素材确认时问清受众市场, 并把结论记进 research/notes.md。依据见 references/compliance.md。", "files": [] + }, + { + "id": 24, + "name": "chart-animation-and-no-fx", + "prompt": "财务页要画营收对比图和一条三年趋势线, 要有点动效; 但老板说这版赶时间, 最好能一个开关把动画全关掉, 画面别变。", + "expected_output": "图表全部纯 CSS/SVG 自绘(禁外链 Chart.js/ECharts: 离线取不到 + canvas 动画逐帧 seek 不到)。动效走现成 fx 类且逐帧可 seek: 条形 fx-grow-x 从左长出、柱状 fx-grow-y 从底长出、折线 fx-draw 描边画入(SVG 自设 stroke-dasharray:800)、环形/进度条 fx-pop 与 fx-grow-x; 错峰用 --fx-delay:calc(var(--t2)+N×120ms)。开关: 根元素加 class=\"no-fx\" 一键全关(单张容器加则只关该张), 关掉后 motion 捕获检测不到动画窗、自动按静态帧出片, 时长与音画同步不变, 字幕照常烧录; 实测动画终态与 no-fx 帧 PSNR 51.7dB(画面一致)。⚠ 两个必踩的坑: ① 只做 transform/描边的关键帧必须显式写 opacity:1, 否则 [data-stage] 的 opacity:0 基础态抬不回来 → 元素永久隐形(check-slides 5b 项会报); ② 柱状图外层容器不能写 align-items:flex-end(列 wrapper 高度塌陷 → 柱子百分比高度变 0), 要用默认 stretch + 列内 justify-content:flex-end。见 references/authoring.md 的\"纯 CSS/SVG 图表\"与\"动效开关\"两节。", + "files": [] } ] } diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md index 46b1afe7..34fba620 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md @@ -7,7 +7,15 @@ 1. **只有标题没有展开** —— 观众 7 秒只看到一行大字,信息量为零。 2. **文字一次性全糊上屏** —— 没有节奏,口播念到后半句时视觉早已无话可说。 -解法是强制的三层结构:**标题层(stage 1)先出 → 展开层(stage 2)在口播展开句开口时入 → 视觉锚点(stage 3,可选)压轴**。口播与画面是同一句话的两个声部。 +解法是强制的三层结构:**标题层 + 展开层 + 视觉锚点(可选),三层分属不同 stage**。口播与画面是同一句话的两个声部。 + +**层的入场顺序不固定 —— 由叙事决定,标题先行只是默认。** 判据是强同步原则:哪个层被哪句口播提到,就挂在那个 stage。完全合法的变体: + +- **大数字先入,标题后出**(stat-highlight 的默认形态):口播第一句就砸数字,数字 s1、说明与标题 s2; +- **设问先出,答案再出**:问题大字 s1 → 答案/数据 s2 → 标题收拢 s3; +- **图先入,文字后落**:主体图 s1 → 关键词标注 s2(图片类题材常用)。 + +要拦的从来不是"标题不先行",而是**只有一层**:整张停在标题/单块内容上、没有第二个信息层推进。 ## 内容量表(硬规则) @@ -26,7 +34,7 @@ 判定规则(写给执行者,自查用): -- 除 title-hero/closing 外,**画面视觉块 ≥2 个且分属不同 stage**;"光标题 + 页码"直接打回。 +- 除 title-hero/closing 外,**画面视觉块 ≥2 个且分属不同 stage**;"光标题 + 页码"直接打回。**层的先后顺序自由**(数字可以先于标题、设问可以先于答案),错的是"只有一个层",不是"标题不在 s1"。 - **强同步原则(2026-09 实测教训):每个 stage 的视觉锚点(大数字/关键词/主体图)必须写在触发它的那句口播里,不要放在上一句。**反例:口播第一句就是"三亿人在用",但"3 亿"数字卡挂在 stage 2——观众听到"三亿"时画面没反应,数字卡进场时口播已讲到下一句,体感就是"动画对不上"。正例:第一句只铺垫("它有三个你可能用过的产品"),"3 亿"作为第二句开头,数字卡挂 stage 2 → 声画同时砸出"3 亿",强同步。 - 口播 clauses 与 stage 的映射:**stage k 的视觉在 clause k 开口时入场**。stage 数 ≈ clause 数;多出的视觉层用 script.json 的 `stageTimes` 显式给时刻。 - 单张口播硬上限 60 字;超了说明这张在干两张的活,拆。字幕场景下单句建议 ≤18 字(capture 烧录字幕单行展示);双语时第二行(text2)≤60 字符。 @@ -180,14 +188,21 @@ console.log(answer.output_text); | big-quote | 大引语转场 | 引语大字(s1) + 署名(s2) | 20–32 | 2 句 | ```html - -
+ +

REVENUE

1248K

↑ 38% YoY

RETENTION

74%

→ 持平

+

NPS

+
62
+

↑ 9 pts

+

CHURN

+
3.1%
+

↓ 0.4 pts

@@ -214,14 +229,35 @@ console.log(answer.output_text); - +
-
-

2021

成立

-
-

2023

首个产品

-
-

2026

上市

+
+
+

2021

成立

+
+

2023

首个产品

+
+

2026

上市

+
+
+ + +
+
+

NOW · 2026 Q3

+

开放平台公测

+

长上下灰度放量

+
+
+

NEXT · Q4

+

多模态 API 定价

+

企业私有化版本

+
+
+

LATER · 2027

+

Agent 应用商店

+

海外节点

+
@@ -260,6 +296,101 @@ console.log(answer.output_text); --- +# 纯 CSS/SVG 图表(data-viz 的画法) + +图表**全部自绘**,三条底线: + +1. **禁外链图表库**(Chart.js / ECharts 等 CDN):离线沙箱取不到、违反禁外链铁律,而且 canvas 动画逐帧 seek 不到(会渲染成静止/空白)。 +2. **数值必须来自 research/notes.md 里已核实的口径** —— 图表是"把核实过的数字画出来",不是装饰;画不了的口径(缺时点/混币种)先回 Phase 0。 +3. **禁止用 AI 生图当图表**(`image generate` 只限抽象概念图):数据图形必须可追溯到来源,生成图不可核查。 + +颜色一律走令牌:涨跌用 `var(--up)/var(--down)`(财经按受众翻转,见 compliance.md),中性对比用 `var(--accent)/var(--accent-2)`,网格线 `var(--line)`,数值标签 `var(--fg)`。**动效是图表的一部分**(仍是纯 @keyframes,逐帧 seek 确定性不变):条形/柱状用 `fx-grow-x/fx-grow-y` 从 0 长到数值、折线用 `fx-draw` 描边画入、环形用 `fx-pop`;同层错峰照旧走 `--fx-delay:calc(var(--t2) + N×120ms)`。**全套动效可一键关**(见下文"动效开关")。 + +```html + +
+
+

产品 A

+
+
1.86 亿
+
+
+
+

产品 B

+
+
+
+
+
+ + +
+
+ 4.1 +
+ Q1 +
+
+ 6.8 +
+ Q2 +
+
+ + +
+
+ 62% + 市场份额 +
+
+ + + + + + + +``` + +选型速查:对比/排名 → 横向条形;时间趋势(≤5 点)→ 柱状,(>5 点)→ SVG 折线;占比 ≤3 块 → 环形;达成率 → 下面这条进度条。都别忘图注:数据口径 + 时点(受监管题材必须,见 compliance.md)。 + +```html + +
+
+ 年度目标达成78% +
+
+
+
+
+``` + +## 动效开关(三种粒度) + +| 粒度 | 做法 | 效果 | +|---|---|---| +| **整个项目** | ``(或 `.stage.no-fx`) | 全部入场/氛围动效关闭,元素直接呈终态;motion 捕获检测不到动画窗,自动按静态帧出片,时长与音画同步不受影响;字幕照常烧录 | +| **单张** | 该张根元素加 `no-fx`(tokens.css 的规则按后代匹配,任何容器加都行) | 只有这张无动效,其余张正常 | +| **单个元素** | 不给它 fx 类和 data-stage | 该元素静态呈现 | + +适用:赶时间要快速出片、题材要求克制(政务/法律/讣告类)、或用户明确说"不要动画"。开工对齐时可当作一个问题问出去;默认全开。 + +--- + # 主题速查(受众 → 主题) | 场景 | 主题 | diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/asr.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/asr.mjs index 8e6d9ac9..1b99f892 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/asr.mjs +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/asr.mjs @@ -9,12 +9,14 @@ // // Key 来源(按序): --api-key sk-xxx → 环境变量 MINIMAX_API_KEY // 区域(按序): --base-url → 环境变量 MINIMAX_BASE_URL → MINIMAX_REGION(cn=api.minimaxi.com / global=api.minimax.io) +// 端点安全: Key 只发上面两个官方域; 其他 --base-url/MINIMAX_BASE_URL 一律拒绝, 自建网关需显式 --allow-any-endpoint。 // 接口约束(官方文档): wav/aiff/flac/m4a/mp3/aac/opus/ogg; 时长 ≤500s; 大小 ≤50MB。 // 超限时本脚本会用 ffmpeg 自动转成单声道 16k mp3 再传(识别率不受影响)。 import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { requireTool } from './tools.mjs'; +import { requireTool, safeId, validateTimingsIds } from './tools.mjs'; +import { assertAsrEndpoint, PolicyError } from './url-policy.mjs'; const argv = process.argv.slice(2); const VALUE_FLAGS = new Set(['--api-key', '--base-url', '--file', '--format', '--timestamp', '--language', '--out', '--from']); @@ -28,8 +30,20 @@ const flag = (n, d) => { const i = argv.indexOf(n); return i > -1 ? argv[i + 1] const KEY = flag('--api-key') || process.env.MINIMAX_API_KEY || ''; const REGION = process.env.MINIMAX_REGION || 'cn'; -const BASE = (flag('--base-url') || process.env.MINIMAX_BASE_URL - || (REGION === 'global' ? 'https://api.minimax.io' : 'https://api.minimaxi.com')).replace(/\/$/, ''); +// 端点白名单: API Key 只发官方 MiniMax 域; 换端点必须显式 --allow-any-endpoint(危险项)。 +// 防的是环境变量/提示词把 MINIMAX_BASE_URL 偷换成收集器后凭证外发。 +let BASE; +try { + BASE = assertAsrEndpoint(flag('--base-url') || process.env.MINIMAX_BASE_URL + || (REGION === 'global' ? 'https://api.minimax.io' : 'https://api.minimaxi.com'), + { allowAny: argv.includes('--allow-any-endpoint') }); +} catch (e) { + if (e instanceof PolicyError) { + console.error(`✗ ASR 端点被拒绝: ${e.message}\n 这把 Key 只允许发往官方端点。自定义网关请确认安全后显式加 --allow-any-endpoint`); + process.exit(1); + } + throw e; +} const LANG = flag('--language', ''); // zh / yue / en ... 空=混合识别 const FORMAT = flag('--format', 'json'); const TS = flag('--timestamp', ''); // '' | sentence | word @@ -71,7 +85,7 @@ async function transcribe(file, { format = FORMAT, ts = TS, language = LANG } = if (language) headers.language = language; // 'zh' 强制普通话; 'yue' 确认粤语 let res; try { - res = await fetch(`${BASE}/v1/speech_to_text`, { method: 'POST', headers, body: fd }); + res = await fetch(`${BASE}/v1/speech_to_text`, { method: 'POST', headers, body: fd, signal: AbortSignal.timeout(180000) }); } catch (e) { throw new Error(`网络请求失败(${BASE}): ${e.message}\n → 检查网络/代理; 海外套餐加 MINIMAX_REGION=global`); } @@ -161,6 +175,7 @@ const asrDir = path.join(dir, 'asr'); if (!fs.existsSync(asrDir)) { console.error(`✗ 找不到 ${asrDir} — 先跑 build-video.mjs <项目> --asr 生成按句切分的音频`); process.exit(1); } const timingsPath = path.join(dir, 'build', 'timings.json'); const timings = fs.existsSync(timingsPath) ? JSON.parse(fs.readFileSync(timingsPath, 'utf8')) : null; +if (timings) validateTimingsIds(timings); // 模式 3: 用 ASR 时间戳实测句开口, 校对比 timings 估算 if (argv.includes('--verify-timing') && timings) { @@ -168,7 +183,8 @@ if (argv.includes('--verify-timing') && timings) { console.log('ASR 实测句开口(字/段级时间戳) vs plan-timings 估算:\n'); for (const t of timings.slides) { if (!Array.isArray(t.clauses) || t.clauses.length < 2) continue; - const audio = path.join(dir, 'audio', `${t.id}.mp3`); + const tid = safeId(t.id); + const audio = path.join(dir, 'audio', `${tid}.mp3`); if (!fs.existsSync(audio)) { console.warn(`- 跳过 ${t.id}: 缺音频`); continue; } const r = await transcribe(audio, { format: 'verbose_json', ts: 'word' }); const units = r.segments || []; diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/build-video.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/build-video.mjs index 013faf37..704f410b 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/build-video.mjs +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/build-video.mjs @@ -4,7 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { requireTool } from './tools.mjs'; +import { requireTool, safeId, safeRel, validateScriptPaths, validateTimingsIds } from './tools.mjs'; const argv = process.argv.slice(2); const dir = path.resolve(argv.find(a => !a.startsWith('--')) ?? '.'); @@ -26,8 +26,19 @@ const run = (cmd, args, label) => { }; const script = JSON.parse(fs.readFileSync(path.join(dir, 'script.json'), 'utf8')); +validateScriptPaths(script, dir); // script.json 是 agent 可编辑文件: id/audio/bgm 派生路径先收监 const timings = JSON.parse(fs.readFileSync(path.join(dir, 'build', 'timings.json'), 'utf8')); -const W = script.width ?? 1920, H = script.height ?? 1080; +validateTimingsIds(timings); +// W/H 会拼进 ffmpeg filter 字符串(scale=...), 强转整数并限范围, 防字符串注入 +const W = clampDim(script.width, 1920, 'width'), H = clampDim(script.height, 1080, 'height'); +function clampDim(v, dflt, name) { + const n = Number(v ?? dflt); + if (!Number.isInteger(n) || n < 16 || n > 16384) { + console.error(`✗ script.${name} 非法: ${JSON.stringify(v)} — 需要 16–16384 的整数`); + process.exit(1); + } + return n; +} const fps = timings.fps ?? script.fps ?? 30; const total = timings.total; const abs = p => path.resolve(p).replace(/\\/g, '/'); @@ -49,13 +60,14 @@ fs.mkdirSync(path.join(dir, 'build'), { recursive: true }); // ── 1. 单张编码 ───────────────────────────────────────────── const segs = []; for (const t of timings.slides) { + const tid = safeId(t.id); const D = t.duration; const fadeOut = Math.max(0, D - 0.35); const fades = `fade=t=in:st=0:d=0.25,fade=t=out:st=${fadeOut.toFixed(3)}:d=0.3`; - const fdir = path.join(dir, 'build', 'frames', t.id); + const fdir = path.join(dir, 'build', 'frames', tid); const firstFrame = path.join(fdir, 'f00000.png'); - const png = path.join(dir, 'preview', `${t.id}.png`); - const seg = path.join(dir, 'out', `slide-${t.id}.mp4`); + const png = path.join(dir, 'preview', `${tid}.png`); + const seg = path.join(dir, 'out', `slide-${tid}.mp4`); const common = ['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'medium', '-crf', '20', '-movflags', '+faststart']; if (fs.existsSync(firstFrame)) { @@ -64,19 +76,19 @@ for (const t of timings.slides) { if (sz && (sz[0] !== W || sz[1] !== H)) vf.push(`scale=${W}:${H}:flags=lanczos`); // dsf 2 超采样降采 vf.push(fades); run(FFMPEG, ['-y', '-framerate', String(fps), '-i', path.join(fdir, 'f%05d.png'), - '-vf', vf.join(','), '-t', D.toFixed(4), ...common, seg], `编码 ${t.id} (帧序列)`); + '-vf', vf.join(','), '-t', D.toFixed(4), ...common, seg], `编码 ${tid} (帧序列)`); } else if (fs.existsSync(png)) { let vf = [fades]; const sz = probeSize(png); if (sz && (sz[0] !== W || sz[1] !== H)) vf.unshift(`scale=${W}:${H}:flags=lanczos`); run(FFMPEG, ['-y', '-loop', '1', '-framerate', String(fps), '-i', png, - '-vf', vf.join(','), '-t', D.toFixed(4), ...common, seg], `编码 ${t.id} (静态图)`); + '-vf', vf.join(','), '-t', D.toFixed(4), ...common, seg], `编码 ${tid} (静态图)`); } else { - console.error(`✗ ${t.id} 既无帧序列也无 preview/${t.id}.png — 先运行 capture.mjs`); + console.error(`✗ ${tid} 既无帧序列也无 preview/${tid}.png — 先运行 capture.mjs`); process.exit(1); } const d = probeDur(seg); - if (d != null && Math.abs(d - D) > 0.2) console.warn(`⚠ ${t.id} 段长 ${d.toFixed(2)}s ≠ 预期 ${D.toFixed(2)}s`); + if (d != null && Math.abs(d - D) > 0.2) console.warn(`⚠ ${tid} 段长 ${d.toFixed(2)}s ≠ 预期 ${D.toFixed(2)}s`); segs.push(seg); } @@ -100,7 +112,9 @@ if (vd == null || Math.abs(vd - total) > 0.25) { const audioInputs = []; const chains = []; timings.slides.forEach((t, i) => { - const a = path.join(dir, 'audio', script.slides.find(s => s.id === t.id)?.audio ?? `${t.id}.mp3`); + const tid = safeId(t.id); + const audioRel = script.slides.find(s => s.id === tid)?.audio ?? `${tid}.mp3`; + const a = safeRel(path.join(dir, 'audio'), audioRel, { where: `slides[${tid}].audio` }); if (!fs.existsSync(a)) { console.error(`✗ 缺音频 ${a}`); process.exit(1); } audioInputs.push(a); chains.push(`[${i}:a]aresample=44100,aformat=channel_layouts=mono,apad=whole_dur=${t.duration.toFixed(4)}[s${i}]`); @@ -114,7 +128,7 @@ run(FFMPEG, ['-y', ...audioInputs.flatMap(a => ['-i', a]), let audioFinal = audioWav; if (script.bgm) { const cfg = typeof script.bgm === 'string' ? { file: script.bgm } : script.bgm; - const bgmPath = path.resolve(dir, cfg.file); + const bgmPath = safeRel(dir, cfg.file, { where: 'bgm.file' }); // 拒绝绝对路径与越界(原来的 path.resolve 会整体逃逸) if (!fs.existsSync(bgmPath)) { console.warn(`⚠ 配置了 bgm 但找不到 ${bgmPath} — 跳过, 只出人声`); } else { @@ -188,13 +202,14 @@ if (WANT_ASR) { let count = 0; cum = 0; for (const t of timings.slides) { + const tid = safeId(t.id); const clauses = Array.isArray(t.clauses) && t.clauses.length ? t.clauses : [{ start: 0, text: t.script }]; clauses.forEach((c, i) => { const end = t.clauses[i + 1]?.start ?? t.duration; - const part = path.join(dir, 'asr', `part-${t.id}-${i + 1}.mp3`); + const part = path.join(dir, 'asr', `part-${tid}-${i + 1}.mp3`); run(FFMPEG, ['-y', '-ss', (cum + c.start).toFixed(3), '-t', (end - c.start).toFixed(3), '-i', audioWav, - '-ac', '1', '-ar', '16000', '-c:a', 'libmp3lame', '-b:a', '128k', part], `ASR 切分 ${t.id}-${i + 1}`); - lines.push(`| asr/part-${t.id}-${i + 1}.mp3 | ${(cum + c.start).toFixed(1)}s | ${c.text} | | |`); + '-ac', '1', '-ar', '16000', '-c:a', 'libmp3lame', '-b:a', '128k', part], `ASR 切分 ${tid}-${i + 1}`); + lines.push(`| asr/part-${tid}-${i + 1}.mp3 | ${(cum + c.start).toFixed(1)}s | ${c.text} | | |`); count++; }); cum += t.duration; diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/capture.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/capture.mjs index a4a6dee8..15e1c726 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/capture.mjs +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/capture.mjs @@ -5,7 +5,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { loadPackage } from './tools.mjs'; +import { loadPackage, safeId, safeRel, validateScriptPaths, validateTimingsIds } from './tools.mjs'; const argv = process.argv.slice(2); const dir = path.resolve(argv.find(a => !a.startsWith('--')) ?? '.'); @@ -16,12 +16,14 @@ const SUBS = !argv.includes('--no-subs'); // 字幕默认烧录 const idsFilter = flag('--ids', '') ? flag('--ids', '').split(',').map(s => s.trim()) : null; const script = JSON.parse(fs.readFileSync(path.join(dir, 'script.json'), 'utf8')); +validateScriptPaths(script, dir); // script.json 是 agent 可编辑文件: id/html 等派生路径先收监再使用 const timingsPath = path.join(dir, 'build', 'timings.json'); if (!fs.existsSync(timingsPath)) { console.error('✗ 缺 build/timings.json — 先运行 plan-timings.mjs'); process.exit(1); } const timings = JSON.parse(fs.readFileSync(timingsPath, 'utf8')); +validateTimingsIds(timings); const slides = script.slides.filter(s => !idsFilter || idsFilter.includes(s.id)); if (!slides.length) { console.error('✗ 没有匹配的 slide'); process.exit(1); } @@ -73,7 +75,8 @@ fs.mkdirSync(path.join(dir, 'preview'), { recursive: true }); let done = 0; for (const s of slides) { - const htmlPath = path.join(dir, 'slides', s.html ?? `${s.id}.html`); + const sid = safeId(s.id); // 递归删帧目录/写 preview 都由 id 派生, 在源头再拦一次 + const htmlPath = safeRel(path.join(dir, 'slides'), s.html ?? `${sid}.html`, { where: `slides[${sid}].html` }); if (!fs.existsSync(htmlPath)) { console.warn(`- 跳过 ${s.id}: 缺 ${htmlPath}`); continue; } const t = timings.slides.find(x => x.id === s.id); if (!t) { console.warn(`- 跳过 ${s.id}: timings 里无此张`); continue; } @@ -192,14 +195,19 @@ for (const s of slides) { console.warn(' 先跑 check-slides.mjs 定位; SVG 类资源建议 inline 进 HTML(依赖外部资源/XML 有误/缺 width-height 都会 broken)'); } + // 帧目录是派生数据: 本次只要产出的是静态图(still 或 无动画), 旧 motion 帧一律作废, + // 否则 build-video 会优先用残留帧, 把过时动画混进成片(切 no-fx 后重渲染时必踩) + const invalidateFrames = () => fs.rmSync(path.join(dir, 'build', 'frames', sid), { recursive: true, force: true }); + if (mode === 'still') { // 直接跳到所有有限动画的终态(finish), 无限氛围动画保持运行, 截图即终态。 await page.evaluate(() => { document.getAnimations().forEach(a => { try { a.finish(); } catch { /* infinite */ } }); }); await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))); - await page.screenshot({ path: path.join(dir, 'preview', `${s.id}.png`) }); - console.log(`✓ ${s.id} 终态截图 → preview/${s.id}.png`); + await page.screenshot({ path: path.join(dir, 'preview', `${sid}.png`) }); + invalidateFrames(); + console.log(`✓ ${sid} 终态截图 → preview/${sid}.png`); } else { // 逐帧步进: 全部动画暂停在 0, 每帧统一 seek 到 t, 截图。CSS 动画自带 delay, seek 是绝对时间, 时序天然正确。 await page.evaluate(() => { @@ -218,14 +226,15 @@ for (const s of slides) { }); if (meta.animEnd <= 0.05) { // 页面没有任何有限动画: 静态页, 单帧即全部信息, 走 still 路径 - await page.screenshot({ path: path.join(dir, 'preview', `${s.id}.png`) }); - console.log(`✓ ${s.id} 无动画, 静态截图 → preview/${s.id}.png`); + await page.screenshot({ path: path.join(dir, 'preview', `${sid}.png`) }); + invalidateFrames(); + console.log(`✓ ${sid} 无动画, 静态截图 → preview/${sid}.png`); await page.close(); done++; continue; } const fps = timings.fps ?? 30; const windowS = Math.min(t.duration, meta.animEnd + 0.25); // 动画窗口逐帧, 其余靠 tpad 补尾帧 const frames = Math.max(1, Math.ceil(windowS * fps)); - const fdir = path.join(dir, 'build', 'frames', s.id); + const fdir = path.join(dir, 'build', 'frames', sid); fs.rmSync(fdir, { recursive: true, force: true }); fs.mkdirSync(fdir, { recursive: true }); const t0 = Date.now(); @@ -238,8 +247,8 @@ for (const s of slides) { if (i > 0 && i % 60 === 0) console.log(` ${s.id}: ${i}/${frames} 帧 (${((Date.now() - t0) / 1000).toFixed(0)}s)`); } fs.copyFileSync(path.join(fdir, 'f' + String(frames - 1).padStart(5, '0') + '.png'), - path.join(dir, 'preview', `${s.id}.png`)); - console.log(`✓ ${s.id} ${frames} 帧 @${fps}fps (动画窗 ${windowS.toFixed(1)}s / 成片 ${t.duration.toFixed(1)}s) → build/frames/${s.id}/ + preview/${s.id}.png`); + path.join(dir, 'preview', `${sid}.png`)); + console.log(`✓ ${sid} ${frames} 帧 @${fps}fps (动画窗 ${windowS.toFixed(1)}s / 成片 ${t.duration.toFixed(1)}s) → build/frames/${sid}/ + preview/${sid}.png`); } await page.close(); done++; diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-slides.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-slides.mjs index a5222e07..282e928a 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-slides.mjs +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-slides.mjs @@ -10,10 +10,37 @@ // 3. 硬编码颜色(#hex / rgb() / hsl()) —— 换主题时会串色, 且说明配色没落到 token // 4. 外链资源(http/https 的 src/href) —— 离线沙箱会失败 + 引入 FOUT 风险 // 5. data-stage 没配 fx-* 类 —— 元素会永远停在 opacity:0(除非放进 .fx-stagger 容器) +// 5b. fx 类的关键帧不含 opacity —— 同上, 基础态 opacity:0 抬不回来, 元素永远隐形 +// (只做 transform/描边的动画必须显式写 opacity:1; 2026-09-18 实测踩过) // 6. 整片级领域自查 —— 命中多个财经/投研关键词却全片没有免责或出处行 → 提示(不是错误), // 提醒确认领域与免责口径(见 references/compliance.md); 用户明确不要免责时可忽略 import fs from 'node:fs'; import path from 'node:path'; +import { safeId, safeRel, inside } from './tools.mjs'; + +// tokens.css 里的 @keyframes 名 → 是否声明了 opacity(用来判断"动画能否把基础态抬回可见") +function opacityAwareKeyframes(css) { + const map = new Map(); + for (const m of css.matchAll(/@keyframes\s+([\w-]+)\s*\{/g)) { + // 从头扫到配平的右花括号, 拿到该关键帧块 + let i = m.index + m[0].length - 1, depth = 0, end = i; + for (; end < css.length; end++) { + if (css[end] === '{') depth++; + else if (css[end] === '}') { depth--; if (depth === 0) break; } + } + map.set(m[1], /opacity\s*:/.test(css.slice(i, end))); + } + return map; +} +// .fx-x { animation: ... } → fx 类名到关键帧名的映射 +function fxClassKeyframes(css) { + const map = new Map(); + for (const m of css.matchAll(/\.(fx-[\w-]+)\s*(?:,[^{]*)?\{([^}]*)\}/g)) { + const anim = /animation\s*:\s*([\w-]+)/.exec(m[2]); + if (anim) map.set(m[1], anim[1]); + } + return map; +} const argv = process.argv.slice(2); const dir = path.resolve(argv.find(a => !a.startsWith('--')) ?? '.'); @@ -31,6 +58,9 @@ const tokensCss = fs.readFileSync(tokensPath, 'utf8').replace(/\/\*[\s\S]*?\*\// // tokens.css 里定义的变量 const defined = new Set([...tokensCss.matchAll(/(--[\w-]+)\s*:/g)].map(m => m[1])); +// fx 类 → 关键帧名, 以及关键帧是否声明 opacity(供 5b 检查: 动画必须能把基础态 opacity:0 抬回 1) +const fxKeyframes = fxClassKeyframes(tokensCss); +const kfOpacity = opacityAwareKeyframes(tokensCss); // 渲染管线/浏览器在运行时注入或框架自带的变量, 不需要在 tokens.css 里定义 const RUNTIME_VARS = new Set([ '--t1', '--t2', '--t3', '--fx-delay', '--stagger-base', // capture 注入 / 入场延迟 @@ -46,7 +76,7 @@ const report = []; const deckText = []; // 整片文本(HTML 去标签 + 口播稿), 用于第 6 项领域自查 for (const s of slides) { - const file = path.join(slidesDir, s.html ?? `${s.id}.html`); + const file = safeRel(slidesDir, s.html ?? `${safeId(s.id)}.html`, { where: `slides[${s.id}].html` }); if (!fs.existsSync(file)) { // 未写的 slide 只是"还没做", 截图时本就会跳过 —— 记提示而不是错误, 避免把告警训成噪音 report.push({ id: s.id, level: 'warn', msg: `未写: ${path.relative(dir, file)}(截图时会跳过)` }); @@ -65,12 +95,17 @@ for (const s of slides) { errors++; } - // 2. 图片: 文件必须存在; SVG 建议 inline + // 2. 图片: 文件必须存在; SVG 建议 inline(引用必须落在项目目录内, 防二阶越界读) for (const m of html.matchAll(/]*\bsrc\s*=\s*["']([^"']+)["']/g)) { const src = m[1]; if (/^(https?:)?\/\//.test(src) || src.startsWith('data:')) continue; // 外链单独在下面报 - const imgPath = path.resolve(path.dirname(file), src); - if (!fs.existsSync(imgPath)) { + const resolved = path.resolve(path.dirname(file), src); + if (path.isAbsolute(src) || !inside(dir, resolved)) { + report.push({ id: s.id, level: 'error', msg: `图片引用越出项目目录: ${src} — 素材必须先落 assets/ 再引用` }); + errors++; + continue; + } + if (!fs.existsSync(resolved)) { report.push({ id: s.id, level: 'error', msg: `图片不存在: ${src}(会渲染成 broken 图标)` }); errors++; } else if (/\.svg$/i.test(src)) { @@ -101,6 +136,23 @@ for (const s of slides) { report.push({ id: s.id, level: 'warn', msg: `有 data-stage 但没有 fx-* 类: ${tag.slice(0, 70)}… — 元素会永远停在 opacity:0(放进 .fx-stagger 容器可豁免)` }); warns++; } + + // 5b. fx 类的关键帧不改 opacity → 基础态 opacity:0 抬不回来, 元素永远隐形 + for (const m of html.matchAll(/<[^>]*\bdata-stage\s*=\s*["'][^"']*["'][^>]*>/g)) { + const tag = m[0]; + const cls = /class\s*=\s*["']([^"']*)["']/.exec(tag); + if (!cls) continue; + if (/\bfx-stagger\b/.test(cls[1])) continue; + for (const c of cls[1].split(/\s+/).filter(x => x.startsWith('fx-'))) { + const kf = fxKeyframes.get(c); + if (!kf) continue; // 未在 tokens.css 定义(环境类/氛围类), 交给别的检查 + const aware = kfOpacity.get(kf); + if (aware === false) { + report.push({ id: s.id, level: 'error', msg: `.${c} 的关键帧 ${kf} 没声明 opacity, 而 [data-stage] 基础态是 opacity:0 → 该元素入场后永远不可见; 在关键帧里补 opacity:1` }); + errors++; + } + } + } } // 6. 受监管领域自查(整片级): 像财经/投研内容却没见免责或出处标注 → 提示, 不是错误 diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-timing.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-timing.mjs index 131da47d..2e220d59 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-timing.mjs +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/check-timing.mjs @@ -6,7 +6,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { requireTool } from './tools.mjs'; +import { requireTool, safeId, safeRel, validateScriptPaths, validateTimingsIds } from './tools.mjs'; const argv = process.argv.slice(2); const dir = path.resolve(argv.find(a => !a.startsWith('--')) ?? '.'); @@ -20,7 +20,9 @@ const FFMPEG = requireTool('ffmpeg', dir); const timingsPath = path.join(dir, 'build', 'timings.json'); if (!fs.existsSync(timingsPath)) { console.error('✗ 缺 build/timings.json — 先运行 plan-timings.mjs'); process.exit(1); } const timings = JSON.parse(fs.readFileSync(timingsPath, 'utf8')); +validateTimingsIds(timings); const script = JSON.parse(fs.readFileSync(path.join(dir, 'script.json'), 'utf8')); +validateScriptPaths(script, dir); function detectGaps(file, tts) { const r = spawnSync(FFMPEG, ['-i', file, '-af', `silencedetect=noise=${NOISE}:d=${MIN_SIL}`, '-f', 'null', '-'], { encoding: 'utf8', windowsHide: true }); @@ -71,8 +73,9 @@ const report = []; let calibratable = 0; for (const t of timings.slides) { if (!t.clauses || t.clauses.length < 2) continue; - const audioPath = path.join(dir, 'audio', script.slides.find(s => s.id === t.id)?.audio ?? `${t.id}.mp3`); - if (!fs.existsSync(audioPath)) { console.warn(`- 跳过 ${t.id}: 缺音频`); continue; } + const tid = safeId(t.id); + const audioPath = safeRel(path.join(dir, 'audio'), script.slides.find(s => s.id === tid)?.audio ?? `${tid}.mp3`, { where: `slides[${tid}].audio` }); + if (!fs.existsSync(audioPath)) { console.warn(`- 跳过 ${tid}: 缺音频`); continue; } const gaps = detectGaps(audioPath, t.tts); const { method, meas } = matchBoundaries(gaps, t.clauses); const complete = meas.length > 0 && meas.every(v => v != null); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/fetch-official-images.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/fetch-official-images.mjs index 2355b414..50865602 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/fetch-official-images.mjs +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/fetch-official-images.mjs @@ -7,15 +7,23 @@ // node fetch-official-images.mjs <网址> --get 1,3,5 [--out-dir assets] 按序号下载 // node fetch-official-images.mjs <网址> --min 800 只看宽度 ≥800px 的 // node fetch-official-images.mjs <网址> --json 输出 JSON(供程序处理) +// 本地页(file://)加 --allow-file; 覆盖已存在文件 / out-dir 出项目 加 --force // // 纪律(与 image-sources.md 一致): 只取官方域名的资源; 列出的每一项都要人眼过一遍 // (主体是否居中/有无水印/比例是否合适), 选中的落盘 assets/ 并登记 MANIFEST.md。 +// +// 网络安全(全部在打开浏览器之前校验, 负向路径无需 playwright): +// - 只允许 http(s) 公网页面; file:// 本地页需显式 --allow-file(离线场景) +// - 拦内网/本地/元数据地址(loopback、169.254.x、私网段、无点主机名等, 见 url-policy.mjs) +// - 下载逐跳过同一策略(maxRedirects=0 手动跟, ≤5 跳), 响应有大小上限(默认 30MB) +// - 落盘必须在工作目录内(--out-dir 越界需 --force), 不覆盖已存在文件(需 --force) import fs from 'node:fs'; -import { loadPackage } from './tools.mjs'; +import { loadPackage, inside } from './tools.mjs'; import path from 'node:path'; +import { assertFetchableUrl, assertRedirectTarget, sanitizeFilename, MAX_REDIRECTS, PolicyError } from './url-policy.mjs'; const argv = process.argv.slice(2); -const VALUE_FLAGS = new Set(['--get', '--out-dir', '--min']); +const VALUE_FLAGS = new Set(['--get', '--out-dir', '--min', '--max-mb']); const positional = []; for (let i = 0; i < argv.length; i++) { const a = argv[i]; @@ -23,13 +31,30 @@ for (let i = 0; i < argv.length; i++) { positional.push(a); } const flag = (n, d) => { const i = argv.indexOf(n); return i > -1 ? argv[i + 1] : d; }; +const FORCE = argv.includes('--force'); +const ALLOW_FILE = argv.includes('--allow-file'); +const MAX_BYTES = Math.max(1, parseFloat(flag('--max-mb', '30')) * 1024 * 1024); const url = positional[0]; -if (!url || !/^https?:|^file:/.test(url)) { - console.error('用法: node fetch-official-images.mjs <网址> [--list|--get 1,3] [--out-dir assets] [--min 800] [--json]'); +if (!url || !/^(https?|file):/.test(url)) { + console.error('用法: node fetch-official-images.mjs <网址> [--list|--get 1,3] [--out-dir assets] [--min 800] [--max-mb 30] [--allow-file] [--force] [--json]'); process.exit(1); } +try { + assertFetchableUrl(url, { allowFile: ALLOW_FILE, where: '页面 URL' }); +} catch (e) { + if (e instanceof PolicyError) { console.error(`✗ ${e.message}`); process.exit(1); } + throw e; +} const MIN = parseInt(flag('--min', '0'), 10) || 0; +// 落盘收监: 默认 assets/ 必须在当前工作目录(项目根)内; 越界要显式 --force const OUT_DIR = path.resolve(flag('--out-dir', 'assets')); +if (!inside(process.cwd(), OUT_DIR) && OUT_DIR !== path.resolve(process.cwd())) { + if (!FORCE) { + console.error(`✗ --out-dir 越出当前工作目录: ${OUT_DIR}\n 默认只写项目内(README 的"写入都在项目目录"承诺)。确需外部位置请加 --force`); + process.exit(1); + } + console.warn(`⚠ --force: 下载将写到工作目录之外 ${OUT_DIR}`); +} const playwright = await loadPackage('playwright', { projectDir: process.cwd() }); if (!playwright) { console.error('✗ 未找到 playwright(已按 项目目录 / 调用目录 / npm 全局 逐个找过)。先执行: npm i playwright && npx playwright install chromium'); process.exit(1); } @@ -69,7 +94,7 @@ try { return out; }); - // 去重 + 尺寸过滤 + 官方域名标注 + // 去重 + 尺寸过滤 + 官方域名标注 + host 策略过滤(候选 src 由页面控制, 同样只收公网 http(s)/放行的 file:) const seen = new Set(); const candidates = []; const pageHost = (() => { try { return new URL(url).host; } catch { return ''; } })(); @@ -78,6 +103,14 @@ try { seen.add(it.src); let host = '', ext = ''; try { const u = new URL(it.src); host = u.host; ext = (u.pathname.match(/\.(png|jpe?g|webp|avif|svg|gif)$/i) || [''])[0].toLowerCase(); } catch { /* ignore */ } + let srcPolicy = 'ok'; + try { + assertFetchableUrl(it.src, { allowFile: ALLOW_FILE, where: '候选图' }); + } catch (e) { + if (!(e instanceof PolicyError)) throw e; + srcPolicy = e.reason; // 内网/file/坏协议的 src 不进候选(下载时也过同一策略, 双保险) + } + if (srcPolicy !== 'ok') continue; const offsite = pageHost && host && !host.endsWith(pageHost.replace(/^www\./, '')) && !pageHost.endsWith(host.replace(/^www\./, '')); const bigEnough = !MIN || it.w >= MIN || (it.w === 0 && ext === '.svg'); // svg 无自然宽度, 不按宽度筛 if (!bigEnough) continue; @@ -103,15 +136,34 @@ try { let ok = 0; for (const n of picks) { const c = candidates[n - 1]; - const name = (c.alt ? c.alt.replace(/[\\/:*?"<>|\s]+/g, '-').slice(0, 30) : `official-${String(n).padStart(2, '0')}`) + (c.ext.startsWith('.') ? c.ext : '.png'); + const name = sanitizeFilename(c.alt, { maxLen: 30, fallback: `official-${String(n).padStart(2, '0')}` }) + (c.ext.startsWith('.') ? c.ext : '.png'); const out = path.join(OUT_DIR, name); + if (fs.existsSync(out) && !FORCE) { console.error(` ✗ [${n}] 已存在, 不覆盖: ${out}(要覆盖加 --force)`); continue; } try { if (c.src.startsWith('file:')) { - fs.copyFileSync(new URL(c.src), out); // 本地页面(file://)直拷, 便于离线/内网场景 + fs.copyFileSync(new URL(c.src), out); // 本地页面(file://, 需 --allow-file)直拷 } else { - const res = await context.request.get(c.src, { timeout: 30000 }); - if (!res.ok()) throw new Error(`HTTP ${res.status()}`); - fs.writeFileSync(out, await res.body()); + // 手动跟重定向: 每一跳都重新过 host 策略, 不让重定向绕开内网拦截 + let current = c.src; + let res = null; + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + assertFetchableUrl(current, { allowFile: false, where: `下载(第 ${hop + 1} 跳)` }); + res = await context.request.get(current, { timeout: 30000, maxRedirects: 0 }); + if (res.status() >= 300 && res.status() < 400) { + const loc = res.headers()['location']; + if (!loc) throw new Error(`HTTP ${res.status()} 无 Location`); + current = new URL(loc, current).href; + continue; + } + break; + } + if (!res || res.status() >= 300) throw new Error(`重定向超过 ${MAX_REDIRECTS} 跳`); + if (res.status() >= 400) throw new Error(`HTTP ${res.status()}`); + const declared = parseInt(res.headers()['content-length'] || '0', 10); + if (declared > MAX_BYTES) throw new Error(`响应 ${ (declared / 1048576).toFixed(0)}MB 超过上限 ${Math.round(MAX_BYTES / 1048576)}MB(--max-mb 可调)`); + const body = await res.body(); + if (body.length > MAX_BYTES) { throw new Error(`响应 ${(body.length / 1048576).toFixed(0)}MB 超过上限 ${Math.round(MAX_BYTES / 1048576)}MB(--max-mb 可调)`); } + fs.writeFileSync(out, body); } console.log(` ✓ ${path.relative(process.cwd(), out)} (${(fs.statSync(out).size / 1024).toFixed(0)}KB)`); ok++; diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/init-project.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/init-project.mjs index 23f84141..318d98a2 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/init-project.mjs +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/init-project.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node // html2video-for-mcode · 生成项目骨架 -// 用法: node init-project.mjs <项目目录> [--topic "主题名"] +// 用法: node init-project.mjs <项目目录> [--topic "主题名"] [--force] +// 目标目录已存在且非空时拒绝执行(会重置 script.json 等 5 个生成文件), 需显式 --force。 import fs from 'node:fs'; import path from 'node:path'; @@ -12,6 +13,9 @@ if (!dirArg) { } const topicIdx = argv.indexOf('--topic'); const topic = topicIdx > -1 ? argv[topicIdx + 1] : ''; +// topic 会插进模板 HTML 的 .brand 角标, 插入点单独转义(script.json / notes.md 用原文, 不转义) +const escapeHtml = s => String(s).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); +const topicHtml = escapeHtml(topic); const dir = path.resolve(dirArg); const TOKENS_CSS = `/* html2video-for-mcode 设计令牌 · 由 init-project 生成 @@ -366,6 +370,18 @@ html, body { width: var(--stage-w, 1920px); height: var(--stage-h, 1080px); over .fx-ripple { animation: fx-ripple 1.2s var(--ease-out) var(--fx-delay, 0ms) both; } /* 斜角冲开 */ .fx-glitch { animation: fx-glitch .8s steps(5, end) var(--fx-delay, 0ms) both; } /* 故障切入 */ .fx-draw { animation: fx-draw 1.2s var(--ease-in-out) var(--fx-delay, 0ms) both; } /* 需元素自设 stroke-dasharray:800 */ +.fx-grow-x { transform-origin: left center; animation: fx-grow-x-kf .9s var(--ease-out) var(--fx-delay, 0ms) both; } /* 图表: 条形从左长出 */ +.fx-grow-y { transform-origin: bottom center; animation: fx-grow-y-kf .9s var(--ease-out) var(--fx-delay, 0ms) both; } /* 图表: 柱状从底长出 */ + +/* 一键关全部动效: 或 .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; } @keyframes fx-up { from { opacity: 0; transform: translateY(32px); } to { opacity: 1; transform: none; } } @keyframes fx-fade { from { opacity: 0; } to { opacity: 1; } } @@ -383,7 +399,11 @@ html, body { width: var(--stage-w, 1920px); height: var(--stage-h, 1080px); over 80% { transform: translateX(2px); clip-path: inset(0 0 0 0); } 100% { opacity: 1; transform: none; } } -@keyframes fx-draw { from { stroke-dashoffset: 800; } to { stroke-dashoffset: 0; } } +@keyframes fx-draw { from { stroke-dashoffset: 800; opacity: 1; } to { stroke-dashoffset: 0; opacity: 1; } } +/* 注意: 只做 transform 的动画必须显式带上 opacity:1 —— [data-stage] 的基础态是 opacity:0, + 靠动画抬回 1; 关键帧不碰 opacity 的动画会让元素永远隐形(2026-09-18 实测) */ +@keyframes fx-grow-x-kf { from { transform: scaleX(0); opacity: 1; } to { transform: scaleX(1); opacity: 1; } } +@keyframes fx-grow-y-kf { from { transform: scaleY(0); opacity: 1; } to { transform: scaleY(1); opacity: 1; } } /* 氛围(无限循环, 不参与时长计算) */ .fx-pulse { animation: fx-pulse 2.4s var(--ease-in-out) infinite; } @@ -427,7 +447,7 @@ const TEMPLATE_HTML = `
- A · ${topic || '主题名'} + A · ${topicHtml || '主题名'}

SECTION · 小节名

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

@@ -465,6 +485,23 @@ const scriptJson = { ], }; +// 覆盖保护(必须在建目录之前检查, 否则会把自建的空子目录当成"非空"): 目标目录已存在且 +// 非空 → 拒绝。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']; +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 }); } 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 index dec19768..4205ce9c 100644 --- 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 @@ -6,7 +6,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { requireTool } from './tools.mjs'; +import { requireTool, safeId, safeRel, validateScriptPaths } from './tools.mjs'; // 语种相关的计量基准。中文按"字", 英文按"字符"(含词间节奏, 与音节时长大致成正比)。 // pacing = 每单位每秒的常见语速; subMax = 字幕单行建议上限; pace 区间用于语速异常预警。 @@ -27,6 +27,7 @@ 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); @@ -45,7 +46,7 @@ const r3 = x => Math.round(x * 1000) / 1000; const rows = []; const warns = []; for (const s of script.slides) { - const audioPath = path.join(dir, 'audio', s.audio ?? `${s.id}.mp3`); + 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); } 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 index ef80d05a..402bb250 100644 --- 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 @@ -63,7 +63,9 @@ if (argv.includes('--check') || (!argv.includes('--crop') && positional.length)) 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]'); process.exit(1); } + 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); } diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/tools.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/tools.mjs index 79570aee..ce725f86 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/tools.mjs +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/tools.mjs @@ -62,6 +62,88 @@ export function requireTool(name, projectDir) { return p; } +// ── 输入收监(script.json 是 agent 可编辑文件, 其派生路径必须关进项目目录) ──── +// 威胁模型: script.json 里的 slides[].id / html / audio、顶层 bgm.file 若含 +// "../" 或绝对路径, 可让 fs 读写删与 ffmpeg argv 落到项目目录之外。 +// 所有消费者在 JSON.parse 之后立即调用本节的校验, 失败即退出, 不带病运行。 + +const ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +export function isSafeId(v) { + return typeof v === 'string' && ID_RE.test(v); +} + +// 白名单校验 slide id; 返回原值, 不合格直接退出(并指明是哪个字段) +export function safeId(v, where = 'slides[].id') { + if (!isSafeId(v)) { + console.error(`✗ ${where} 非法: ${JSON.stringify(v)} — 只允许字母/数字/下划线/连字符, 长度 1–64`); + process.exit(1); + } + return v; +} + +// resolve 后是否仍落在 root 内(不含 root 本身; root 的 realpath 也参与比较, +// 防 root 自身就是指向外面的符号链接) +export function inside(root, target) { + const rel = path.relative(path.resolve(root), path.resolve(target)); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +// 校验"root 下的相对路径"并返回绝对路径: +// 拒绝绝对路径 → inside(root) → 符号链接复核(取最深已存在祖先的 realpath, +// 必须仍在 root 的 realpath 内, 防 root 内某段是指向外面的 symlink) +export function safeRel(root, rel, { where = 'path', mustExist = false } = {}) { + if (typeof rel !== 'string' || rel === '') { + console.error(`✗ ${where} 非法: ${JSON.stringify(rel)} — 需要非空字符串`); + process.exit(1); + } + if (path.isAbsolute(rel)) { + console.error(`✗ ${where} 必须是相对路径: ${JSON.stringify(rel)}`); + process.exit(1); + } + const abs = path.resolve(root, rel); + if (!inside(root, abs)) { + console.error(`✗ ${where} 越出项目目录: ${JSON.stringify(rel)} → ${abs}`); + process.exit(1); + } + // 符号链接复核: 目标(或其最深已存在祖先)的 realpath, 必须仍在 root 的 realpath 内 + // (或是 root realpath 的祖先 —— root 尚不存在时, 共同祖先就是最近的真实路径, 属正常) + try { + let probe = abs; + while (!fs.existsSync(probe)) probe = path.dirname(probe); + const real = fs.realpathSync(probe); + const realRoot = fs.existsSync(root) ? fs.realpathSync(path.resolve(root)) : path.resolve(root); + if (real !== realRoot && !inside(realRoot, real) && !inside(real, realRoot)) { + console.error(`✗ ${where} 经符号链接越出项目目录: ${JSON.stringify(rel)}`); + process.exit(1); + } + } catch { /* lstat 失败按不存在处理, 由 mustExist/后续逻辑兜底 */ } + if (mustExist && !fs.existsSync(abs)) { + console.error(`✗ ${where} 不存在: ${rel}`); + process.exit(1); + } + return abs; +} + +// 一次性走查 script.json 的全部路径派生字段(两个调用点: 每个 consumer 读入后) +export function validateScriptPaths(script, dir) { + const list = Array.isArray(script?.slides) ? script.slides : []; + for (const s of list) { + safeId(s.id, `slides[].id (${JSON.stringify(s.title ?? '')})`); + if (s.html !== undefined) safeRel(path.join(dir, 'slides'), s.html, { where: `slides[${s.id}].html` }); + if (s.audio !== undefined) safeRel(path.join(dir, 'audio'), s.audio, { where: `slides[${s.id}].audio` }); + } + const bgm = script?.bgm; + const bgmFile = typeof bgm === 'string' ? bgm : bgm?.file; + if (bgmFile !== undefined) safeRel(dir, bgmFile, { where: 'bgm.file' }); +} + +// timings.json 的 slides[].id 是 script.json 的二次传播, 消费侧同样校验 +export function validateTimingsIds(timings) { + const list = Array.isArray(timings?.slides) ? timings.slides : []; + for (const t of list) safeId(t.id, 'timings.slides[].id'); +} + // ── Node 包解析(playwright) ────────────────────────────────────── // 技能通常装在 ~/.claude/skills/ 或 ~/.openclaw/skills/ —— 不在项目树里, // 而 Node 的 import 是按"脚本所在位置"向上找 node_modules 的, 于是会出现 diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/url-policy.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/url-policy.mjs new file mode 100644 index 00000000..7cfafe94 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/url-policy.mjs @@ -0,0 +1,122 @@ +// html2video-for-mcode · 网络 URL 策略(纯函数, 供 asr.mjs / fetch-official-images.mjs 与测试复用) +// 两个职责: +// 1. 出网端点白名单 —— asr.mjs 只把 API Key 发往官方 MiniMax 域; 换端点必须显式 +// --allow-any-endpoint(危险项), 防止被环境变量/提示词偷换后凭证外发。 +// 2. 抓图目标校验 —— fetch-official-images.mjs 的页面 URL、每个候选图 src、每一跳 +// 重定向都过同一套 host 策略: 拦 loopback / 链路本地(含云元数据)/私网 / 无点主机名, +// 只允许 http(s), file:// 需显式 --allow-file, 禁 userinfo。 +// 全部为纯函数, 不做 IO; 抛 PolicyError(带 reason code)由调用方决定退出码与文案。 + +export const OFFICIAL_ASR_BASES = ['https://api.minimaxi.com', 'https://api.minimax.io']; + +export class PolicyError extends Error { + constructor(reason, detail) { + super(`${reason}: ${detail ?? ''}`); + this.reason = reason; + this.detail = detail; + } +} + +// ── host 判定 ──────────────────────────────────────────────────── + +function ipv4ToLong(ip) { + const parts = ip.split('.').map(Number); + if (parts.length !== 4 || parts.some(p => !Number.isInteger(p) || p < 0 || p > 255)) return null; + return parts.reduce((a, p) => a * 256 + p, 0); +} + +// [网络基址, 前缀位数]; 计算一律走无符号, 避开 int32 符号位比较的坑 +const BLOCKED_V4 = [ + [0b00000000, 8], // 0.0.0.0/8 "this host" + [10 << 24, 8], // 10.0.0.0/8 私网 + [100 << 24 | 64 << 16, 10], // 100.64.0.0/10 CGNAT(常被漏掉的内网段) + [127 << 24, 8], // 127.0.0.0/8 loopback + [169 << 24 | 254 << 16, 16], // 169.254.0.0/16 链路本地(含云元数据 169.254.169.254) + [172 << 24 | 16 << 16, 12], // 172.16.0.0/12 私网 + [192 << 24 | 168 << 16, 16],// 192.168.0.0/16 私网 +].map(([base, bits]) => [base >>> 0, bits]); + +// host 是否属于禁止出网/抓取的地址(或形态) +export function isBlockedHost(hostname) { + const h = String(hostname ?? '').toLowerCase().replace(/^\[|\]$/g, ''); // IPv6 字面量去方括号 + if (!h) return true; + if (h.includes(':')) { // IPv6 + if (h === '::' || h === '::1') return true; // unspecified / loopback + if (/^f[cd][0-9a-f]{2}:/.test(h)) return true; // fc00::/7 私网(ULA) + if (/^fe[89ab][0-9a-f]:/.test(h)) return true; // fe80::/10 链路本地 + if (/^::ffff:/.test(h)) return isBlockedHost(h.replace(/^::ffff:/, '')); // IPv4-mapped + return false; + } + const ipv4 = ipv4ToLong(h); + if (ipv4 !== null) { + for (const [base, prefixBits] of BLOCKED_V4) { + const mask = prefixBits === 0 ? 0 : (0xffffffff << (32 - prefixBits)) >>> 0; + if (((ipv4 & mask) >>> 0) === ((base & mask) >>> 0)) return true; + } + return false; + } + // 非字面量: 无点主机名(localhost / 内网裸名 / NetBIOS)拦下; 内网风格后缀拦下 + if (!h.includes('.')) return true; + if (h === 'localhost' || ['.localhost', '.local', '.internal', '.localdomain', '.home.arpa'].some(sfx => h.endsWith(sfx))) return true; + return false; +} + +// ── 出网端点(asr) ──────────────────────────────────────────────── + +// base 必须是官方端点; 允许用 --base-url/env 在两个官方域之间切换(region 对齐), +// 任何其他值都拒 —— 除非 allowAny(显式危险项)。http 一律拒绝(官方端点全是 https)。 +export function assertAsrEndpoint(base, { allowAny = false } = {}) { + const normalized = String(base ?? '').replace(/\/+$/, ''); + if (OFFICIAL_ASR_BASES.includes(normalized)) return normalized; + if (allowAny) { + let u; + try { u = new URL(normalized); } catch { throw new PolicyError('bad-url', normalized); } + if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new PolicyError('bad-scheme', normalized); + console.warn(`⚠ --allow-any-endpoint: API Key 将发往非官方端点 ${normalized}(自担风险, 仅用于自建网关/测试)`); + return normalized; + } + throw new PolicyError('endpoint-not-allowed', + `${normalized} 不在 ASR 端点白名单(${OFFICIAL_ASR_BASES.join(' / ')})。确需自定义请显式加 --allow-any-endpoint`); +} + +// ── 抓图目标(fetch-official-images) ───────────────────────────── + +// 校验一个要访问的 URL; allowFile 时放行 file: 协议(本地离线页是文档化场景) +export function assertFetchableUrl(raw, { allowFile = false, where = 'url' } = {}) { + let u; + try { u = new URL(raw); } catch { throw new PolicyError('bad-url', `${where}: ${raw}`); } + if (u.protocol === 'file:') { + if (!allowFile) throw new PolicyError('file-not-allowed', `${where}: file:// 需要显式 --allow-file`); + if (u.username || u.password) throw new PolicyError('userinfo', where); + return u; + } + if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new PolicyError('bad-scheme', `${where}: ${u.protocol}`); + if (u.username || u.password) throw new PolicyError('userinfo', `${where}: 带凭据的 URL`); // 凭据会进日志/请求头 + if (isBlockedHost(u.hostname)) throw new PolicyError('blocked-host', `${where}: ${u.hostname} 是内网/本地/元数据地址`); + return u; +} + +// 重定向跳转是否允许(逐跳调用; 每一跳都要过同一套校验) +export function assertRedirectTarget(location, { where = 'redirect' } = {}) { + return assertFetchableUrl(location, { allowFile: false, where }); +} + +export const MAX_REDIRECTS = 5; + +// ── 落盘文件名(抓图下载) ───────────────────────────────────────── + +const WIN_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + +// alt/标题转成安全文件名: 去路径分隔符与控制字符、压长度、挡 Windows 保留名与开头点/空格 +export function sanitizeFilename(name, { maxLen = 30, fallback = 'official' } = {}) { + let s = String(name ?? '') + .replace(/[\u0000-\u001f\u007f]/g, '') + .replace(/[\\/:*?"<>|]+/g, '-') + .replace(/\s+/g, '-') + .replace(/^[.\s-]+/, '') // 不允许开头点/空格/连字符(点开头=隐藏文件) + .replace(/[.\s-]+$/, '') + .slice(0, maxLen) + .replace(/^[.\s-]+|[.\s-]+$/g, ''); // 截断后再清一次边缘 + if (!s || WIN_RESERVED.test(s)) s = fallback; + return s; +} diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/endpoint-allowlist.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/endpoint-allowlist.test.mjs new file mode 100644 index 00000000..2bd7cb1d --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/endpoint-allowlist.test.mjs @@ -0,0 +1,73 @@ +// 安全边界 · ASR 端点白名单(API Key 只发官方域) +// 对应评审意见 3: credential exfiltration via --base-url / MINIMAX_BASE_URL。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { assertAsrEndpoint, PolicyError } from '../scripts/url-policy.mjs'; +import { findTool } from '../scripts/tools.mjs'; +import { runSkill, runSkillAsync, tmpdir } from './helpers.mjs'; + +describe('assertAsrEndpoint(纯函数)', () => { + test('两个官方域放行(含尾斜杠归一)', () => { + assert.equal(assertAsrEndpoint('https://api.minimaxi.com'), 'https://api.minimaxi.com'); + assert.equal(assertAsrEndpoint('https://api.minimaxi.com/'), 'https://api.minimaxi.com'); + assert.equal(assertAsrEndpoint('https://api.minimax.io'), 'https://api.minimax.io'); + }); + test('官方域的 http 变体也拒绝', () => { + assert.throws(() => assertAsrEndpoint('http://api.minimaxi.com'), PolicyError); + }); + test('任意第三方域拒绝', () => { + for (const b of ['https://evil.example', 'https://api.minimaxi.com.evil.example', 'https://127.0.0.1:9456']) { + assert.throws(() => assertAsrEndpoint(b), PolicyError, b); + } + }); + test('allowAny 显式放行(自建网关/测试, 自担风险)', () => { + assert.equal(assertAsrEndpoint('http://127.0.0.1:9456', { allowAny: true }), 'http://127.0.0.1:9456'); + assert.throws(() => assertAsrEndpoint('ftp://x', { allowAny: true }), PolicyError); + }); +}); + +describe('asr.mjs 进程级行为', () => { + test('MINIMAX_BASE_URL=第三方域 + 假 Key → 退出 1, 不发起请求', () => { + const r = runSkill('asr.mjs', ['--api-key', 'sk-test-not-real', '--base-url', 'https://evil.example', '--file', 'whatever.mp3']); + assert.notEqual(r.status, 0); + assert.ok((r.stderr + r.stdout).includes('端点'), '应说明端点被拒'); + }); + + test('--allow-any-endpoint 指向本地服务器 → 请求到达且带着 Bearer 假 Key(证明门在 fetch 之前且可显式放行)', async () => { + if (!findTool('ffprobe')) return; // 主 CI 无 ffmpeg: 只留纯函数/负向证据 + // 造一个 1s 静音 mp3 + const work = tmpdir(); + const mp3 = path.join(work, 'clip.mp3'); + const FFMPEG = findTool('ffmpeg'); + const g = spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', '-t', '1', '-c:a', 'libmp3lame', '-y', mp3], { windowsHide: true }); + if (g.status !== 0) return; // 造不出音频就跳过 + + let sawAuth = null; + const server = http.createServer((req, res) => { + sawAuth = req.headers.authorization ?? '(none)'; + // Connection:close 让 undici 不留 keep-alive 连接 —— 否则 server.close() 永远等不到 + // socket 全关, 测试进程挂死(本次实测踩过: 整个 node --test 卡到超时) + res.writeHead(200, { 'content-type': 'application/json', connection: 'close' }); + res.end(JSON.stringify({ text: '' })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + try { + // 必须 runSkillAsync: spawnSync 冻结事件循环, 本地服务器 accept 不了子进程请求 → 互等死锁 + const r = await runSkillAsync('asr.mjs', ['--api-key', 'sk-test-not-real', '--allow-any-endpoint', + '--base-url', `http://127.0.0.1:${port}`, '--file', mp3]); + assert.equal(sawAuth, 'Bearer sk-test-not-real', '本地服务器应收到 Bearer 假 Key'); + // 脚本本身成功与否不强断言(响应是假 JSON); 关键证据是 sawAuth + assert.ok(r.status === 0 || (r.stdout + r.stderr).length > 0); + } finally { + server.closeAllConnections?.(); + server.close(); + server.unref(); + } + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/fetch-policy.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/fetch-policy.test.mjs new file mode 100644 index 00000000..5bc4dc57 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/fetch-policy.test.mjs @@ -0,0 +1,89 @@ +// 安全边界 · 抓图 URL 策略(SSRF / 本地文件读 / 重定向 / 文件名) +// 对应评审意见 4: SSRF and local-file-read behavior in fetch-official-images.mjs。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isBlockedHost, assertFetchableUrl, assertRedirectTarget, sanitizeFilename, MAX_REDIRECTS, PolicyError, +} from '../scripts/url-policy.mjs'; + +describe('isBlockedHost(内网/本地/元数据/裸主机名)', () => { + const blocked = [ + '127.0.0.1', '127.8.9.10', // loopback + '169.254.169.254', '169.254.0.9', // 链路本地(含云元数据) + '10.0.0.5', '172.16.0.1', '172.31.9.9', '192.168.1.1', // 私网 + '100.64.0.1', // CGNAT + '0.0.0.0', + '::1', '::', 'fe80::1', 'fc00::1', 'fd12:3456::1', // IPv6 loopback/链路本地/ULA + '::ffff:127.0.0.1', '::ffff:10.0.0.1', // IPv4-mapped + 'localhost', 'intranet-app', // 无点主机名 + 'box.local', 'corp.internal', 'x.home.arpa', + '', // 空 + ]; + const allowed = [ + '8.8.8.8', '1.1.1.1', '172.32.0.1', '172.15.0.1', // 公网(含私网段边界外) + 'api.minimaxi.com', 'example.com', 'cdn.example.co.uk', + '::ffff:8.8.8.8', '2606:4700::1111', + ]; + for (const h of blocked) test(`拦 ${h}`, () => assert.equal(isBlockedHost(h), true, h)); + for (const h of allowed) test(`放 ${h}`, () => assert.equal(isBlockedHost(h), false, h)); +}); + +describe('assertFetchableUrl', () => { + test('公网 https/http 放行', () => { + assertFetchableUrl('https://example.com/a.png'); + assertFetchableUrl('http://example.com/a.png'); + }); + test('内网/元数据/裸名拒绝', () => { + for (const u of ['https://127.0.0.1/x', 'http://169.254.169.254/latest', 'https://192.168.0.1/', 'http://localhost/', 'http://intranet/x']) { + assert.throws(() => assertFetchableUrl(u), PolicyError, u); + } + }); + test('file:// 默认拒绝, --allow-file 放行', () => { + assert.throws(() => assertFetchableUrl('file:///C:/page.html'), PolicyError); + assertFetchableUrl('file:///C:/page.html', { allowFile: true }); + }); + test('带 userinfo 的 URL 拒绝(凭据会泄漏进日志)', () => { + assert.throws(() => assertFetchableUrl('https://user:pw@example.com/a.png'), PolicyError); + }); + test('非 http(s)/file 协议拒绝', () => { + for (const u of ['ftp://example.com/a', 'javascript:alert(1)', 'data:text/html,x']) { + assert.throws(() => assertFetchableUrl(u), PolicyError, u); + } + }); +}); + +describe('重定向目标逐跳校验', () => { + test('重定向到私网/元数据/file: 拒绝', () => { + for (const loc of ['http://10.0.0.5/x.png', 'http://169.254.169.254/a', 'file:///C:/Windows/win.ini', 'https://evil.example']) { + if (loc === 'https://evil.example') continue; // 公网第三方在抓图语境是允许的(offsite 仅标注) + assert.throws(() => assertRedirectTarget(loc), PolicyError, loc); + } + }); + test('重定向到公网放行; MAX_REDIRECTS 有上限', () => { + assertRedirectTarget('https://cdn.example.com/a.png'); + assert.equal(typeof MAX_REDIRECTS, 'number'); + assert.ok(MAX_REDIRECTS >= 1 && MAX_REDIRECTS <= 10, '重定向上限应在 1–10 跳'); + }); +}); + +describe('sanitizeFilename(页面控制的 alt → 落盘名)', () => { + test('路径分隔符与控制字符被替换', () => { + const n = sanitizeFilename('brand/logo\\main v2'); + assert.ok(!n.includes('/') && !n.includes('\\'), n); + }); + test('Windows 保留名被替换', () => { + for (const bad of ['CON', 'con', 'NUL', 'com1', 'LPT9']) { + assert.ok(sanitizeFilename(bad) !== bad, `保留名 ${bad} 必须被换掉`); + } + }); + test('开头点/空格被剥(不产生隐藏文件)', () => { + assert.ok(!sanitizeFilename(' .hidden').startsWith('.'), sanitizeFilename(' .hidden')); + }); + test('空/undefined → fallback', () => { + assert.equal(sanitizeFilename(''), 'official'); + assert.equal(sanitizeFilename(undefined), 'official'); + }); + test('长度有上限', () => { + assert.ok(sanitizeFilename('x'.repeat(500)).length <= 30); + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/helpers.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/helpers.mjs new file mode 100644 index 00000000..968cb636 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/helpers.mjs @@ -0,0 +1,56 @@ +// html2video-for-mcode · 测试共用件(文件名不匹配 *.test.mjs, 不会被 node --test 当作用例执行) +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +export const SCRIPTS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts'); +export const skill = name => path.join(SCRIPTS, name); + +// 跑一个技能脚本, 返回 { status, stdout, stderr } +export function run(cmd, args, opts = {}) { + const r = spawnSync(cmd, args, { encoding: 'utf8', windowsHide: true, ...opts }); + return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; +} +export const runSkill = (name, args, opts = {}) => run(process.execPath, [skill(name), ...args], opts); + +// 异步版: 子进程与父进程需要并发交互时必须用这个 —— spawnSync 会冻结事件循环, +// 父进程里的本地服务器 accept 不了子进程的连接, 互相等死(本次实测踩过) +import { spawn } from 'node:child_process'; +export function runSkillAsync(name, args, opts = {}) { + return new Promise(resolve => { + const c = spawn(process.execPath, [skill(name), ...args], { windowsHide: true, ...opts }); + let stdout = '', stderr = ''; + c.stdout.on('data', d => { stdout += d; }); + c.stderr.on('data', d => { stderr += d; }); + c.on('error', err => resolve({ status: -1, stdout, stderr: stderr + String(err) })); + c.on('close', status => resolve({ status, stdout, stderr })); + }); +} + +// 临时目录 +export const tmpdir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'h2v-test-')); + +// 在 subprocess 里调用 tools.mjs 的退出型助手(safeId/safeRel 会 process.exit, 只能这样测) +export function probeHelper(expr, argsJson) { + const code = ` + import { ${expr} } from ${JSON.stringify('file://' + skill('tools.mjs').replace(/\\\\/g, '/'))}; + const args = ${argsJson}; + const v = ${expr}(...args); + process.stdout.write('RETURN:' + String(v)); + `; + return run(process.execPath, ['--input-type=module', '-e', code]); +} + +// 最小可跑的项目骨架(check-slides 需要 tokens.css; 其余脚本按需补) +export function mkproj(dir, { slides = [], tokens = ':root { --accent: #111; }' } = {}) { + fs.mkdirSync(path.join(dir, 'slides'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'audio'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'build'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'slides', 'tokens.css'), tokens); + fs.writeFileSync(path.join(dir, 'script.json'), JSON.stringify({ + topic: 't', fps: 30, width: 1920, height: 1080, slides, + }, null, 2)); + return dir; +} diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/no-clobber.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/no-clobber.test.mjs new file mode 100644 index 00000000..eb678a0a --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/no-clobber.test.mjs @@ -0,0 +1,89 @@ +// 安全边界 · 覆盖拒绝(不再无条件覆写用户/agent 已有内容) +// 对应评审意见 2: destructive overwrite behavior。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runSkill, mkproj, tmpdir } from './helpers.mjs'; + +describe('init-project: 非空目录拒绝 / --force 只重置生成文件', () => { + test('空目录 → 正常生成', () => { + const proj = tmpdir(); + const r = runSkill('init-project.mjs', [proj, '--topic', 'T']); + assert.equal(r.status, 0, r.stderr); + assert.ok(fs.existsSync(path.join(proj, 'script.json'))); + }); + + test('非空目录(有无关文件) → 拒绝, 退出 1, 无关文件不动', () => { + const proj = tmpdir(); + fs.writeFileSync(path.join(proj, 'user-notes.txt'), 'mine'); + const r = runSkill('init-project.mjs', [proj, '--topic', 'T']); + assert.notEqual(r.status, 0); + assert.ok(r.stderr.includes('--force'), '应提示 --force'); + assert.equal(fs.readFileSync(path.join(proj, 'user-notes.txt'), 'utf8'), 'mine', '无关文件不得被动'); + }); + + test('已有 script.json 的项目 → 拒绝并列出将被覆写的文件', () => { + const proj = mkproj(tmpdir(), { slides: [{ id: '01', clauses: [{ stage: 1, text: '已填的口播' }] }] }); + const r = runSkill('init-project.mjs', [proj]); + assert.notEqual(r.status, 0); + assert.ok(r.stderr.includes('script.json'), '应点名 script.json'); + assert.equal(JSON.parse(fs.readFileSync(path.join(proj, 'script.json'), 'utf8')).slides[0].clauses[0].text, '已填的口播', '已填内容不得被清'); + }); + + test('--force → 重新生成, 无关文件保留', () => { + const proj = mkproj(tmpdir(), { slides: [{ id: '01', clauses: [{ stage: 1, text: '旧' }] }] }); + fs.writeFileSync(path.join(proj, 'user-notes.txt'), 'mine'); + const r = runSkill('init-project.mjs', [proj, '--force']); + assert.equal(r.status, 0, r.stderr); + assert.equal(fs.readFileSync(path.join(proj, 'user-notes.txt'), 'utf8'), 'mine', '--force 也不删无关文件'); + const script = JSON.parse(fs.readFileSync(path.join(proj, 'script.json'), 'utf8')); + assert.equal(script.slides[0].clauses[0].text, '', '--force 后 script.json 被重置'); + }); + + test('--topic 的 HTML 转义: + + +`; +} + +// ─────────────────────────── 主流程 ─────────────────────────── + +function main() { + const argv = process.argv.slice(2); + const dir = path.resolve(argv.find(a => !a.startsWith('--')) ?? '.'); + const OPEN = argv.includes('--open'); + 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; + if (timings) { + const byId = new Map((timings.slides ?? []).map(t => [t.id, t])); + const missing = script.slides.filter(s => !byId.has(s.id)).map(s => s.id); + if (missing.length) console.warn(`⚠ timings.json 里没有 ${missing.join(', ')} —— 这几张用占位延迟`); + } + + const slidesDir = path.join(dir, 'slides'); + const outDir = path.join(dir, 'preview', 'play'); + fs.mkdirSync(outDir, { recursive: true }); + + // 老项目(第 8 轮之前生成的 tokens.css)没有 no-fx 规则: 只给"关动效副本"兜底注入, + // 并在页面上如实说明 —— 否则 X 键对照会把画面锁在入场前的透明态, 看着像整片空白。 + const tokensPath = path.join(slidesDir, 'tokens.css'); + const tokensCss = fs.existsSync(tokensPath) ? fs.readFileSync(tokensPath, 'utf8') : ''; + const noFxStale = !hasNofxRules(tokensCss); + const noFxNote = noFxStale + ? `本项目 slides/tokens.css ${tokensCss ? '没有 no-fx 规则(旧模板生成的)' : '不存在'} —— 关动效副本里已兜底注入。想让 对成片也生效, 请用新版 init-project 重生成 tokens.css` + : ''; + + const rows = []; + for (const s of script.slides) { + const sid = safeId(s.id); + // 安全性照旧收监(越界仍退出 1); 但"这张还没写"按 capture 的惯例跳过并警告 —— + // 写到第 3 张就想先放映看看是常态, 不该因为后面几张还没写就整个出不来。 + const relHtml = s.html ?? `${sid}.html`; + const srcPath = safeRel(slidesDir, relHtml, { where: `slides[${sid}].html` }); + if (!fs.existsSync(srcPath)) { console.warn(`- 跳过 ${sid}: 缺 slides/${relHtml}`); continue; } + const html = fs.readFileSync(srcPath, 'utf8'); + const base = path.basename(srcPath); + const t = timings?.slides?.find(x => x.id === s.id); + const stages = t?.stages ?? {}; + const decl = stageVars(stages, { w: script.width ?? 1920, h: script.height ?? 1080 }); + + let copy = addBaseHref(injectHtmlVars(html, decl), '../../slides/'); + copy = `\n` + copy; + fs.writeFileSync(path.join(outDir, base), copy); + const nofxName = base.replace(/\.html?$/i, '') + '.nofx.html'; + let nofx = addNoFx(copy); + if (noFxStale) nofx = injectStyle(nofx, NOFX_CSS); + fs.writeFileSync(path.join(outDir, nofxName), nofx); + + const stageVals = Object.values(stages).filter(Number.isFinite); + rows.push({ + id: sid, name: base, title: s.title ?? s.layout ?? '', + copy: base, copyNofx: nofxName, + duration: t?.duration, + lastStage: stageVals.length ? Math.max(...stageVals) : null, + clauses: t?.clauses ?? (s.clauses ?? []).map(c => ({ stage: c.stage ?? null, start: null, text: c.text, text2: c.text2 })), + }); + } + + const p2 = n => String(n).padStart(2, '0'); + const now = new Date(); + if (!rows.length) { console.error('✗ 一张可放映的 slide 都没有(检查 script.json 的 html 字段与 slides/ 目录)'); process.exit(1); } + const page = buildPlayPage({ + topic: script.topic ?? '', lang: script.lang ?? 'zh', slides: rows, hasTimings, noFxNote, + generatedAt: `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())} ${p2(now.getHours())}:${p2(now.getMinutes())}`, + }); + fs.writeFileSync(path.join(outDir, 'index.html'), page); + + console.log(`✓ 放映页 → ${path.relative(process.cwd(), path.join(outDir, 'index.html'))}`); + console.log(` 快照 ${rows.length} 张 ×2(动效/关动效) → ${path.relative(process.cwd(), outDir)}/${rows[0]?.name ?? '.html'}`); + if (!hasTimings) console.warn('⚠ 缺 build/timings.json: 快照用的是 tokens.css 占位延迟, 时序不是成片时序 —— 先跑 plan-timings.mjs'); + else console.log(` 已注入实测 stage 延迟: ${rows.map(r => `${r.id}(${r.lastStage != null ? '末层 ' + r.lastStage.toFixed(1) + 's' : '无 stage'})`).join(' ')}`); + if (noFxStale) console.warn(`⚠ slides/tokens.css ${tokensCss ? '缺 no-fx 规则(旧模板生成的)' : '不存在'} —— 关动效副本已兜底注入; 想让它对成片也生效, 用新版 init-project 重生成 tokens.css`); + console.log(' 快捷键: ← → 翻页 · R 重播 · X 关动效对比 · P 提词面板 · O 总览 · F 全屏'); + + if (OPEN) { + const target = path.join(outDir, 'index.html'); + const cmd = process.platform === 'win32' ? ['cmd', ['/c', 'start', '', target]] + : process.platform === 'darwin' ? ['open', [target]] : ['xdg-open', [target]]; + spawn(cmd[0], cmd[1], { detached: true, stdio: 'ignore' }).unref(); + console.log(' 已在浏览器打开(若没弹出, 手动双击上面的 index.html)'); + } +} + +// 被 tests/ import 时不要跑主流程(只有当脚本直接执行才跑) +const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).toLowerCase() === fileURLToPath(import.meta.url).toLowerCase(); +if (invokedDirectly) main(); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/preview-page.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/preview-page.test.mjs new file mode 100644 index 00000000..fec938db --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/preview-page.test.mjs @@ -0,0 +1,173 @@ +// 放映页(preview-page): 快照时序注入 + no-fx 兜底 + 自包含 + 路径收监 +// 这是"用户自己在浏览器里先放映一遍"的交付物, 也是"元素永久不可见"那类静默故障的目视闸门。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runSkill, mkproj, tmpdir, SCRIPTS } from './helpers.mjs'; + +const mod = await import('file://' + path.join(SCRIPTS, 'preview-page.mjs').replace(/\\/g, '/')); +const { stageVars, injectHtmlVars, addNoFx, addBaseHref, injectStyle, buildPlayPage } = mod; +const { hasNofxRules } = await import('file://' + path.join(SCRIPTS, 'nofx-css.mjs').replace(/\\/g, '/')); + +const SLIDE = ` + + + + + +

文案

+ +`; + +describe('预览放映页 · 纯函数', () => { + test('stageVars: 秒 → 整数毫秒, 负数归零, 非数值跳过', () => { + assert.equal(stageVars({ 1: 0, 2: 4.8655 }, { w: 1920, h: 1080 }), + '--t1:0ms;--t2:4866ms;--stage-w:1920px;--stage-h:1080px'); + assert.equal(stageVars({ 2: -3 }, {}), '--t2:0ms'); + assert.equal(stageVars({ 2: 'x' }, {}), ''); + }); + + test('injectHtmlVars: 写进 的 style(等价 capture 的 documentElement 注入)', () => { + const out = injectHtmlVars(SLIDE, '--t2:4866ms'); + assert.match(out, //); + assert.ok(out.includes(''), '正文不得被改动'); + }); + + test('injectHtmlVars: 已有 style 时合并而不是覆盖', () => { + const html = ''; + assert.equal(injectHtmlVars(html, '--t1:0ms'), ''); + }); + + test('addNoFx: 无 class 时新增, 有 class 时追加, 重复调用不叠加', () => { + assert.match(addNoFx(SLIDE), //); + const once = addNoFx(''); + assert.equal(once, ''); + assert.equal(addNoFx(once), once); + }); + + test('addBaseHref: 插在 之后、link 之前(base 必须早于相对 URL)', () => { + const out = addBaseHref(SLIDE, '../../slides/'); + assert.ok(out.indexOf('') < out.indexOf('href="tokens.css"'), 'base 必须排在相对链接前面'); + }); + + test('injectStyle: 插到 前(tokens.css 之后, 后写覆盖)', () => { + const out = injectStyle(SLIDE, '.no-fx [data-stage]{opacity:1 !important}'); + assert.ok(out.indexOf('tokens.css') < out.indexOf('.no-fx [data-stage]')); + assert.ok(out.indexOf('.no-fx [data-stage]') < out.indexOf('')); + }); + + test('buildPlayPage: 自包含(无外链/无外站脚本)且含全部张次', () => { + const page = buildPlayPage({ + topic: 'T', slides: [ + { id: '01', name: '01.html', copy: '01.html', copyNofx: '01.nofx.html', duration: 6.3, lastStage: 3.2, clauses: [{ stage: 1, start: 0, text: '甲' }] }, + { id: '02', name: '02.html', copy: '02.html', copyNofx: '02.nofx.html', duration: 5, lastStage: 2, clauses: [] }, + ], noFxNote: '注意 X', + }); + assert.ok(!/https?:/.test(page), '不得引用任何外部 URL(离线双击可用)'); + assert.ok(!/]+src=/i.test(page), '不得外链脚本'); + assert.ok(page.indexOf('01.html') < page.indexOf('02.html'), '张次顺序保持'); + assert.ok(page.includes('01.nofx.html'), '关动效副本要在模型里'); + assert.ok(page.includes('注意 X'), 'tokens.css 落后时应把提示带给用户'); + }); +}); + +describe('预览放映页 · 落到磁盘', () => { + const build = (proj, { tokens, timings } = {}) => { + mkproj(proj, { + slides: [{ id: '01', layout: 'statement', html: '01-title.html', audio: '01.mp3', title: '开场', clauses: [{ stage: 1, text: '第一句。' }, { stage: 2, text: '第二句。' }] }], + ...(tokens ? { tokens } : {}), + }); + fs.writeFileSync(path.join(proj, 'slides', '01-title.html'), SLIDE); + if (timings) { + fs.writeFileSync(path.join(proj, 'build', 'timings.json'), JSON.stringify({ + fps: 30, slides: [{ id: '01', duration: 6.3, stages: { 1: 0, 2: 4.8655 }, clauses: [{ stage: 1, start: 0, text: '第一句。' }, { stage: 2, start: 4.87, text: '第二句。' }] }], + })); + } + return proj; + }; + + test('生成放映页 + 两份快照, 副本带真实时序', () => { + const proj = build(tmpdir(), { timings: true }); + const r = runSkill('preview-page.mjs', [proj]); + assert.equal(r.status, 0, r.stderr); + const out = path.join(proj, 'preview', 'play'); + for (const f of ['index.html', '01-title.html', '01-title.nofx.html']) assert.ok(fs.existsSync(path.join(out, f)), `缺 ${f}`); + const copy = fs.readFileSync(path.join(out, '01-title.html'), 'utf8'); + assert.ok(copy.includes('style="--t1:0ms;--t2:4866ms;'), '注入实测延迟(tokens.css 占位值是 800ms)'); + assert.ok(copy.includes('')); + assert.ok(copy.includes('请勿编辑'), '副本要标明是快照'); + assert.ok(fs.readFileSync(path.join(proj, 'slides', '01-title.html'), 'utf8').includes('data-theme="a"'), '原文件不得被改写'); + assert.ok(r.stdout.includes('4866') || r.stdout.includes('末层'), '应打印注入结果'); + }); + + test('老项目 tokens.css 缺 no-fx 规则 → 只给关动效副本兜底注入, 并如实警告', () => { + const proj = build(tmpdir(), { timings: true }); + const r = runSkill('preview-page.mjs', [proj]); + assert.equal(r.status, 0, r.stderr); + const out = path.join(proj, 'preview', 'play'); + const nofx = fs.readFileSync(path.join(out, '01-title.nofx.html'), 'utf8'); + assert.ok(/class="[^"]*no-fx/.test(nofx), '副本根元素要带 no-fx'); + assert.ok(nofx.includes('opacity: 1 !important'), '旧 tokens.css 下必须兜底注入 opacity 重置, 否则对照是空白'); + assert.ok(!fs.readFileSync(path.join(out, '01-title.html'), 'utf8').includes('兜底注入'), '动效副本不得被注入'); + assert.ok(r.stderr.includes('no-fx'), '应在终端说明 tokens.css 落后'); + }); + + test('新 tokens.css(含 no-fx 规则) → 不注入, 保持纯快照', () => { + const tokens = ':root{--accent:#111}\n.no-fx [data-stage], .no-fx .fx-stagger > * { opacity: 1 !important; }'; + assert.equal(hasNofxRules(tokens), true); + const proj = build(tmpdir(), { tokens, timings: true }); + const r = runSkill('preview-page.mjs', [proj]); + assert.equal(r.status, 0, r.stderr); + const nofx = fs.readFileSync(path.join(proj, 'preview', 'play', '01-title.nofx.html'), 'utf8'); + assert.ok(!nofx.includes('兜底注入')); + assert.ok(!r.stderr.includes('no-fx')); + }); + + test('没有 timings.json → 照常出页, 但明确警告时序不是成片的', () => { + const proj = build(tmpdir()); + const r = runSkill('preview-page.mjs', [proj]); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.stderr.includes('timings.json'), '应提示先跑 plan-timings'); + assert.ok(fs.readFileSync(path.join(proj, 'preview', 'play', 'index.html'), 'utf8').includes('占位延迟')); + }); + + test('slides[].html 越界 → 退出 1, 项目外不落任何文件', () => { + const proj = build(tmpdir(), { timings: true }); + const outside = tmpdir(); + const canary = path.join(outside, 'canary.txt'); + fs.writeFileSync(canary, 'SAFE'); + const j = JSON.parse(fs.readFileSync(path.join(proj, 'script.json'), 'utf8')); + j.slides[0].html = '../../' + path.basename(outside) + '/pwn.html'; + fs.writeFileSync(path.join(proj, 'script.json'), JSON.stringify(j)); + const r = runSkill('preview-page.mjs', [proj]); + assert.equal(r.status, 1, '越界必须被 safeRel 拦下'); + assert.equal(fs.readFileSync(canary, 'utf8'), 'SAFE'); + assert.ok(!fs.existsSync(path.join(outside, 'pwn.html'))); + }); + + test('部分张还没写 → 跳过并警告, 其余照常出页(与 capture 的惯例一致)', () => { + const proj = build(tmpdir(), { timings: true }); + const j = JSON.parse(fs.readFileSync(path.join(proj, 'script.json'), 'utf8')); + j.slides.push({ id: '02', layout: 'bullets', html: '02-bullets.html', audio: '02.mp3', clauses: [{ stage: 1, text: '还没写。' }] }); + fs.writeFileSync(path.join(proj, 'script.json'), JSON.stringify(j)); + const r = runSkill('preview-page.mjs', [proj]); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.stderr.includes('跳过 02'), '应点名跳过哪张'); + assert.ok(fs.existsSync(path.join(proj, 'preview', 'play', '01-title.html')), '已写的张照常出'); + assert.ok(!fs.existsSync(path.join(proj, 'preview', 'play', '02-bullets.html'))); + }); + + test('一张可放映的都没有 → 退出 1', () => { + const proj = build(tmpdir(), { timings: true }); + fs.rmSync(path.join(proj, 'slides', '01-title.html')); + const r = runSkill('preview-page.mjs', [proj]); + assert.equal(r.status, 1); + assert.ok(r.stderr.includes('可放映')); + }); + + test('缺 script.json → 退出 1', () => { + const r = runSkill('preview-page.mjs', [tmpdir()]); + assert.equal(r.status, 1); + }); +}); 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..6c3af768 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/tokens-fx.test.mjs @@ -0,0 +1,97 @@ +// 模板自检: 生成出来的 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/); + }); +}); + +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); + }); +}); From acdfefd7915c303a6c3108fca814d3e0023cadbb Mon Sep 17 00:00:00 2001 From: Hahaha Date: Fri, 18 Sep 2026 14:05:33 +0800 Subject: [PATCH 12/24] =?UTF-8?q?ci:=20smoke=20workflow=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=96=B0=E5=A2=9E=202=20=E4=B8=AA=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6(=E5=85=B1=207=20=E4=B8=AA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/html2video-for-mcode-smoke.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/html2video-for-mcode-smoke.yml b/.github/workflows/html2video-for-mcode-smoke.yml index cec95e09..89e603bf 100644 --- a/.github/workflows/html2video-for-mcode-smoke.yml +++ b/.github/workflows/html2video-for-mcode-smoke.yml @@ -36,13 +36,15 @@ jobs: npm install --no-save --no-package-lock playwright npx playwright install --with-deps chromium - - name: Security & policy tests (containment / overwrite refusal / endpoint allowlist / SSRF) + - 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 \ --test-timeout=60000 - name: Render smoke (init → timings → static gate → capture → build) From 745518145685c9c2b47b3127704ac6e347744d66 Mon Sep 17 00:00:00 2001 From: Hahaha Date: Fri, 18 Sep 2026 14:18:18 +0800 Subject: [PATCH 13/24] =?UTF-8?q?refine:=20=E6=94=BE=E6=98=A0=E9=A1=B5?= =?UTF-8?q?=E5=8F=AA=E5=81=9A=E6=94=BE=E6=98=A0(=E5=8E=BB=E8=AE=A1?= =?UTF-8?q?=E6=97=B6=E5=99=A8/=E8=BF=9B=E5=BA=A6=E6=9D=A1/=E8=B7=9F?= =?UTF-8?q?=E8=AF=BB=E9=AB=98=E4=BA=AE)=20+=20=E5=93=8D=E5=BA=94=E5=BC=8F?= =?UTF-8?q?=E5=B8=83=E5=B1=80;=20=E5=8F=A3=E6=92=AD=20UI=20=E6=8C=89?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=87=AA=E5=8A=A8=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Wzdhehe/html2video-for-mcode/CHANGELOG.md | 193 +++++------ .../Wzdhehe/html2video-for-mcode/README.md | 17 +- .../html2video-for-mcode/README.zh-CN.md | 12 +- .../skills/html2video-for-mcode/SKILL.md | 2 +- .../html2video-for-mcode/evals/evals.json | 2 +- .../html2video-for-mcode/references/render.md | 26 +- .../scripts/preview-page.mjs | 319 +++++++++++------- .../tests/preview-page.test.mjs | 72 +++- 8 files changed, 405 insertions(+), 238 deletions(-) diff --git a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md index c094a400..b7785ebd 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md +++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md @@ -1,96 +1,97 @@ -# Changelog - -## 1.1.0 — 2026-09-18 - -**安全边界(响应 PR #41 评审的五条 Request changes)** - -- **路径收监**:`script.json` 是 agent 可编辑文件,其 `slides[].id/html/audio` 与 `bgm.file` 此前被直接拼进文件路径。现于 `tools.mjs` 新增 `safeId / safeRel / inside / validateScriptPaths / validateTimingsIds`,六个消费脚本(capture / build-video / plan-timings / check-timing / check-slides / asr)在 `JSON.parse` 后立即校验:id 走白名单 `^[A-Za-z0-9_-]{1,64}$`,路径拒绝绝对路径、resolve 后必须落在项目目录内、并对符号链接做 realpath 复核。此前 `id="../../victim"` 可触发**项目目录外的递归删除**(`capture.mjs` 的 `rmSync(build/frames/, {recursive:true})`)。 -- **覆盖拒绝**:`init-project.mjs` 对已存在且非空的目录直接拒绝(列出将被覆写的 5 个生成文件),需显式 `--force`;`fetch-official-images.mjs` 的 `--out-dir` 默认收监在工作目录内、已存在文件不覆盖;`prep-image.mjs --crop` 输出已存在需 `--force`。顺带修 `--topic` 未转义即插入模板 HTML 的问题。 -- **ASR 端点白名单**:API Key 只发官方域(`api.minimaxi.com` / `api.minimax.io`);`--base-url` / `MINIMAX_BASE_URL` 指向其他地址一律硬拒绝,自建网关需显式 `--allow-any-endpoint`(打印醒目警告)。此前被偷换的环境变量可把 Key 发往任意端点。 -- **抓图 SSRF 收紧**:新增 `scripts/url-policy.mjs`(纯函数)。拦 loopback / 链路本地(含云元数据 169.254.169.254)/ 私网 / CGNAT / 无点主机名;只允许 http(s),`file://` 需显式 `--allow-file`;禁带 userinfo 的 URL;`maxRedirects:0` 手动跟重定向且**逐跳**复用同一策略;响应大小上限默认 30MB(`--max-mb`);落盘文件名清洗补 Windows 保留名。 -- **可执行测试**:新增 `tests/`(node:test,零依赖,从仓库根 `node --test` 自动发现 → 被 `npm run check` 真实执行)—— `safe-paths`(恶意 id/路径 + canary 完好性 + symlink 逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(白名单拒绝 + 本地假服务器收到 Bearer 假 Key 的正向证据)、`fetch-policy`(host/URL/重定向/文件名 44 例)、`render-smoke`(init → 静音音频 → plan-timings → check-slides → capture → build-video 全链出片)。共 72 例,本地全绿。另附 scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml`(path-filter 只在本插件变更时跑,装 ffmpeg + playwright 后真实执行,含渲染冒烟)。 - -**图表与动效** - -- `references/authoring.md` 新增 **纯 CSS/SVG 图表**章节:横向条形 / 柱状 / 环形(`conic-gradient`)/ 折线(inline SVG)/ 进度条五种画法与选型速查;三条底线 = 禁外链图表库(离线取不到且 canvas 动画逐帧 seek 不到)、数值必须来自已核实口径、禁止 AI 生图当图表。 -- 新增 `fx-grow-x` / `fx-grow-y`(条形从左、柱状从底生长),折线 `fx-draw` 描边画入;**动效可一键关**:根元素或任意容器加 `no-fx`,关掉后 motion 捕获自动退化为静态帧,时长与音画同步不变(实测动画终态 vs `no-fx` 帧 PSNR 51.7dB,画面一致)。 -- `timeline` 版式改为整轴淡入 + 时间点逐个错峰入场。 - -**静默故障(继续加闸门)** - -- **fx 关键帧不含 opacity → 元素永久隐形**:`[data-stage]` 的基础态是 `opacity:0`,靠动画抬回 1;只做 transform/描边的动画(`fx-grow-x/y`、既有的 `fx-draw`)不改 opacity 就会永远不可见。关键帧已补 `opacity:1`,并在 `check-slides.mjs` 新增 **5b 项**静态拦截(fx 类的关键帧未声明 opacity → ✗)。 -- **旧帧目录污染成片**:切 `no-fx` 或改用 still 后,上一轮 motion 的帧目录仍在,`build-video` 会优先用残留帧把过时动画混进成片;capture 现在在产出静态图的路径上主动作废该张帧目录。 -- **柱状图模板高度塌陷**:外层容器写 `align-items:flex-end` 会让列 wrapper 高度塌成内容高,柱子百分比高度变 0(静默不显示);模板改为默认 stretch + 列内 `justify-content:flex-end`,并在文档里写明这个坑。 - -**放映页(可以先放映一遍再渲染)** - -- 新增 `scripts/preview-page.mjs <项目> [--open]` → `preview/play/index.html`:单文件、零依赖、`file://` 双击即看的放映页。`←` `→` 翻页、`R` 重播入场动画、`P` 提词面板(该张 clauses 按播放时间高亮,并标出"成片 X s · 最后一层入场 ≈ Y s",收尾静止不足 1.2s 标红)、`O` 总览(渲 `preview/*.png`)、`F` 全屏、`X` **动效 / 关动效对照**。 -- 为什么不是"直接打开 `slides/*.html`":延迟 `--t1/--t2/--t3` 与画布尺寸由渲染管线按 `timings.json` 注入,tokens.css 里只有占位值(`--t2:800ms`),直接开原文件会看到"所有动画挤在开头两秒"。放映页生成**快照副本**(`preview/play/.html`),把实测延迟写进 ``(等价于管线注入,优先级最高)并加 `` 让主题与素材照常解析。实测:同一张 t=3.0s,副本第二层 `opacity 0`(未入场),原文件 `opacity 1`(已入场)——与成片一致的是副本。 -- 副本是快照,改完 `slides/` 必须重跑;页面与副本头部都写明真实文件路径与生成时间。找不到 `build/timings.json` 时照常出页,但会明确警告"时序不是成片的"。 -- 老项目没有 `no-fx` 规则时,只给关动效副本兜底注入该规则并在页面提示(否则 `X` 切过去是空白,会被误判成"关动效 bug")。 -- 新增 `init-project.mjs --upgrade-css`:给老项目的 `tokens.css` 幂等补上新版 `no-fx` 规则(只动这一个文件,不碰 `script.json` 等其他内容)。此前对所有旧项目的建议"用新版 init-project 重生成"是错的 —— 那需要 `--force`,会重置 `script.json`。 -- 新增 `scripts/nofx-css.mjs`:`no-fx` 规则的唯一来源(`init-project` 写入 tokens.css、`preview-page` 兜底注入共用一份,避免 CSS 漂移)。 -- `fx-spotlight` 的关键帧补 `opacity: 1` —— 它是本技能文档里列为可用的入场类,但只做 `clip-path`,基础态 `opacity:0` 抬不回来 → 用了就永久隐形(被 `check-slides` 的 5b 项拦住,即"文档说能用、闸门说不能用")。 -- SKILL.md 的 Gate 4 增加"放映页交用户自己放一遍";`references/render.md` 增放映页章节(键位、为什么要副本、快照纪律);`authoring.md` 的动效开关一节写明交付前用 `X` 对照验收。 -- 测试 +21 例(共 93):`preview-page`(注入/合并/no-fx/base 顺序/自包含无外链/越界拒绝/幂等升级)、`tokens-fx`(对模板断言**每个非无限 fx 动画的关键帧都声明 opacity**、no-fx 规则含 opacity 重置、`--upgrade-css` 幂等且不碰其他文件)。 - -**文档** - -- 开工对齐"主题与受众"→"**主题与领域**"(受监管题材必问免责声明与数据出处标注);新增 `references/compliance.md`(财经口播红线、数字三要件、涨跌色按受众翻转、免责声明写法、医疗/法律/广告法、Gate 清单);`tokens.css` 增 `--up/--down` 与 `.disclaimer`;`check-slides.mjs` 增整片级财经关键词自查。 -- **层的入场顺序不再固定为"标题先行"**:改为"每张至少两个信息层、分属不同 stage,顺序由强同步原则决定"(大数字先入 / 设问先出 / 图先入都合法),要拦的是"只有一个层"。 -- 补 `roadmap` 版式的 HTML 片段(此前表格里有、代码块缺失),并修 kpi-grid 片段使用未定义类 `.grid g4`(会静默竖排)的问题。 - -## 1.0.1 — 2026-09-17 - -**新增:领域与合规(受监管题材)** - -- `references/compliance.md`(新)— 领域确认问法(题材 + 受众 + 是否受监管)、财经口播三条红线(不给操作建议 / 不预测价格 / 不编数据图形)、数字三要件(**口径 + 币种 + 时点**)、涨跌色按受众翻转(A 股/港股 = 红涨绿跌)、免责声明写法与位置(`.disclaimer`,停留 ≥3s,口播不念也不进字幕)、医疗 / 法律 / 政务 / 广告法要点、Gate 收尾检查清单 -- **开工对齐**第一批问题从"主题与受众"升级为"**主题与领域**":受监管题材(财经投研 / 医疗健康 / 法律 / 政务政策 / 营销效果宣称)必须多问一句"要不要免责声明与数据出处标注"(默认要),并把结论记进 Gate 0 与 `research/notes.md` -- `tokens.css` 新增 `--up` / `--down` 涨跌专用令牌(默认 = `--good` / `--bad`)与 `.disclaimer` / `.disclaimer-box` 原语(纯附加,旧项目与既有主题不受影响) -- `check-slides.mjs` 新增**整片级领域自查**:命中多个财经/投研关键词却没有免责或出处行 → 给出提示(提示而非错误;用户已明确不要免责可忽略) -- `research.md` 增"先确认领域"与财经/投研题材坑(同比≠环比、GAAP≠非GAAP、把旧时点当"目前");`authoring.md` 增免责声明片段、kpi-grid 改用 `var(--up)/var(--down)`、主题速查增财经行;`evals` 增 2 条(财经开局确认、涨跌色按受众) - -## 1.0.0 — 2026-09-17 - -首个公开版本。 - -**流水线**:开工对齐(语言/风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 → HTML 分步入场 → 逐帧渲染 → ASR 反向校验,共 7 阶段 6 个确认闸门。 - -**脚本(11 个,纯 Node,无构建步骤)** - -| 脚本 | 作用 | -|---|---| -| `init-project.mjs` | 生成项目骨架(目录 + tokens.css + slide 模板 + script.json 契约) | -| `plan-timings.mjs` | ffprobe 实测每段 TTS → 时长、每层入场时刻、每句开口时刻 | -| `check-timing.mjs` | 静音检测实测每句真实开口,与估算对比并可校准 | -| `check-theme.mjs` | 全部主题的 WCAG 对比度闸门(正文/次级/字幕/强调色) | -| `check-slides.mjs` | 渲染前静态检查(未定义变量/图片缺失/外链/data-stage 未配动画/硬编码颜色) | -| `capture.mjs` | 终态截图或逐帧步进捕获,字幕默认烧录 | -| `build-video.mjs` | 编码 → 拼接 → 音轨对位 → BGM 混音 → 合成 → 自检 + SRT | -| `asr.mjs` | ASR 转写与脚本比对(直调 REST),支持字级时间戳核对开口时刻 | -| `prep-image.mjs` | 配图检查与受限裁切(裁掉面积上限 20%) | -| `fetch-official-images.mjs` | 从官方站点列取并下载候选素材图 | -| `tools.mjs` | ffmpeg/ffprobe 与 Node 包的多锚点探测 | - -**设计系统**:13 套主题、17 种版式、图片框原语(`.img-frame`)、14 个入场/氛围动画、分步入场与错峰容器。 - -**语言**:中文普通话 / 英语 / 粤语,字数与语速基准、字幕行宽、ASR 语言校验随语言切换。 - -**画布**:1920×1080 横屏与 1080×1920 竖版(字幕几何随画布比例自适应)。 - -**字幕**:单语或双语烧录,另出 `out/subs.srt`。 - -**声音**:BGM 垫底(可选,自动循环与淡入淡出)、ASR 反向校验(音色语种、数字与专名一致性)。 - -**资料搜集**:`references/research.md` —— 来源四级分级、四条硬规则(多源交叉验证/一手优先/标注口径日期/不确定不进脚本)、query 设计、多源矛盾处理、notes 模板、各环境搜索工具差异(含 mmx search 10 条上限)。 - -**两套工具链**:mcode 沙箱用 platform connectors;其他 Agent 环境用 mmx-cli(配 TTS)与本仓 `asr.mjs`(配 ASR)。 - -**已修复的静默故障(均加了闸门)** - -- 入场延迟被 `animation` 简写覆盖 → 元素在 0 秒入场(改用变量槽传递延迟) -- 未定义 CSS 变量 + 透明文字填充 → 文字完全隐形(check-slides 静态拦截) -- 图片加载失败 → 只出 broken 图标而无报错(capture 运行期点名) -- 缺少 clauses → 静默出无字幕片(capture 告警) -- 字幕窗口重叠 → 相邻两句同时可见, 看起来像"重影/错字"(淡出改为在窗口内归零, 并加窗口自检) -- 主题覆盖被忽略、同名选择器只取首个块(check-theme 合并语义修正) +# Changelog + +## 1.1.0 — 2026-09-18 + +**安全边界(响应 PR #41 评审的五条 Request changes)** + +- **路径收监**:`script.json` 是 agent 可编辑文件,其 `slides[].id/html/audio` 与 `bgm.file` 此前被直接拼进文件路径。现于 `tools.mjs` 新增 `safeId / safeRel / inside / validateScriptPaths / validateTimingsIds`,六个消费脚本(capture / build-video / plan-timings / check-timing / check-slides / asr)在 `JSON.parse` 后立即校验:id 走白名单 `^[A-Za-z0-9_-]{1,64}$`,路径拒绝绝对路径、resolve 后必须落在项目目录内、并对符号链接做 realpath 复核。此前 `id="../../victim"` 可触发**项目目录外的递归删除**(`capture.mjs` 的 `rmSync(build/frames/, {recursive:true})`)。 +- **覆盖拒绝**:`init-project.mjs` 对已存在且非空的目录直接拒绝(列出将被覆写的 5 个生成文件),需显式 `--force`;`fetch-official-images.mjs` 的 `--out-dir` 默认收监在工作目录内、已存在文件不覆盖;`prep-image.mjs --crop` 输出已存在需 `--force`。顺带修 `--topic` 未转义即插入模板 HTML 的问题。 +- **ASR 端点白名单**:API Key 只发官方域(`api.minimaxi.com` / `api.minimax.io`);`--base-url` / `MINIMAX_BASE_URL` 指向其他地址一律硬拒绝,自建网关需显式 `--allow-any-endpoint`(打印醒目警告)。此前被偷换的环境变量可把 Key 发往任意端点。 +- **抓图 SSRF 收紧**:新增 `scripts/url-policy.mjs`(纯函数)。拦 loopback / 链路本地(含云元数据 169.254.169.254)/ 私网 / CGNAT / 无点主机名;只允许 http(s),`file://` 需显式 `--allow-file`;禁带 userinfo 的 URL;`maxRedirects:0` 手动跟重定向且**逐跳**复用同一策略;响应大小上限默认 30MB(`--max-mb`);落盘文件名清洗补 Windows 保留名。 +- **可执行测试**:新增 `tests/`(node:test,零依赖,从仓库根 `node --test` 自动发现 → 被 `npm run check` 真实执行)—— `safe-paths`(恶意 id/路径 + canary 完好性 + symlink 逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(白名单拒绝 + 本地假服务器收到 Bearer 假 Key 的正向证据)、`fetch-policy`(host/URL/重定向/文件名 44 例)、`render-smoke`(init → 静音音频 → plan-timings → check-slides → capture → build-video 全链出片)。共 72 例,本地全绿。另附 scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml`(path-filter 只在本插件变更时跑,装 ffmpeg + playwright 后真实执行,含渲染冒烟)。 + +**图表与动效** + +- `references/authoring.md` 新增 **纯 CSS/SVG 图表**章节:横向条形 / 柱状 / 环形(`conic-gradient`)/ 折线(inline SVG)/ 进度条五种画法与选型速查;三条底线 = 禁外链图表库(离线取不到且 canvas 动画逐帧 seek 不到)、数值必须来自已核实口径、禁止 AI 生图当图表。 +- 新增 `fx-grow-x` / `fx-grow-y`(条形从左、柱状从底生长),折线 `fx-draw` 描边画入;**动效可一键关**:根元素或任意容器加 `no-fx`,关掉后 motion 捕获自动退化为静态帧,时长与音画同步不变(实测动画终态 vs `no-fx` 帧 PSNR 51.7dB,画面一致)。 +- `timeline` 版式改为整轴淡入 + 时间点逐个错峰入场。 + +**静默故障(继续加闸门)** + +- **fx 关键帧不含 opacity → 元素永久隐形**:`[data-stage]` 的基础态是 `opacity:0`,靠动画抬回 1;只做 transform/描边的动画(`fx-grow-x/y`、既有的 `fx-draw`)不改 opacity 就会永远不可见。关键帧已补 `opacity:1`,并在 `check-slides.mjs` 新增 **5b 项**静态拦截(fx 类的关键帧未声明 opacity → ✗)。 +- **旧帧目录污染成片**:切 `no-fx` 或改用 still 后,上一轮 motion 的帧目录仍在,`build-video` 会优先用残留帧把过时动画混进成片;capture 现在在产出静态图的路径上主动作废该张帧目录。 +- **柱状图模板高度塌陷**:外层容器写 `align-items:flex-end` 会让列 wrapper 高度塌成内容高,柱子百分比高度变 0(静默不显示);模板改为默认 stretch + 列内 `justify-content:flex-end`,并在文档里写明这个坑。 + +**放映页(可以先放映一遍再渲染)** + +- 新增 `scripts/preview-page.mjs <项目> [--open] [--no-script]` → `preview/play/index.html`:单文件、零依赖、`file://` 双击即看的放映页。**只干「把 HTML 画面放一遍」这一件事**:`←` `→`(触屏左右滑)翻页、`R` 重播入场动画、`X` 动效 / 关动效对照、`P` 口播文案 开 / 关、`O` 总览、`F` 全屏。**刻意不做播放器那套 UI** —— 没有计时器、进度条、逐句跟读高亮:要看时间或节奏就看成片,预览页里跑计时器只会让人盯秒表(实测标签页放着就变成 `204.2s / 6.3s`)。 +- **口播 UI 按数据决定加不加载**:有 clauses + 有 `timings.json` → 列出该张口播文案;有 clauses 但还没对时 → 只列文案并标「(未对时)」(口播还没做也能先看 HTML);没有 clauses 或 `--no-script` → 面板与口播按钮完全不出现,画面占满整宽。 +- **布局随窗口自适应**:顶栏/底栏可换行、话题名过长省略号;窄窗口与手机上口播面板收成底部抽屉并默认收起(画面优先),手机给触摸条按钮 + 左右滑动翻页,总览网格按宽度自动列数,高度用 `100dvh`(免得被手机地址栏切掉)。 +- 为什么不是「直接打开 `slides/*.html`」:延迟 `--t1/--t2/--t3` 与画布尺寸由渲染管线按 `timings.json` 注入,tokens.css 里只有占位值(`--t2:800ms`),直接开原文件会看到「所有动画挤在开头两秒」。放映页生成**快照副本**(`preview/play/.html`),把实测延迟写进 ``(等价于管线注入,优先级最高)并加 `` 让主题与素材照常解析。实测:同一张 t=3.0s,副本第二层 `opacity 0`(未入场),原文件 `opacity 1`(已入场)——与成片一致的是副本。**还没对时**则按 HTML 里实际用到的 stage 等间隔排(0.3/1.3/2.3s),页面顶部黄条如实标注「不是成片时序」。 +- 副本是快照,改完 `slides/` 必须重跑;页面与副本头部都写明真实文件路径与生成时间。 +- 老项目没有 `no-fx` 规则时,只给关动效副本兜底注入该规则并在页面提示(否则切过去是空白,会被误判成「关动效 bug」)。 +- 新增 `init-project.mjs --upgrade-css`:给老项目的 `tokens.css` 幂等补上新版 `no-fx` 规则(只动这一个文件,不碰 `script.json` 等其他内容)。此前对所有旧项目的建议「用新版 init-project 重生成」是错的 —— 那需要 `--force`,会重置 `script.json`。 +- 新增 `scripts/nofx-css.mjs`:`no-fx` 规则的唯一来源(`init-project` 写入 tokens.css、`preview-page` 兜底注入共用一份,避免 CSS 漂移)。 +- `fx-spotlight` 的关键帧补 `opacity: 1` —— 它是本技能文档里列为可用的入场类,但只做 `clip-path`,基础态 `opacity:0` 抬不回来 → 用了就永久隐形(被 `check-slides` 的 5b 项拦住,即「文档说能用、闸门说不能用」)。 +- SKILL.md 的 Gate 4 增加「放映页交用户自己放一遍」;`references/render.md` 增放映页章节(定位、键位、口播三态、为什么必须用副本);`authoring.md` 的动效开关一节写明交付前用 `X` 对照验收。 +- 测试 +27 例(共 99):`preview-page`(注入/合并/no-fx/base 顺序/自包含无外链/**不做计时器**/**不得出现「配音」字样**/响应式与触摸、口播三态、等间隔兜底、越界拒绝/幂等升级)、`tokens-fx`(对模板断言**每个非无限 fx 动画的关键帧都声明 opacity**、no-fx 规则含 opacity 重置、`--upgrade-css` 幂等且不碰其他文件)。 +**文档** + +- 开工对齐"主题与受众"→"**主题与领域**"(受监管题材必问免责声明与数据出处标注);新增 `references/compliance.md`(财经口播红线、数字三要件、涨跌色按受众翻转、免责声明写法、医疗/法律/广告法、Gate 清单);`tokens.css` 增 `--up/--down` 与 `.disclaimer`;`check-slides.mjs` 增整片级财经关键词自查。 +- **层的入场顺序不再固定为"标题先行"**:改为"每张至少两个信息层、分属不同 stage,顺序由强同步原则决定"(大数字先入 / 设问先出 / 图先入都合法),要拦的是"只有一个层"。 +- 补 `roadmap` 版式的 HTML 片段(此前表格里有、代码块缺失),并修 kpi-grid 片段使用未定义类 `.grid g4`(会静默竖排)的问题。 + +## 1.0.1 — 2026-09-17 + +**新增:领域与合规(受监管题材)** + +- `references/compliance.md`(新)— 领域确认问法(题材 + 受众 + 是否受监管)、财经口播三条红线(不给操作建议 / 不预测价格 / 不编数据图形)、数字三要件(**口径 + 币种 + 时点**)、涨跌色按受众翻转(A 股/港股 = 红涨绿跌)、免责声明写法与位置(`.disclaimer`,停留 ≥3s,口播不念也不进字幕)、医疗 / 法律 / 政务 / 广告法要点、Gate 收尾检查清单 +- **开工对齐**第一批问题从"主题与受众"升级为"**主题与领域**":受监管题材(财经投研 / 医疗健康 / 法律 / 政务政策 / 营销效果宣称)必须多问一句"要不要免责声明与数据出处标注"(默认要),并把结论记进 Gate 0 与 `research/notes.md` +- `tokens.css` 新增 `--up` / `--down` 涨跌专用令牌(默认 = `--good` / `--bad`)与 `.disclaimer` / `.disclaimer-box` 原语(纯附加,旧项目与既有主题不受影响) +- `check-slides.mjs` 新增**整片级领域自查**:命中多个财经/投研关键词却没有免责或出处行 → 给出提示(提示而非错误;用户已明确不要免责可忽略) +- `research.md` 增"先确认领域"与财经/投研题材坑(同比≠环比、GAAP≠非GAAP、把旧时点当"目前");`authoring.md` 增免责声明片段、kpi-grid 改用 `var(--up)/var(--down)`、主题速查增财经行;`evals` 增 2 条(财经开局确认、涨跌色按受众) + +## 1.0.0 — 2026-09-17 + +首个公开版本。 + +**流水线**:开工对齐(语言/风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 → HTML 分步入场 → 逐帧渲染 → ASR 反向校验,共 7 阶段 6 个确认闸门。 + +**脚本(11 个,纯 Node,无构建步骤)** + +| 脚本 | 作用 | +|---|---| +| `init-project.mjs` | 生成项目骨架(目录 + tokens.css + slide 模板 + script.json 契约) | +| `plan-timings.mjs` | ffprobe 实测每段 TTS → 时长、每层入场时刻、每句开口时刻 | +| `check-timing.mjs` | 静音检测实测每句真实开口,与估算对比并可校准 | +| `check-theme.mjs` | 全部主题的 WCAG 对比度闸门(正文/次级/字幕/强调色) | +| `check-slides.mjs` | 渲染前静态检查(未定义变量/图片缺失/外链/data-stage 未配动画/硬编码颜色) | +| `capture.mjs` | 终态截图或逐帧步进捕获,字幕默认烧录 | +| `build-video.mjs` | 编码 → 拼接 → 音轨对位 → BGM 混音 → 合成 → 自检 + SRT | +| `asr.mjs` | ASR 转写与脚本比对(直调 REST),支持字级时间戳核对开口时刻 | +| `prep-image.mjs` | 配图检查与受限裁切(裁掉面积上限 20%) | +| `fetch-official-images.mjs` | 从官方站点列取并下载候选素材图 | +| `tools.mjs` | ffmpeg/ffprobe 与 Node 包的多锚点探测 | + +**设计系统**:13 套主题、17 种版式、图片框原语(`.img-frame`)、14 个入场/氛围动画、分步入场与错峰容器。 + +**语言**:中文普通话 / 英语 / 粤语,字数与语速基准、字幕行宽、ASR 语言校验随语言切换。 + +**画布**:1920×1080 横屏与 1080×1920 竖版(字幕几何随画布比例自适应)。 + +**字幕**:单语或双语烧录,另出 `out/subs.srt`。 + +**声音**:BGM 垫底(可选,自动循环与淡入淡出)、ASR 反向校验(音色语种、数字与专名一致性)。 + +**资料搜集**:`references/research.md` —— 来源四级分级、四条硬规则(多源交叉验证/一手优先/标注口径日期/不确定不进脚本)、query 设计、多源矛盾处理、notes 模板、各环境搜索工具差异(含 mmx search 10 条上限)。 + +**两套工具链**:mcode 沙箱用 platform connectors;其他 Agent 环境用 mmx-cli(配 TTS)与本仓 `asr.mjs`(配 ASR)。 + +**已修复的静默故障(均加了闸门)** + +- 入场延迟被 `animation` 简写覆盖 → 元素在 0 秒入场(改用变量槽传递延迟) +- 未定义 CSS 变量 + 透明文字填充 → 文字完全隐形(check-slides 静态拦截) +- 图片加载失败 → 只出 broken 图标而无报错(capture 运行期点名) +- 缺少 clauses → 静默出无字幕片(capture 告警) +- 字幕窗口重叠 → 相邻两句同时可见, 看起来像"重影/错字"(淡出改为在窗口内归零, 并加窗口自检) +- 主题覆盖被忽略、同名选择器只取首个块(check-theme 合并语义修正) diff --git a/plugins/Wzdhehe/html2video-for-mcode/README.md b/plugins/Wzdhehe/html2video-for-mcode/README.md index c57ef76a..5c7d4bd7 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/README.md +++ b/plugins/Wzdhehe/html2video-for-mcode/README.md @@ -112,12 +112,17 @@ node /scripts/build-video.mjs ./my-video --asr authoring rules, asset-sourcing SOP, TTS/timing notes, and rendering internals. `preview-page.mjs` writes a self-contained play page to `preview/play/index.html` (open it from -disk, no server): arrow keys page through the slides, `R` replays the entrance animations, -`P` shows the narration lines for the current slide, `O` is an overview, and `X` switches to a -`no-fx` copy of the same slide — if the picture goes blank, some keyframes never lift the -`opacity: 0` base state. The page loads snapshots with the **measured** stage delays injected -from `timings.json`, so what you see in the browser matches the timing of the final video -(opening `slides/*.html` directly does not — those files carry placeholder delays). +disk, no server) whose only job is **screening the HTML**: arrows (or a swipe) page through the +slides, `R` replays the entrance animations, `O` is an overview, and `X` switches to a `no-fx` +copy — if the picture goes blank, some keyframes never lift the `opacity: 0` base state. There is +deliberately no timer, progress bar, or karaoke-style highlight: for timing, watch the finished +video. The narration panel is data-driven (`P` toggles it): it lists the slide's script lines when +they exist, is skipped entirely when they do not, and `--no-script` hides it outright — so a deck +whose voiceover is not written yet still previews fine. The layout adapts down to phones (panel +becomes a collapsible bottom drawer, touch buttons appear). The page loads snapshots with the +**measured** stage delays injected from `timings.json`, so what you see in the browser matches the +timing of the final video (opening `slides/*.html` directly does not — those files carry +placeholder delays; without `timings.json` the page spreads the stages evenly and says so). ## Supported platforms diff --git a/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md b/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md index 611e58e1..82f86950 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md +++ b/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md @@ -96,10 +96,14 @@ node /scripts/build-video.mjs ./my-video --asr 工作流全文(7 阶段、6 个确认闸门)在 `SKILL.md`;`references/` 放着编写规范、配图 SOP、TTS/对时说明与渲染内幕。 -`preview-page.mjs` 生成一个自包含的**放映页** `preview/play/index.html`(双击即看,不需要起服务): -`←` `→` 翻页、`R` 重播入场动画、`P` 看当前张的口播、`O` 总览、`X` 切到同张的 `no-fx` 副本 —— -切过去画面**变空**就说明有关键帧没把 `opacity:0` 的基础态抬回来。放映页的副本按 `timings.json` -**注入实测延迟**,所以浏览器里看到的时序就是成片时序(直接打开 `slides/*.html` 不是:那些文件里是占位延迟)。 +`preview-page.mjs` 生成一个自包含的**放映页** `preview/play/index.html`(双击即看,不需要起服务), +它只干一件事:**把 HTML 画面放一遍** —— `←` `→`(或触屏左右滑)翻页、`R` 重播入场动画、`O` 总览、 +`X` 切到同张的 `no-fx` 副本(切过去画面**变空**就说明有关键帧没把 `opacity:0` 的基础态抬回来)。 +**刻意不做计时器、进度条、跟读高亮** —— 要看时间或节奏就直接看成片。口播文案面板按数据自动决定: +有文案就列出(`P` 可开/关),没有就整个不出,`--no-script` 也能强制不要 —— 口播还没做时照样能先看画面。 +布局一路适配到手机(面板收成可收起的底部抽屉,出现触摸按钮)。副本按 `timings.json` **注入实测延迟**, +所以浏览器里看到的时序就是成片时序(直接打开 `slides/*.html` 不是:那些文件里是占位延迟;没有 timings 时 +页面按等间隔预览并在顶部如实标注)。 ## 支持平台 diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md index 79e6eb4a..1bff3f91 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md @@ -37,7 +37,7 @@ description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻 | `scripts/check-theme.mjs <项目目录>` | 校验全部主题的 WCAG 对比度(正文/次级/字幕/accent-ink), 不达标退出码 1;新增主题必须过闸 | | `scripts/prep-image.mjs --check <图...>` / `--crop [--ratio 16:9] [--anchor ...]` | 配图 SOP 的执行辅助:查尺寸与裁切风险;按锚点裁切(强制"裁掉 ≤20%、不放大补边") | | `scripts/capture.mjs <项目目录> [--mode still\|motion] [--no-subs]` | Playwright 截图。still=终态单帧;motion=逐帧步进入场动画。**字幕默认烧录**(内容取自 clauses),`--no-subs` 关闭 | -| `scripts/preview-page.mjs <项目目录> [--open]` | 生成**放映页** `preview/play/index.html`(单文件、零依赖、file:// 双击即看):←→ 翻页、R 重播动画、X 关动效对照、P 提词面板、O 总览、F 全屏。快照按 `timings.json` **注入实测延迟**, 所以浏览器里的动画时序 = 成片时序 | +| `scripts/preview-page.mjs <项目目录> [--open] [--no-script]` | 生成**放映页** `preview/play/index.html`(单文件、零依赖、file:// 双击即看):←→ 或触屏左右滑翻页、R 重播动画、X 动效/关动效对照、P 口播文案开/关、O 总览、F 全屏。**只做"放画面"这件事**:没有计时器/进度条/跟读高亮(要看时间就看成片)。快照按 `timings.json` **注入实测延迟**,所以浏览器里的动画时序 = 成片时序;还没对时则按等间隔预览并如实标注。口播面板按数据自动决定加不加载,窄窗口/手机上收成底部抽屉且默认收起 | | `scripts/build-video.mjs <项目目录> [--asr]` | 编码每张 → 拼接 → 音轨对位 → 合成 → 自检 + 出 `out/subs.srt`;`--asr` **按句**切分音频 + 校验清单 | 环境要求:Node 18+(脚本用 fileURLToPath 保兼容, 不依赖 Node 20.11 的 import.meta.dirname)、`npm i playwright && npx playwright install chromium`(项目目录内)。ffmpeg/ffprobe 自动探测:PATH → node_modules(ffmpeg-static/ffprobe-static)→ 常见安装位置,找不到会给逐条诊断而不是莫名报错。 diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json index 69aeb166..1f39c7f7 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json @@ -169,7 +169,7 @@ "id": 25, "name": "preview-before-render", "prompt": "片子渲染要等好几分钟, 我想先自己把八张连着放一遍看看动效节奏对不对, 别每次都等出片。", - "expected_output": "跑 node <技能>/scripts/preview-page.mjs <项目目录> --open → 生成自包含放映页 preview/play/index.html(file:// 双击即看, 零依赖、不需起服务): ←→ 翻页、R 重播当前张入场动画、P 提词面板(该张 clauses 按播放时间高亮, 并标出'成片 X s · 最后一层入场 ≈ Y s', 收尾静止不足 1.2s 标红)、O 总览、F 全屏、X 切'关动效'副本对照。⚠ 关键: 不要直接双击 slides/*.html —— 动画延迟 --t1/--t2/--t3 是渲染管线按 timings.json 注入的, 原文件里只有占位值(--t2:800ms), 看到的是'所有动画挤在开头两秒'的假象; 放映页的副本把实测延迟写进 并加 , 浏览器里的时序才等于成片时序(实测同一张 t=3.0s: 副本第二层 opacity 0 未入场, 原文件已入场)。副本是快照, 改完 slides 必须重跑; 缺 build/timings.json 时页面会明确警告时序不是成片的。X 键切过去若画面变空, 说明有关键帧没把 opacity:0 基础态抬回来; 老项目(没有 no-fx 规则)先跑 init-project.mjs <项目> --upgrade-css 补规则(幂等, 不碰 script.json)。", + "expected_output": "跑 node <技能>/scripts/preview-page.mjs <项目目录> --open → 生成自包含放映页 preview/play/index.html(file:// 双击即看, 零依赖、不需起服务)。页面只做「把 HTML 画面放一遍」: ←→ 或触屏左右滑翻页、R 重播当前张入场动画、X 动效/关动效对照、P 口播文案开/关、O 总览、F 全屏; 刻意不做计时器/进度条/逐句跟读高亮 —— 要看时间或节奏就直接看成片, 预览页里跑计时器只会让人盯秒表(实测标签页放着就变成 204.2s / 6.3s)。口播 UI 按数据自动决定: 有 clauses + 有 timings.json → 列出该张口播文案; 有 clauses 没对时 → 只列文案并标「(未对时)」; 没有 clauses 或 --no-script → 面板与按钮完全不出现, 画面占满整宽。布局自适应: 窄窗口/手机上口播面板收成底部抽屉且默认收起, 手机给触摸条按钮+左右滑动翻页, 总览网格自动列数, 高度用 100dvh。⚠ 关键: 不要直接双击 slides/*.html —— 动画延迟 --t1/--t2/--t3 是管线按 timings.json 注入的, 原文件里只有占位值(--t2:800ms), 看到的是「所有动画挤在开头两秒」的假象; 放映页的副本把实测延迟写进 并加 , 浏览器里的时序才等于成片时序(实测同一张 t=3.0s: 副本第二层 opacity 0 未入场, 原文件已入场)。还没对时则按 HTML 里用到的 stage 等间隔(0.3/1.3/2.3s)预览, 页面顶部黄条如实标注。副本是快照, 改完 slides 必须重跑。X 切过去若画面变空, 说明有关键帧没把 opacity:0 基础态抬回来; 老项目(没有 no-fx 规则)先跑 init-project.mjs <项目> --upgrade-css 补规则(幂等, 不碰 script.json)。", "files": [] } ] diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/render.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/render.md index fc9853d8..10debb41 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/render.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/render.md @@ -60,20 +60,34 @@ 关于 stage 时序的归因:先用 `check-timing.mjs` 拿实测数据,再抽成片帧确认;如果"一部分元素 0 秒就入场、一部分正常",那是 tokens.css 的延迟被 `.fx-*` 简写覆盖(见 authoring.md 的实现原理),不是估算偏差。 -## 放映页(看动画的最短路径,不用等编码) +## 放映页(把 HTML 画面放一遍,不用等编码) -`node scripts/preview-page.mjs <项目> [--open]` → `preview/play/index.html`,单文件、零依赖、file:// 双击即看: +`node scripts/preview-page.mjs <项目> [--open] [--no-script]` → `preview/play/index.html`,单文件、零依赖、file:// 双击即看。 -| 键 | 作用 | +**定位:这个页面只干"看画面"这一件事** —— 翻页、重播、动效对照、总览;口播文案是锦上添花。**刻意没有播放器那套 UI**:计时器(走秒的 `X s / Y s`)、进度条、逐句跟读高亮都不做 —— 要看时间/节奏就直接看成片,预览页里跑计时器只会让人盯着秒表看(实测标签页开着一会儿就变成 `204.2s / 6.3s`,毫无意义)。 + +| 操作 | 作用 | |---|---| -| `←` `→` / 空格 | 翻页(只加载当前这张,不预跑全部动画) | +| `←` `→` / 空格 / 触屏左右滑 | 翻页(只加载当前这张,不预跑全部动画) | | `R` | 重播当前张(换 iframe src = 重新加载 = 动画从 0 开始) | -| `X` | **动效 / 关动效对照**:切到 `preview/play/.nofx.html`(根元素带 `no-fx`)。画面变空 = 有关键帧没把 `opacity:0` 抬回来;两版一致 = 动效设定干净 | -| `P` | 提词面板(该张 clauses,按播放时间高亮当前句);面板下方给"成片 X s · 最后一层入场 ≈ Y s",收尾不足 1.2s 会标红 | +| `X`(或点顶栏"动效") | **动效 / 关动效 对照**:切到 `preview/play/.nofx.html`(根元素带 `no-fx`)。画面变空 = 有关键帧没把 `opacity:0` 抬回来;两版一致 = 动效设定干净 | +| `P`(或触屏条"口播") | 口播文案面板 开 / 关 | | `O` / `F` | 总览(preview/*.png 缩略图,点击跳转)/ 全屏 | +**口播 UI 按数据决定加不加载**(不靠用户记得关): + +| 情形 | 页面表现 | +|---|---| +| 有 clauses + 有 `timings.json` | 右侧(窄屏为底部抽屉)列出该张口播文案 | +| 有 clauses、还没对时 | 列出文案,标题标"(未对时)" —— 口播还没做时也能先看 HTML | +| 没有 clauses,或 `--no-script` | 完全不出面板与口播按钮,画面占满整宽 | + +**布局随窗口自适应**:顶栏/底栏可换行(话题名过长省略号截断),窄窗口与手机上口播面板收成底部抽屉并**默认收起**(画面优先),手机用触摸条按钮 + 左右滑动翻页,总览网格按宽度自动列数。用 `100dvh` 而不是 `100vh`,免得手机被地址栏切掉一截。 + **为什么要生成副本,而不是直接打开 `slides/*.html`**:动画延迟(`--t1/--t2/--t3`)和画布尺寸由渲染管线按 `timings.json` 注入(capture 走 `addInitScript`),HTML 与 tokens.css 里只有占位默认值。直接双击原文件,`--t2` 是 800ms 而不是实测的 4.9s —— 所有入场动画挤在开头两秒,看到的节奏与成片完全不同(实测:同一张 t=3.0s,副本里第二层还是 opacity 0,原文件里已经可见)。副本把这些值写进 ``(等价于管线注入,优先级最高),并加 `` 让 tokens.css 与 `../assets/*` 照常解析。 +**还没有 `timings.json` 时**(口播还没做):副本按 HTML 里实际用到的 stage 等间隔排(0.3 / 1.3 / 2.3s),页面顶部黄条如实标注"不是成片时序"。这样至少能看清入场顺序 —— 占位值会把动画全挤在 2 秒内,那才是真看不懂。 + 副本是**快照**:改完 `slides/` 必须重跑 `preview-page.mjs`,否则放映页还是旧画面(副本头部注释会写明真实文件路径)。放映页不烧字幕、不带声音 —— 字幕验收仍以成片为准。 老项目(`--upgrade-css` 之前生成的 tokens.css)没有 `no-fx` 规则,脚本会只给关动效副本兜底注入该规则,并在页面上如实说明。 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 index fef1a118..95e630e2 100644 --- 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 @@ -1,13 +1,19 @@ #!/usr/bin/env node // html2video-for-mcode · 放映页: preview/play/index.html + 逐张"真实时序"快照。 -// 用法: node preview-page.mjs <项目目录> [--open] +// 用法: node preview-page.mjs <项目目录> [--open] [--no-script] +// +// 定位: **用浏览器把 HTML 画面放一遍**。只做四件事 —— 翻页、重播动画、动效/关动效对照、总览; +// 口播文案是锦上添花(可关), 不做计时器/进度条/播放器那套 UI(要看时间就直接看成片)。 // // 为什么不是"直接双击 slides/*.html": 动画延迟(--t1/--t2/--t3)与画布尺寸是渲染管线按 // timings.json 注入的(tokens.css 里只有占位默认值 --t2:800ms), 直接打开看到的是"所有动画 // 挤在 2 秒内"的假象。本脚本把注入值原样写进副本的 , 于是浏览器里的播放时序 // = 成片时序, 且副本带 指回 slides/, 主题与素材照常解析。 -// 另出一份加了 no-fx 的副本, 放映页按 X 键即可对照"关掉动效后画面是否还完整" —— 这是 +// 另出一份加了 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'; @@ -32,6 +38,21 @@ export function stageVars(stages = {}, { w, h } = {}) { 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; +} + function firstTag(html, name) { const m = html.match(new RegExp(`<${name}\\b[^>]*>`, 'i')); return m ? { tag: m[0], index: m.index } : null; @@ -90,130 +111,166 @@ export function injectStyle(html, css) { // ─────────────────────────── 放映页 ─────────────────────────── -export function buildPlayPage({ topic = '', lang = 'zh', slides = [], hasTimings = true, noFxNote = '', generatedAt = '' } = {}) { +export function buildPlayPage({ + topic = '', lang = 'zh', slides = [], noFxNote = '', generatedAt = '', + narration = true, // 是否加载口播文案 UI(没有 clauses 或 --no-script 时为 false) + timing = false, // 是否有对时数据(只影响标题上的"(未对时)"标注) + fallbackNote = '', // "还没对时/等间隔预览"的如实说明(与口播 UI 无关, 画面上也要说清) +} = {}) { const model = slides.map(s => ({ id: s.id, name: s.name, title: s.title ?? '', src: s.copy, nofx: s.copyNofx, - dur: Number.isFinite(s.duration) ? s.duration : null, - lastStage: Number.isFinite(s.lastStage) ? s.lastStage : null, - clauses: (s.clauses ?? []).map(c => ({ stage: c.stage ?? null, start: c.start ?? null, text: c.text ?? '', text2: c.text2 ?? '' })), + clauses: (s.clauses ?? []).map(c => ({ stage: c.stage ?? null, text: c.text ?? '', text2: c.text2 ?? '' })), })); const json = JSON.stringify(model).replace(/⚠ 没找到 build/timings.json —— 副本用的是 tokens.css 占位延迟(动画会挤在开头)。先跑 plan-timings.mjs 再重跑本脚本。

'; + const bodyCls = narration ? '' : 'narr-off'; const nofxWarn = noFxNote ? `

⚠ ${esc(noFxNote)}

` : ''; + const notice = fallbackNote ? `
${esc(fallbackNote)}
` : ''; return ` - + + 放映页 · ${esc(topic) || 'html2video'} · html2video-for-mcode - -
- ${esc(topic) || '放映页'} - 1 / ${slides.length} - 动效 - - -
-
+ +
+
+ ${esc(topic) || '放映页'} + 1 / ${slides.length} + + +
+ ${notice} +
-
- ` : ''}
- 翻页 - R 重播动画 - X 关配音画对比 - P 提词面板 - O 总览 - F 全屏 - 快照生成于 ${esc(generatedAt)} · 画面与成片同源, 无字幕与声音 + + 翻页 + R 重播 + X 动效 / 关动效 对照 + ${narration ? 'P 口播文案 开 / 关' : ''} + O 总览 + F 全屏 + + 快照生成于 ${esc(generatedAt)} · 画面与成片同源(无声、无字幕) + + + + + ${narration ? '' : ''} + + +

总览 · 点任意一张跳转(缩略图来自 preview/*.png)

@@ -307,6 +370,7 @@ 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'); @@ -317,18 +381,13 @@ function main() { const timingsPath = path.join(dir, 'build', 'timings.json'); const hasTimings = fs.existsSync(timingsPath); const timings = hasTimings ? JSON.parse(fs.readFileSync(timingsPath, 'utf8')) : null; - if (timings) { - const byId = new Map((timings.slides ?? []).map(t => [t.id, t])); - const missing = script.slides.filter(s => !byId.has(s.id)).map(s => s.id); - if (missing.length) console.warn(`⚠ timings.json 里没有 ${missing.join(', ')} —— 这几张用占位延迟`); - } const slidesDir = path.join(dir, 'slides'); const outDir = path.join(dir, 'preview', 'play'); fs.mkdirSync(outDir, { recursive: true }); // 老项目(第 8 轮之前生成的 tokens.css)没有 no-fx 规则: 只给"关动效副本"兜底注入, - // 并在页面上如实说明 —— 否则 X 键对照会把画面锁在入场前的透明态, 看着像整片空白。 + // 并在页面上如实说明 —— 否则对照会把画面锁在入场前的透明态, 看着像整片空白。 const tokensPath = path.join(slidesDir, 'tokens.css'); const tokensCss = fs.existsSync(tokensPath) ? fs.readFileSync(tokensPath, 'utf8') : ''; const noFxStale = !hasNofxRules(tokensCss); @@ -337,6 +396,7 @@ function main() { : ''; const rows = []; + let fallbackUsed = false; for (const s of script.slides) { const sid = safeId(s.id); // 安全性照旧收监(越界仍退出 1); 但"这张还没写"按 capture 的惯例跳过并警告 —— @@ -347,7 +407,13 @@ function main() { const html = fs.readFileSync(srcPath, 'utf8'); const base = path.basename(srcPath); const t = timings?.slides?.find(x => x.id === s.id); - const stages = t?.stages ?? {}; + + // 有对时 → 用实测延迟; 没有(口播还没做) → 按 HTML 里实际用到的 stage 等间隔错开, + // 至少能看清入场顺序(占位值会把动画全挤在 2 秒内, 那才是看不懂的)。 + let stages = t?.stages ?? {}; + let fallback = false; + if (!Object.keys(stages).length) { stages = fallbackStages(html); fallback = Object.keys(stages).length > 0; } + if (fallback) fallbackUsed = true; const decl = stageVars(stages, { w: script.width ?? 1920, h: script.height ?? 1080 }); let copy = addBaseHref(injectHtmlVars(html, decl), '../../slides/'); @@ -371,18 +437,33 @@ function main() { const p2 = n => String(n).padStart(2, '0'); const now = new Date(); if (!rows.length) { console.error('✗ 一张可放映的 slide 都没有(检查 script.json 的 html 字段与 slides/ 目录)'); process.exit(1); } + + // 口播 UI 三种状态: 有文案+有对时 → 标注正常;有文案没对时 → 标注"未对时";没文案或 --no-script → 不出 + const anyClauses = rows.some(r => r.clauses.length > 0); + const narration = anyClauses && !NO_SCRIPT; + const timing = narration && !!timings && rows.some(r => r.clauses.some(c => Number.isFinite(c.start))); + // 提示与口播无关: 动画时序不是成片的, 这件事看画面的人也必须知道 + const fallbackNote = [ + (!hasTimings || fallbackUsed) ? '动画按等间隔 0.3/1.3/2.3s 预览入场顺序, 不是成片时序(缺 build/timings.json)' : '', + narration && !timing ? '口播还没对时: 只列文案' : '', + ].filter(Boolean).join(' · '); + const page = buildPlayPage({ - topic: script.topic ?? '', lang: script.lang ?? 'zh', slides: rows, hasTimings, noFxNote, + topic: script.topic ?? '', lang: script.lang ?? 'zh', slides: rows, noFxNote, + narration, timing, fallbackNote, generatedAt: `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())} ${p2(now.getHours())}:${p2(now.getMinutes())}`, }); fs.writeFileSync(path.join(outDir, 'index.html'), page); + const modes = !narration ? (NO_SCRIPT ? '关(--no-script)' : '关(本片没有 clauses)') : timing ? '口播文案(有对时)' : '口播文案(未对时)'; console.log(`✓ 放映页 → ${path.relative(process.cwd(), path.join(outDir, 'index.html'))}`); console.log(` 快照 ${rows.length} 张 ×2(动效/关动效) → ${path.relative(process.cwd(), outDir)}/${rows[0]?.name ?? '.html'}`); - if (!hasTimings) console.warn('⚠ 缺 build/timings.json: 快照用的是 tokens.css 占位延迟, 时序不是成片时序 —— 先跑 plan-timings.mjs'); + console.log(` 口播 UI: ${modes}`); + if (!hasTimings) console.warn('⚠ 缺 build/timings.json: 副本按等间隔预览入场顺序, 不是成片时序 —— 先跑 plan-timings.mjs'); + else if (fallbackUsed) console.warn('⚠ 部分张在 timings.json 里没有 stage 数据: 那几张按等间隔预览'); else console.log(` 已注入实测 stage 延迟: ${rows.map(r => `${r.id}(${r.lastStage != null ? '末层 ' + r.lastStage.toFixed(1) + 's' : '无 stage'})`).join(' ')}`); if (noFxStale) console.warn(`⚠ slides/tokens.css ${tokensCss ? '缺 no-fx 规则(旧模板生成的)' : '不存在'} —— 关动效副本已兜底注入; 想让它对成片也生效, 用新版 init-project 重生成 tokens.css`); - console.log(' 快捷键: ← → 翻页 · R 重播 · X 关动效对比 · P 提词面板 · O 总览 · F 全屏'); + console.log(` 操作: ← → 翻页 · R 重播 · X 动效/关动效对照${narration ? ' · P 口播文案开/关' : ''} · O 总览 · F 全屏;触屏左右滑翻页`); if (OPEN) { const target = path.join(outDir, 'index.html'); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/preview-page.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/preview-page.test.mjs index fec938db..2c7feee3 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/preview-page.test.mjs +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/preview-page.test.mjs @@ -7,7 +7,7 @@ import path from 'node:path'; import { runSkill, mkproj, tmpdir, SCRIPTS } from './helpers.mjs'; const mod = await import('file://' + path.join(SCRIPTS, 'preview-page.mjs').replace(/\\/g, '/')); -const { stageVars, injectHtmlVars, addNoFx, addBaseHref, injectStyle, buildPlayPage } = mod; +const { stageVars, injectHtmlVars, addNoFx, addBaseHref, injectStyle, buildPlayPage, stagesFromHtml, fallbackStages } = mod; const { hasNofxRules } = await import('file://' + path.join(SCRIPTS, 'nofx-css.mjs').replace(/\\/g, '/')); const SLIDE = ` @@ -16,7 +16,7 @@ const SLIDE = ` -

文案

+

文案

展开

`; @@ -60,8 +60,8 @@ describe('预览放映页 · 纯函数', () => { test('buildPlayPage: 自包含(无外链/无外站脚本)且含全部张次', () => { const page = buildPlayPage({ topic: 'T', slides: [ - { id: '01', name: '01.html', copy: '01.html', copyNofx: '01.nofx.html', duration: 6.3, lastStage: 3.2, clauses: [{ stage: 1, start: 0, text: '甲' }] }, - { id: '02', name: '02.html', copy: '02.html', copyNofx: '02.nofx.html', duration: 5, lastStage: 2, clauses: [] }, + { id: '01', name: '01.html', copy: '01.html', copyNofx: '01.nofx.html', clauses: [{ stage: 1, text: '甲' }] }, + { id: '02', name: '02.html', copy: '02.html', copyNofx: '02.nofx.html', clauses: [] }, ], noFxNote: '注意 X', }); assert.ok(!/https?:/.test(page), '不得引用任何外部 URL(离线双击可用)'); @@ -70,6 +70,45 @@ describe('预览放映页 · 纯函数', () => { assert.ok(page.includes('01.nofx.html'), '关动效副本要在模型里'); assert.ok(page.includes('注意 X'), 'tokens.css 落后时应把提示带给用户'); }); + + test('buildPlayPage: 不做播放器那套 UI(没有计时器/进度条/rAF 循环)', () => { + const page = buildPlayPage({ topic: 'T', slides: [{ id: '01', name: 'a.html', copy: 'a.html', copyNofx: 'a.nofx.html', clauses: [] }] }); + assert.ok(!page.includes('id="clock"'), '不要走秒的计时器'); + assert.ok(!page.includes('id="bar"'), '不要时间轴进度条'); + assert.ok(!/requestAnimationFrame/.test(page), '不要常驻 rAF 循环'); + assert.ok(!/配音/.test(page), '页面不得出现"配音"字样 —— 此前把"关动效对照"写成"关配音画对比", 让人以为在管音频'); + }); + + test('buildPlayPage: 随窗口自适应(视口栏 / dvh / 窄屏媒体查询 / 触屏滑动)', () => { + const page = buildPlayPage({ topic: 'T', slides: [{ id: '01', name: 'a.html', copy: 'a.html', copyNofx: 'a.nofx.html', clauses: [] }] }); + assert.match(page, /name="viewport"[^>]*width=device-width/, '手机需要视口声明'); + assert.ok(page.includes('100dvh'), '用动态视口高度, 免得手机上被地址栏切掉'); + assert.match(page, /@media \(max-width:860px\)/, '窄窗口要有独立布局(面板收到底部)'); + assert.ok(page.includes('(pointer:coarse)'), '触屏要有触摸条而不是键盘提示'); + assert.match(page, /class="kbd-hints"/, '键盘提示必须是类(内联样式会盖住媒体查询, 手机上就藏不掉)'); + assert.ok(!page.includes('style="display:flex'), '键盘提示不得用内联 display'); + assert.ok(page.includes('touchstart') && page.includes('touchend'), '触屏要能左右滑翻页'); + }); + + test('buildPlayPage: 口播 UI 三态(有/未对时/不出)', () => { + const withNarr = buildPlayPage({ topic: 'T', timing: true, slides: [{ id: '01', name: 'a.html', copy: 'a.html', copyNofx: 'a.nofx.html', clauses: [{ stage: 1, text: '甲' }] }] }); + assert.ok(withNarr.includes('id="panel"') && withNarr.includes('本张口播文案')); + assert.ok(!withNarr.includes('(未对时)')); + const noTiming = buildPlayPage({ topic: 'T', timing: false, slides: [{ id: '01', name: 'a.html', copy: 'a.html', copyNofx: 'a.nofx.html', clauses: [{ stage: 1, text: '甲' }] }] }); + assert.ok(noTiming.includes('本张口播文案(未对时)'), '没对时要说清楚'); + const off = buildPlayPage({ topic: 'T', narration: false, slides: [{ id: '01', name: 'a.html', copy: 'a.html', copyNofx: 'a.nofx.html', clauses: [] }] }); + assert.ok(!off.includes('id="panel"'), '没有口播内容就不该有面板'); + assert.match(off, //); + assert.ok(!off.includes('data-act="narr"'), '触摸条也不该留口播按钮'); + }); + + test('stagesFromHtml / fallbackStages: 没对时时按 HTML 里用到的 stage 等间隔排', () => { + assert.deepEqual(stagesFromHtml(SLIDE), [1, 2]); + assert.deepEqual(stagesFromHtml('

'), [2]); + assert.deepEqual(fallbackStages(SLIDE), { 1: 0.3, 2: 1.3 }); + assert.deepEqual(fallbackStages('

'), { 3: 0.3 }); + assert.deepEqual(fallbackStages('

无 stage

'), {}); + }); }); describe('预览放映页 · 落到磁盘', () => { @@ -129,7 +168,30 @@ describe('预览放映页 · 落到磁盘', () => { const r = runSkill('preview-page.mjs', [proj]); assert.equal(r.status, 0, r.stderr); assert.ok(r.stderr.includes('timings.json'), '应提示先跑 plan-timings'); - assert.ok(fs.readFileSync(path.join(proj, 'preview', 'play', 'index.html'), 'utf8').includes('占位延迟')); + const page = fs.readFileSync(path.join(proj, 'preview', 'play', 'index.html'), 'utf8'); + assert.ok(page.includes('等间隔'), '页面上要说清动画是按等间隔预览的'); + assert.ok(page.includes('未对时'), '口播标题要标未对时'); + }); + + test('--no-script: 不加载口播 UI, 但时序提示照留(它讲的是画面)', () => { + const proj = build(tmpdir()); + const r = runSkill('preview-page.mjs', [proj, '--no-script']); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.stdout.includes('关(--no-script)')); + const page = fs.readFileSync(path.join(proj, 'preview', 'play', 'index.html'), 'utf8'); + assert.ok(!page.includes('id="panel"')); + assert.ok(!page.includes('data-act="narr"')); + assert.ok(page.includes('等间隔'), '口播关了, 动画时序的提示仍要有'); + }); + + test('script.json 没有 clauses → 口播 UI 自动不加载', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', layout: 'statement', html: '01-title.html', audio: '01.mp3', clauses: [] }] }); + fs.writeFileSync(path.join(proj, 'slides', '01-title.html'), SLIDE); + const r = runSkill('preview-page.mjs', [proj]); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.stdout.includes('本片没有 clauses')); + assert.ok(!fs.readFileSync(path.join(proj, 'preview', 'play', 'index.html'), 'utf8').includes('id="panel"')); }); test('slides[].html 越界 → 退出 1, 项目外不落任何文件', () => { From 7ee2cb46319ab7e618d71ac7c5ac8a46abf2e4d6 Mon Sep 17 00:00:00 2001 From: Hahaha Date: Fri, 18 Sep 2026 14:27:52 +0800 Subject: [PATCH 14/24] =?UTF-8?q?docs:=20=E6=96=87=E6=A1=A3=E4=B8=8E?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=AF=B9=E9=BD=90(11=20=E8=84=9A=E6=9C=AC+3?= =?UTF-8?q?=20=E6=A8=A1=E5=9D=97=E3=80=8199=20=E4=BE=8B=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E3=80=81=E6=94=BE=E5=AE=BD=E5=90=8E=E7=9A=84=E5=B1=82=E8=A7=84?= =?UTF-8?q?=E5=88=99=E3=80=81Gate=204=20=E5=8F=A3=E5=BE=84=E3=80=81?= =?UTF-8?q?=E5=90=88=E8=A7=84/=E5=9B=BE=E8=A1=A8/no-fx=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Wzdhehe/html2video-for-mcode/CHANGELOG.md | 194 +++++++++--------- .../Wzdhehe/html2video-for-mcode/README.md | 34 ++- .../html2video-for-mcode/README.zh-CN.md | 17 +- .../skills/html2video-for-mcode/SKILL.md | 15 +- .../html2video-for-mcode/references/render.md | 2 + 5 files changed, 145 insertions(+), 117 deletions(-) diff --git a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md index b7785ebd..c03dc65f 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md +++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md @@ -1,97 +1,97 @@ -# Changelog - -## 1.1.0 — 2026-09-18 - -**安全边界(响应 PR #41 评审的五条 Request changes)** - -- **路径收监**:`script.json` 是 agent 可编辑文件,其 `slides[].id/html/audio` 与 `bgm.file` 此前被直接拼进文件路径。现于 `tools.mjs` 新增 `safeId / safeRel / inside / validateScriptPaths / validateTimingsIds`,六个消费脚本(capture / build-video / plan-timings / check-timing / check-slides / asr)在 `JSON.parse` 后立即校验:id 走白名单 `^[A-Za-z0-9_-]{1,64}$`,路径拒绝绝对路径、resolve 后必须落在项目目录内、并对符号链接做 realpath 复核。此前 `id="../../victim"` 可触发**项目目录外的递归删除**(`capture.mjs` 的 `rmSync(build/frames/, {recursive:true})`)。 -- **覆盖拒绝**:`init-project.mjs` 对已存在且非空的目录直接拒绝(列出将被覆写的 5 个生成文件),需显式 `--force`;`fetch-official-images.mjs` 的 `--out-dir` 默认收监在工作目录内、已存在文件不覆盖;`prep-image.mjs --crop` 输出已存在需 `--force`。顺带修 `--topic` 未转义即插入模板 HTML 的问题。 -- **ASR 端点白名单**:API Key 只发官方域(`api.minimaxi.com` / `api.minimax.io`);`--base-url` / `MINIMAX_BASE_URL` 指向其他地址一律硬拒绝,自建网关需显式 `--allow-any-endpoint`(打印醒目警告)。此前被偷换的环境变量可把 Key 发往任意端点。 -- **抓图 SSRF 收紧**:新增 `scripts/url-policy.mjs`(纯函数)。拦 loopback / 链路本地(含云元数据 169.254.169.254)/ 私网 / CGNAT / 无点主机名;只允许 http(s),`file://` 需显式 `--allow-file`;禁带 userinfo 的 URL;`maxRedirects:0` 手动跟重定向且**逐跳**复用同一策略;响应大小上限默认 30MB(`--max-mb`);落盘文件名清洗补 Windows 保留名。 -- **可执行测试**:新增 `tests/`(node:test,零依赖,从仓库根 `node --test` 自动发现 → 被 `npm run check` 真实执行)—— `safe-paths`(恶意 id/路径 + canary 完好性 + symlink 逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(白名单拒绝 + 本地假服务器收到 Bearer 假 Key 的正向证据)、`fetch-policy`(host/URL/重定向/文件名 44 例)、`render-smoke`(init → 静音音频 → plan-timings → check-slides → capture → build-video 全链出片)。共 72 例,本地全绿。另附 scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml`(path-filter 只在本插件变更时跑,装 ffmpeg + playwright 后真实执行,含渲染冒烟)。 - -**图表与动效** - -- `references/authoring.md` 新增 **纯 CSS/SVG 图表**章节:横向条形 / 柱状 / 环形(`conic-gradient`)/ 折线(inline SVG)/ 进度条五种画法与选型速查;三条底线 = 禁外链图表库(离线取不到且 canvas 动画逐帧 seek 不到)、数值必须来自已核实口径、禁止 AI 生图当图表。 -- 新增 `fx-grow-x` / `fx-grow-y`(条形从左、柱状从底生长),折线 `fx-draw` 描边画入;**动效可一键关**:根元素或任意容器加 `no-fx`,关掉后 motion 捕获自动退化为静态帧,时长与音画同步不变(实测动画终态 vs `no-fx` 帧 PSNR 51.7dB,画面一致)。 -- `timeline` 版式改为整轴淡入 + 时间点逐个错峰入场。 - -**静默故障(继续加闸门)** - -- **fx 关键帧不含 opacity → 元素永久隐形**:`[data-stage]` 的基础态是 `opacity:0`,靠动画抬回 1;只做 transform/描边的动画(`fx-grow-x/y`、既有的 `fx-draw`)不改 opacity 就会永远不可见。关键帧已补 `opacity:1`,并在 `check-slides.mjs` 新增 **5b 项**静态拦截(fx 类的关键帧未声明 opacity → ✗)。 -- **旧帧目录污染成片**:切 `no-fx` 或改用 still 后,上一轮 motion 的帧目录仍在,`build-video` 会优先用残留帧把过时动画混进成片;capture 现在在产出静态图的路径上主动作废该张帧目录。 -- **柱状图模板高度塌陷**:外层容器写 `align-items:flex-end` 会让列 wrapper 高度塌成内容高,柱子百分比高度变 0(静默不显示);模板改为默认 stretch + 列内 `justify-content:flex-end`,并在文档里写明这个坑。 - -**放映页(可以先放映一遍再渲染)** - -- 新增 `scripts/preview-page.mjs <项目> [--open] [--no-script]` → `preview/play/index.html`:单文件、零依赖、`file://` 双击即看的放映页。**只干「把 HTML 画面放一遍」这一件事**:`←` `→`(触屏左右滑)翻页、`R` 重播入场动画、`X` 动效 / 关动效对照、`P` 口播文案 开 / 关、`O` 总览、`F` 全屏。**刻意不做播放器那套 UI** —— 没有计时器、进度条、逐句跟读高亮:要看时间或节奏就看成片,预览页里跑计时器只会让人盯秒表(实测标签页放着就变成 `204.2s / 6.3s`)。 -- **口播 UI 按数据决定加不加载**:有 clauses + 有 `timings.json` → 列出该张口播文案;有 clauses 但还没对时 → 只列文案并标「(未对时)」(口播还没做也能先看 HTML);没有 clauses 或 `--no-script` → 面板与口播按钮完全不出现,画面占满整宽。 -- **布局随窗口自适应**:顶栏/底栏可换行、话题名过长省略号;窄窗口与手机上口播面板收成底部抽屉并默认收起(画面优先),手机给触摸条按钮 + 左右滑动翻页,总览网格按宽度自动列数,高度用 `100dvh`(免得被手机地址栏切掉)。 -- 为什么不是「直接打开 `slides/*.html`」:延迟 `--t1/--t2/--t3` 与画布尺寸由渲染管线按 `timings.json` 注入,tokens.css 里只有占位值(`--t2:800ms`),直接开原文件会看到「所有动画挤在开头两秒」。放映页生成**快照副本**(`preview/play/.html`),把实测延迟写进 ``(等价于管线注入,优先级最高)并加 `` 让主题与素材照常解析。实测:同一张 t=3.0s,副本第二层 `opacity 0`(未入场),原文件 `opacity 1`(已入场)——与成片一致的是副本。**还没对时**则按 HTML 里实际用到的 stage 等间隔排(0.3/1.3/2.3s),页面顶部黄条如实标注「不是成片时序」。 -- 副本是快照,改完 `slides/` 必须重跑;页面与副本头部都写明真实文件路径与生成时间。 -- 老项目没有 `no-fx` 规则时,只给关动效副本兜底注入该规则并在页面提示(否则切过去是空白,会被误判成「关动效 bug」)。 -- 新增 `init-project.mjs --upgrade-css`:给老项目的 `tokens.css` 幂等补上新版 `no-fx` 规则(只动这一个文件,不碰 `script.json` 等其他内容)。此前对所有旧项目的建议「用新版 init-project 重生成」是错的 —— 那需要 `--force`,会重置 `script.json`。 -- 新增 `scripts/nofx-css.mjs`:`no-fx` 规则的唯一来源(`init-project` 写入 tokens.css、`preview-page` 兜底注入共用一份,避免 CSS 漂移)。 -- `fx-spotlight` 的关键帧补 `opacity: 1` —— 它是本技能文档里列为可用的入场类,但只做 `clip-path`,基础态 `opacity:0` 抬不回来 → 用了就永久隐形(被 `check-slides` 的 5b 项拦住,即「文档说能用、闸门说不能用」)。 -- SKILL.md 的 Gate 4 增加「放映页交用户自己放一遍」;`references/render.md` 增放映页章节(定位、键位、口播三态、为什么必须用副本);`authoring.md` 的动效开关一节写明交付前用 `X` 对照验收。 -- 测试 +27 例(共 99):`preview-page`(注入/合并/no-fx/base 顺序/自包含无外链/**不做计时器**/**不得出现「配音」字样**/响应式与触摸、口播三态、等间隔兜底、越界拒绝/幂等升级)、`tokens-fx`(对模板断言**每个非无限 fx 动画的关键帧都声明 opacity**、no-fx 规则含 opacity 重置、`--upgrade-css` 幂等且不碰其他文件)。 -**文档** - -- 开工对齐"主题与受众"→"**主题与领域**"(受监管题材必问免责声明与数据出处标注);新增 `references/compliance.md`(财经口播红线、数字三要件、涨跌色按受众翻转、免责声明写法、医疗/法律/广告法、Gate 清单);`tokens.css` 增 `--up/--down` 与 `.disclaimer`;`check-slides.mjs` 增整片级财经关键词自查。 -- **层的入场顺序不再固定为"标题先行"**:改为"每张至少两个信息层、分属不同 stage,顺序由强同步原则决定"(大数字先入 / 设问先出 / 图先入都合法),要拦的是"只有一个层"。 -- 补 `roadmap` 版式的 HTML 片段(此前表格里有、代码块缺失),并修 kpi-grid 片段使用未定义类 `.grid g4`(会静默竖排)的问题。 - -## 1.0.1 — 2026-09-17 - -**新增:领域与合规(受监管题材)** - -- `references/compliance.md`(新)— 领域确认问法(题材 + 受众 + 是否受监管)、财经口播三条红线(不给操作建议 / 不预测价格 / 不编数据图形)、数字三要件(**口径 + 币种 + 时点**)、涨跌色按受众翻转(A 股/港股 = 红涨绿跌)、免责声明写法与位置(`.disclaimer`,停留 ≥3s,口播不念也不进字幕)、医疗 / 法律 / 政务 / 广告法要点、Gate 收尾检查清单 -- **开工对齐**第一批问题从"主题与受众"升级为"**主题与领域**":受监管题材(财经投研 / 医疗健康 / 法律 / 政务政策 / 营销效果宣称)必须多问一句"要不要免责声明与数据出处标注"(默认要),并把结论记进 Gate 0 与 `research/notes.md` -- `tokens.css` 新增 `--up` / `--down` 涨跌专用令牌(默认 = `--good` / `--bad`)与 `.disclaimer` / `.disclaimer-box` 原语(纯附加,旧项目与既有主题不受影响) -- `check-slides.mjs` 新增**整片级领域自查**:命中多个财经/投研关键词却没有免责或出处行 → 给出提示(提示而非错误;用户已明确不要免责可忽略) -- `research.md` 增"先确认领域"与财经/投研题材坑(同比≠环比、GAAP≠非GAAP、把旧时点当"目前");`authoring.md` 增免责声明片段、kpi-grid 改用 `var(--up)/var(--down)`、主题速查增财经行;`evals` 增 2 条(财经开局确认、涨跌色按受众) - -## 1.0.0 — 2026-09-17 - -首个公开版本。 - -**流水线**:开工对齐(语言/风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 → HTML 分步入场 → 逐帧渲染 → ASR 反向校验,共 7 阶段 6 个确认闸门。 - -**脚本(11 个,纯 Node,无构建步骤)** - -| 脚本 | 作用 | -|---|---| -| `init-project.mjs` | 生成项目骨架(目录 + tokens.css + slide 模板 + script.json 契约) | -| `plan-timings.mjs` | ffprobe 实测每段 TTS → 时长、每层入场时刻、每句开口时刻 | -| `check-timing.mjs` | 静音检测实测每句真实开口,与估算对比并可校准 | -| `check-theme.mjs` | 全部主题的 WCAG 对比度闸门(正文/次级/字幕/强调色) | -| `check-slides.mjs` | 渲染前静态检查(未定义变量/图片缺失/外链/data-stage 未配动画/硬编码颜色) | -| `capture.mjs` | 终态截图或逐帧步进捕获,字幕默认烧录 | -| `build-video.mjs` | 编码 → 拼接 → 音轨对位 → BGM 混音 → 合成 → 自检 + SRT | -| `asr.mjs` | ASR 转写与脚本比对(直调 REST),支持字级时间戳核对开口时刻 | -| `prep-image.mjs` | 配图检查与受限裁切(裁掉面积上限 20%) | -| `fetch-official-images.mjs` | 从官方站点列取并下载候选素材图 | -| `tools.mjs` | ffmpeg/ffprobe 与 Node 包的多锚点探测 | - -**设计系统**:13 套主题、17 种版式、图片框原语(`.img-frame`)、14 个入场/氛围动画、分步入场与错峰容器。 - -**语言**:中文普通话 / 英语 / 粤语,字数与语速基准、字幕行宽、ASR 语言校验随语言切换。 - -**画布**:1920×1080 横屏与 1080×1920 竖版(字幕几何随画布比例自适应)。 - -**字幕**:单语或双语烧录,另出 `out/subs.srt`。 - -**声音**:BGM 垫底(可选,自动循环与淡入淡出)、ASR 反向校验(音色语种、数字与专名一致性)。 - -**资料搜集**:`references/research.md` —— 来源四级分级、四条硬规则(多源交叉验证/一手优先/标注口径日期/不确定不进脚本)、query 设计、多源矛盾处理、notes 模板、各环境搜索工具差异(含 mmx search 10 条上限)。 - -**两套工具链**:mcode 沙箱用 platform connectors;其他 Agent 环境用 mmx-cli(配 TTS)与本仓 `asr.mjs`(配 ASR)。 - -**已修复的静默故障(均加了闸门)** - -- 入场延迟被 `animation` 简写覆盖 → 元素在 0 秒入场(改用变量槽传递延迟) -- 未定义 CSS 变量 + 透明文字填充 → 文字完全隐形(check-slides 静态拦截) -- 图片加载失败 → 只出 broken 图标而无报错(capture 运行期点名) -- 缺少 clauses → 静默出无字幕片(capture 告警) -- 字幕窗口重叠 → 相邻两句同时可见, 看起来像"重影/错字"(淡出改为在窗口内归零, 并加窗口自检) -- 主题覆盖被忽略、同名选择器只取首个块(check-theme 合并语义修正) +# Changelog + +## 1.1.0 — 2026-09-18 + +**安全边界(响应 PR #41 评审的五条 Request changes)** + +- **路径收监**:`script.json` 是 agent 可编辑文件,其 `slides[].id/html/audio` 与 `bgm.file` 此前被直接拼进文件路径。现于 `tools.mjs` 新增 `safeId / safeRel / inside / validateScriptPaths / validateTimingsIds`,六个消费脚本(capture / build-video / plan-timings / check-timing / check-slides / asr)在 `JSON.parse` 后立即校验:id 走白名单 `^[A-Za-z0-9_-]{1,64}$`,路径拒绝绝对路径、resolve 后必须落在项目目录内、并对符号链接做 realpath 复核。此前 `id="../../victim"` 可触发**项目目录外的递归删除**(`capture.mjs` 的 `rmSync(build/frames/, {recursive:true})`)。 +- **覆盖拒绝**:`init-project.mjs` 对已存在且非空的目录直接拒绝(列出将被覆写的 5 个生成文件),需显式 `--force`;`fetch-official-images.mjs` 的 `--out-dir` 默认收监在工作目录内、已存在文件不覆盖;`prep-image.mjs --crop` 输出已存在需 `--force`。顺带修 `--topic` 未转义即插入模板 HTML 的问题。 +- **ASR 端点白名单**:API Key 只发官方域(`api.minimaxi.com` / `api.minimax.io`);`--base-url` / `MINIMAX_BASE_URL` 指向其他地址一律硬拒绝,自建网关需显式 `--allow-any-endpoint`(打印醒目警告)。此前被偷换的环境变量可把 Key 发往任意端点。 +- **抓图 SSRF 收紧**:新增 `scripts/url-policy.mjs`(纯函数)。拦 loopback / 链路本地(含云元数据 169.254.169.254)/ 私网 / CGNAT / 无点主机名;只允许 http(s),`file://` 需显式 `--allow-file`;禁带 userinfo 的 URL;`maxRedirects:0` 手动跟重定向且**逐跳**复用同一策略;响应大小上限默认 30MB(`--max-mb`);落盘文件名清洗补 Windows 保留名。 +- **可执行测试**:新增 `tests/`(node:test,零依赖,从仓库根 `node --test` 自动发现 → 被 `npm run check` 真实执行)—— `safe-paths`(恶意 id/路径 + canary 完好性 + symlink 逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(白名单拒绝 + 本地假服务器收到 Bearer 假 Key 的正向证据)、`fetch-policy`(host/URL/重定向/文件名 44 例)、`render-smoke`(init → 静音音频 → plan-timings → check-slides → capture → build-video 全链出片)。首轮 72 例(后续又加了 `preview-page` 与 `tokens-fx`,见下,现共 99 例),本地全绿。另附 scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml`(path-filter 只在本插件变更时跑,装 ffmpeg + playwright 后真实执行,含渲染冒烟)。 + +**图表与动效** + +- `references/authoring.md` 新增 **纯 CSS/SVG 图表**章节:横向条形 / 柱状 / 环形(`conic-gradient`)/ 折线(inline SVG)/ 进度条五种画法与选型速查;三条底线 = 禁外链图表库(离线取不到且 canvas 动画逐帧 seek 不到)、数值必须来自已核实口径、禁止 AI 生图当图表。 +- 新增 `fx-grow-x` / `fx-grow-y`(条形从左、柱状从底生长),折线 `fx-draw` 描边画入;**动效可一键关**:根元素或任意容器加 `no-fx`,关掉后 motion 捕获自动退化为静态帧,时长与音画同步不变(实测动画终态 vs `no-fx` 帧 PSNR 51.7dB,画面一致)。 +- `timeline` 版式改为整轴淡入 + 时间点逐个错峰入场。 + +**静默故障(继续加闸门)** + +- **fx 关键帧不含 opacity → 元素永久隐形**:`[data-stage]` 的基础态是 `opacity:0`,靠动画抬回 1;只做 transform/描边的动画(`fx-grow-x/y`、既有的 `fx-draw`)不改 opacity 就会永远不可见。关键帧已补 `opacity:1`,并在 `check-slides.mjs` 新增 **5b 项**静态拦截(fx 类的关键帧未声明 opacity → ✗)。 +- **旧帧目录污染成片**:切 `no-fx` 或改用 still 后,上一轮 motion 的帧目录仍在,`build-video` 会优先用残留帧把过时动画混进成片;capture 现在在产出静态图的路径上主动作废该张帧目录。 +- **柱状图模板高度塌陷**:外层容器写 `align-items:flex-end` 会让列 wrapper 高度塌成内容高,柱子百分比高度变 0(静默不显示);模板改为默认 stretch + 列内 `justify-content:flex-end`,并在文档里写明这个坑。 + +**放映页(可以先放映一遍再渲染)** + +- 新增 `scripts/preview-page.mjs <项目> [--open] [--no-script]` → `preview/play/index.html`:单文件、零依赖、`file://` 双击即看的放映页。**只干「把 HTML 画面放一遍」这一件事**:`←` `→`(触屏左右滑)翻页、`R` 重播入场动画、`X` 动效 / 关动效对照、`P` 口播文案 开 / 关、`O` 总览、`F` 全屏。**刻意不做播放器那套 UI** —— 没有计时器、进度条、逐句跟读高亮:要看时间或节奏就看成片,预览页里跑计时器只会让人盯秒表(实测标签页放着就变成 `204.2s / 6.3s`)。 +- **口播 UI 按数据决定加不加载**:有 clauses + 有 `timings.json` → 列出该张口播文案;有 clauses 但还没对时 → 只列文案并标「(未对时)」(口播还没做也能先看 HTML);没有 clauses 或 `--no-script` → 面板与口播按钮完全不出现,画面占满整宽。 +- **布局随窗口自适应**:顶栏/底栏可换行、话题名过长省略号;窄窗口与手机上口播面板收成底部抽屉并默认收起(画面优先),手机给触摸条按钮 + 左右滑动翻页,总览网格按宽度自动列数,高度用 `100dvh`(免得被手机地址栏切掉)。 +- 为什么不是「直接打开 `slides/*.html`」:延迟 `--t1/--t2/--t3` 与画布尺寸由渲染管线按 `timings.json` 注入,tokens.css 里只有占位值(`--t2:800ms`),直接开原文件会看到「所有动画挤在开头两秒」。放映页生成**快照副本**(`preview/play/.html`),把实测延迟写进 ``(等价于管线注入,优先级最高)并加 `` 让主题与素材照常解析。实测:同一张 t=3.0s,副本第二层 `opacity 0`(未入场),原文件 `opacity 1`(已入场)——与成片一致的是副本。**还没对时**则按 HTML 里实际用到的 stage 等间隔排(0.3/1.3/2.3s),页面顶部黄条如实标注「不是成片时序」。 +- 副本是快照,改完 `slides/` 必须重跑;页面与副本头部都写明真实文件路径与生成时间。 +- 老项目没有 `no-fx` 规则时,只给关动效副本兜底注入该规则并在页面提示(否则切过去是空白,会被误判成「关动效 bug」)。 +- 新增 `init-project.mjs --upgrade-css`:给老项目的 `tokens.css` 幂等补上新版 `no-fx` 规则(只动这一个文件,不碰 `script.json` 等其他内容)。此前对所有旧项目的建议「用新版 init-project 重生成」是错的 —— 那需要 `--force`,会重置 `script.json`。 +- 新增 `scripts/nofx-css.mjs`:`no-fx` 规则的唯一来源(`init-project` 写入 tokens.css、`preview-page` 兜底注入共用一份,避免 CSS 漂移)。 +- `fx-spotlight` 的关键帧补 `opacity: 1` —— 它是本技能文档里列为可用的入场类,但只做 `clip-path`,基础态 `opacity:0` 抬不回来 → 用了就永久隐形(被 `check-slides` 的 5b 项拦住,即「文档说能用、闸门说不能用」)。 +- SKILL.md 的 Gate 4 增加「放映页交用户自己放一遍」;`references/render.md` 增放映页章节(定位、键位、口播三态、为什么必须用副本);`authoring.md` 的动效开关一节写明交付前用 `X` 对照验收。 +- 测试 +27 例(共 99):`preview-page`(注入/合并/no-fx/base 顺序/自包含无外链/**不做计时器**/**不得出现「配音」字样**/响应式与触摸、口播三态、等间隔兜底、越界拒绝/幂等升级)、`tokens-fx`(对模板断言**每个非无限 fx 动画的关键帧都声明 opacity**、no-fx 规则含 opacity 重置、`--upgrade-css` 幂等且不碰其他文件)。 +**文档** + +- 开工对齐"主题与受众"→"**主题与领域**"(受监管题材必问免责声明与数据出处标注);新增 `references/compliance.md`(财经口播红线、数字三要件、涨跌色按受众翻转、免责声明写法、医疗/法律/广告法、Gate 清单);`tokens.css` 增 `--up/--down` 与 `.disclaimer`;`check-slides.mjs` 增整片级财经关键词自查。 +- **层的入场顺序不再固定为"标题先行"**:改为"每张至少两个信息层、分属不同 stage,顺序由强同步原则决定"(大数字先入 / 设问先出 / 图先入都合法),要拦的是"只有一个层"。 +- 补 `roadmap` 版式的 HTML 片段(此前表格里有、代码块缺失),并修 kpi-grid 片段使用未定义类 `.grid g4`(会静默竖排)的问题。 + +## 1.0.1 — 2026-09-17 + +**新增:领域与合规(受监管题材)** + +- `references/compliance.md`(新)— 领域确认问法(题材 + 受众 + 是否受监管)、财经口播三条红线(不给操作建议 / 不预测价格 / 不编数据图形)、数字三要件(**口径 + 币种 + 时点**)、涨跌色按受众翻转(A 股/港股 = 红涨绿跌)、免责声明写法与位置(`.disclaimer`,停留 ≥3s,口播不念也不进字幕)、医疗 / 法律 / 政务 / 广告法要点、Gate 收尾检查清单 +- **开工对齐**第一批问题从"主题与受众"升级为"**主题与领域**":受监管题材(财经投研 / 医疗健康 / 法律 / 政务政策 / 营销效果宣称)必须多问一句"要不要免责声明与数据出处标注"(默认要),并把结论记进 Gate 0 与 `research/notes.md` +- `tokens.css` 新增 `--up` / `--down` 涨跌专用令牌(默认 = `--good` / `--bad`)与 `.disclaimer` / `.disclaimer-box` 原语(纯附加,旧项目与既有主题不受影响) +- `check-slides.mjs` 新增**整片级领域自查**:命中多个财经/投研关键词却没有免责或出处行 → 给出提示(提示而非错误;用户已明确不要免责可忽略) +- `research.md` 增"先确认领域"与财经/投研题材坑(同比≠环比、GAAP≠非GAAP、把旧时点当"目前");`authoring.md` 增免责声明片段、kpi-grid 改用 `var(--up)/var(--down)`、主题速查增财经行;`evals` 增 2 条(财经开局确认、涨跌色按受众) + +## 1.0.0 — 2026-09-17 + +首个公开版本。 + +**流水线**:开工对齐(语言/风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 → HTML 分步入场 → 逐帧渲染 → ASR 反向校验,共 7 阶段 6 个确认闸门。 + +**脚本(11 个,纯 Node,无构建步骤)** + +| 脚本 | 作用 | +|---|---| +| `init-project.mjs` | 生成项目骨架(目录 + tokens.css + slide 模板 + script.json 契约) | +| `plan-timings.mjs` | ffprobe 实测每段 TTS → 时长、每层入场时刻、每句开口时刻 | +| `check-timing.mjs` | 静音检测实测每句真实开口,与估算对比并可校准 | +| `check-theme.mjs` | 全部主题的 WCAG 对比度闸门(正文/次级/字幕/强调色) | +| `check-slides.mjs` | 渲染前静态检查(未定义变量/图片缺失/外链/data-stage 未配动画/硬编码颜色) | +| `capture.mjs` | 终态截图或逐帧步进捕获,字幕默认烧录 | +| `build-video.mjs` | 编码 → 拼接 → 音轨对位 → BGM 混音 → 合成 → 自检 + SRT | +| `asr.mjs` | ASR 转写与脚本比对(直调 REST),支持字级时间戳核对开口时刻 | +| `prep-image.mjs` | 配图检查与受限裁切(裁掉面积上限 20%) | +| `fetch-official-images.mjs` | 从官方站点列取并下载候选素材图 | +| `tools.mjs` | ffmpeg/ffprobe 与 Node 包的多锚点探测 | + +**设计系统**:13 套主题、17 种版式、图片框原语(`.img-frame`)、14 个入场/氛围动画、分步入场与错峰容器。 + +**语言**:中文普通话 / 英语 / 粤语,字数与语速基准、字幕行宽、ASR 语言校验随语言切换。 + +**画布**:1920×1080 横屏与 1080×1920 竖版(字幕几何随画布比例自适应)。 + +**字幕**:单语或双语烧录,另出 `out/subs.srt`。 + +**声音**:BGM 垫底(可选,自动循环与淡入淡出)、ASR 反向校验(音色语种、数字与专名一致性)。 + +**资料搜集**:`references/research.md` —— 来源四级分级、四条硬规则(多源交叉验证/一手优先/标注口径日期/不确定不进脚本)、query 设计、多源矛盾处理、notes 模板、各环境搜索工具差异(含 mmx search 10 条上限)。 + +**两套工具链**:mcode 沙箱用 platform connectors;其他 Agent 环境用 mmx-cli(配 TTS)与本仓 `asr.mjs`(配 ASR)。 + +**已修复的静默故障(均加了闸门)** + +- 入场延迟被 `animation` 简写覆盖 → 元素在 0 秒入场(改用变量槽传递延迟) +- 未定义 CSS 变量 + 透明文字填充 → 文字完全隐形(check-slides 静态拦截) +- 图片加载失败 → 只出 broken 图标而无报错(capture 运行期点名) +- 缺少 clauses → 静默出无字幕片(capture 告警) +- 字幕窗口重叠 → 相邻两句同时可见, 看起来像"重影/错字"(淡出改为在窗口内归零, 并加窗口自检) +- 主题覆盖被忽略、同名选择器只取首个块(check-theme 合并语义修正) diff --git a/plugins/Wzdhehe/html2video-for-mcode/README.md b/plugins/Wzdhehe/html2video-for-mcode/README.md index 5c7d4bd7..2e23b0fd 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/README.md +++ b/plugins/Wzdhehe/html2video-for-mcode/README.md @@ -22,6 +22,7 @@ my-video/ ├── audio/ 8 TTS clips ├── build/timings.json measured durations + per-layer entrance times ├── preview/*.png terminal-state frames +├── preview/play/ self-contained play page (screen the deck without encoding) └── out/ ├── final.mp4 ★ the deliverable (1920×1080 or 1080×1920, H.264 + AAC) ├── subs.srt subtitles for platform upload @@ -30,18 +31,19 @@ my-video/ ## Pipeline -One Skill drives a 13-script pipeline (`skills/html2video-for-mcode/scripts/`): +One Skill drives an 11-script pipeline (plus three internal modules — path containment, URL +policy, `no-fx` rules — under `skills/html2video-for-mcode/scripts/`): | Stage | What happens | |---|---| | 1. Kickoff alignment | Ask about language (Chinese / English / Cantonese), style & brand color, subtitles (none / single / bilingual), canvas (16:9 or 9:16), duration, voice, asset boundaries | | 2. Research | Fact-check the topic and record sources before writing | -| 3. Script | Per-slide narration split into clauses; every slide must have a title layer and a detail layer | +| 3. Script | Per-slide narration split into clauses; every slide carries at least two information layers on separate animation stages (order is free) | | 4. TTS | Voice synthesis (mcode connector, or `mmx-cli` elsewhere), then measure every clip with ffprobe | | 5. Timing | Derive each slide's duration and each animation's entrance time from the **measured** audio — nothing is hand-written | | 6. Assets | Official sources first, image framing primitives, compliance manifest | | 7. HTML | Staged entrance animations bound to the measured timings; layout recipes for 17 slide types | -| 8. Render | Deterministic frame-stepping capture (animations land in the video), ffmpeg assembly with subtitles, optional background music | +| 8. Render | Deterministic frame-stepping capture (animations land in the video), ffmpeg assembly with subtitles, optional background music. Before spending encode time, `preview-page.mjs` writes a play page so the deck can be screened in a browser | | 9. Verify | ASR transcription compared against the script; contrast, theme, and static-slide gates | ## Design decisions worth knowing @@ -49,13 +51,26 @@ One Skill drives a 13-script pipeline (`skills/html2video-for-mcode/scripts/`): - **Nothing about timing is hand-written.** Every slide's duration and every animation's entrance time comes from the measured TTS audio, so "the voiceover finished but the picture is still waiting" cannot happen by construction. -- **Every slide has a title layer and a detail layer** on separate animation stages, so a slide is - never just a big title with nothing to look at. +- **Every slide has at least two information layers** on separate animation stages (which layer + enters first is a judgement call — a big number, a question, or an image may lead), so a slide is + never just one line of text with nothing to look at. Entrance order is checked statically, not + left to hope. - **Animations land in the video.** Capture steps frames deterministically instead of screen-recording, so entrance animations are actually rendered rather than frozen. +- **Regulated topics get compliance handling.** For finance / medical / legal / policy decks the + workflow asks about disclaimers and source attribution up front, pins every number to its basis + (scope + currency + as-of date), and flips the up/down colour convention to the audience's market + (A-shares and Hong Kong read red as up) — see `references/compliance.md`. +- **Charts are drawn in plain CSS/SVG, and effects can be switched off in one place.** No chart + library and no canvas (offline they would not load, and canvas animations cannot be frame-seeked). + Adding `no-fx` to the root element — or to any container, for a single slide — turns every + entrance and ambient animation off; the renderer then emits static frames and the timing stays + intact. - **Rendering is gated.** A static check refuses to render slides with undefined CSS variables, missing images, external resources, or entrance animations without an animation class — the failure modes that otherwise ship a video that looks broken while every script reports success. + It also refuses entrance animations whose keyframes never lift the `opacity: 0` base state — those + elements would silently stay invisible in the finished video. ## Install @@ -177,7 +192,7 @@ The Skill ships an executable test suite (`skills/html2video-for-mcode/tests/`, node --test "plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/*.test.mjs" ``` -93 tests in seven files: `safe-paths` (malicious slide ids / paths, canary intactness, symlink +99 tests in seven files: `safe-paths` (malicious slide ids / paths, canary intactness, symlink escapes), `no-clobber` (refusing to overwrite), `endpoint-allowlist` (key never leaves the official hosts — plus a local server that proves the gate sits before the request), `fetch-policy` (SSRF, `file://`, redirect and filename rules), `preview-page` (snapshot timing injection, `base` ordering, @@ -191,8 +206,11 @@ Chromium; where those are missing they skip with a stated reason, and the scoped `SKILL.md` ends with a symptom → cause → fix table covering the failures this pipeline has actually hit: silent tails after the voiceover, slides with nothing on them, elements entering at 0 seconds, -invisible text from undefined CSS variables, broken images, subtitles washed out on dark themes, -wrong voice language, and mismatched concat durations. +invisible text from undefined CSS variables, entrance elements that never appear at all (keyframes +that never lift the `opacity: 0` base state), a `no-fx` switch that leaves the slide blank, opening +`slides/*.html` and finding every animation crammed into the first two seconds, a blank play page, +broken images, subtitles washed out on dark themes, wrong voice language, mismatched concat +durations, and finance decks missing a disclaimer or with the up/down colour flipped. ## License diff --git a/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md b/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md index 82f86950..87861d2d 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md +++ b/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md @@ -20,6 +20,7 @@ my-video/ ├── audio/ 8 段 TTS 音频 ├── build/timings.json 实测时长 + 每个视觉层的入场时刻 ├── preview/*.png 终态预览帧 +├── preview/play/ 自包含放映页(不编码也能先过一遍) └── out/ ├── final.mp4 ★ 交付成片(1920×1080 或 1080×1920,H.264 + AAC) ├── subs.srt 供平台上传的字幕 @@ -28,26 +29,28 @@ my-video/ ## 流水线 -一个技能驱动 13 个脚本(`skills/html2video-for-mcode/scripts/`): +一个技能驱动 11 个脚本,外加三个内部模块(路径收监 / URL 策略 / `no-fx` 规则,都在 `skills/html2video-for-mcode/scripts/` 下): | 阶段 | 做什么 | |---|---| | 1. 开工对齐 | 询问语言(中文/英语/粤语)、风格与品牌色、字幕(不要/单语/双语)、画布(16:9 或 9:16)、时长、音色、素材边界 | | 2. 信息搜集 | 事实性题材先核查并记录来源,再动笔 | -| 3. 脚本 | 每张 slide 的口播拆成逐句 clauses;每张必须有"标题层 + 展开层" | +| 3. 脚本 | 每张 slide 的口播拆成逐句 clauses;每张至少两个信息层、分属不同动画 stage(先后顺序自由) | | 4. TTS | 配音合成(mcode connector,其他环境用 `mmx-cli`),随后用 ffprobe 实测每段时长 | | 5. 对时 | 每张时长、每层入场时刻**全部由实测音频推出**,不手写任何秒数 | | 6. 配图 | 官方素材优先、图片框原语、合规清单登记 | | 7. HTML | 分步入场动画绑定实测时刻;17 种版式配方 | -| 8. 渲染 | 确定性逐帧步进捕获(动画真正进视频)、ffmpeg 合成与字幕、可选 BGM | +| 8. 渲染 | 确定性逐帧步进捕获(动画真正进视频)、ffmpeg 合成与字幕、可选 BGM。真正编码前先用 `preview-page.mjs` 出的放映页在浏览器里过一遍 | | 9. 校验 | ASR 转写与脚本比对;对比度、主题、渲染前静态闸门 | ## 几个关键设计 - **时序不靠手写**:每张时长与每个动画的入场时刻都来自实测音频,所以"配音念完了画面还在等"在结构上就不可能发生。 -- **每张都有标题层与展开层**,分属不同动画 stage,不会出现"只有一行大字"的页面。 +- **每张至少两个信息层**,分属不同动画 stage(哪层先入场是判断题 —— 大数字、设问、图都可以先出),不会出现"只有一行大字"的页面;入场顺序是**静态检查**出来的,不靠运气。 - **动画真的进视频**:捕获用逐帧步进而不是录屏,入场动画是渲染出来的,不是冻结在终态。 -- **渲染有闸门**:静态检查会拒绝未定义 CSS 变量、图片缺失、外链资源、入场动画缺动画类的页面 —— 这些正是"视频看着坏了但每个脚本都报成功"的元凶。 +- **渲染有闸门**:静态检查会拒绝未定义 CSS 变量、图片缺失、外链资源、入场动画缺动画类的页面,也会拒绝"关键帧从不把 `opacity: 0` 基础态抬回来"的入场动画(那类元素会在成片里静默隐形)—— 这些正是"视频看着坏了但每个脚本都报成功"的元凶。 +- **受监管题材走合规流程**:财经 / 医疗 / 法律 / 政务类片子开工就问免责声明与出处标注,每个数字钉住口径(口径 + 币种 + 时点),涨跌色按受众市场翻转(A 股 / 港股是红涨绿跌)—— 见 `references/compliance.md`。 +- **图表纯 CSS/SVG 自绘,动效可一键关**:不引图表库、不用 canvas(离线取不到,canvas 动画也逐帧 seek 不到);根元素(或任意容器,单张生效)加 `no-fx` 即可关掉全部入场与氛围动效,渲染自动走静态帧、时长不变。 ## 安装 @@ -136,11 +139,11 @@ Windows / macOS / Linux。脚本全部是 Node ESM,不依赖特定 shell。Windo node --test "plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/*.test.mjs" ``` -七个文件共 93 例:`safe-paths`(恶意 slide id / 路径、canary 完好性、符号链接逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(Key 不离开官方域,并用本地服务器证明闸门在请求之前)、`fetch-policy`(SSRF、`file://`、重定向与文件名规则)、`preview-page`(快照注入实测延迟、`base` 顺序、自包含无外链、越界拒绝)、`tokens-fx`(模板里每个入场动画的关键帧必须声明 `opacity`、`no-fx` 必须重置基础态、`--upgrade-css` 幂等)与 `render-smoke`(init → 对时 → 静态闸门 → 截图 → 成片全链)。渲染冒烟与三例依赖 ffmpeg 的路径检查需要 ffmpeg 与 Chromium:缺失时按原因 skip,scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml` 会装齐依赖并把全部用例真跑一遍。 +七个文件共 99 例:`safe-paths`(恶意 slide id / 路径、canary 完好性、符号链接逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(Key 不离开官方域,并用本地服务器证明闸门在请求之前)、`fetch-policy`(SSRF、`file://`、重定向与文件名规则)、`preview-page`(快照注入实测延迟、`base` 顺序、自包含无外链、越界拒绝)、`tokens-fx`(模板里每个入场动画的关键帧必须声明 `opacity`、`no-fx` 必须重置基础态、`--upgrade-css` 幂等)与 `render-smoke`(init → 对时 → 静态闸门 → 截图 → 成片全链)。渲染冒烟与三例依赖 ffmpeg 的路径检查需要 ffmpeg 与 Chromium:缺失时按原因 skip,scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml` 会装齐依赖并把全部用例真跑一遍。 ## 排错 -`SKILL.md` 末尾有一张"症状 → 原因 → 处置"表,覆盖这条流水线真实踩过的坑:配音念完画面还在等、页面只有标题、元素在 0 秒就入场、未定义 CSS 变量导致文字隐形、图片 broken、深色主题字幕糊底、音色语种不对、拼接后时长不符。 +`SKILL.md` 末尾有一张"症状 → 原因 → 处置"表,覆盖这条流水线真实踩过的坑:配音念完画面还在等、页面只有标题、元素在 0 秒就入场、未定义 CSS 变量导致文字隐形、**元素入场后永远不出现(关键帧没把 `opacity` 抬回来)**、**切了 `no-fx` 画面反而更空**、**双击 `slides/*.html` 发现动画全挤在开头**、**放映页空白/图裂**、图片 broken、深色主题字幕糊底、音色语种不对、拼接后时长不符、**财经片漏了免责声明或涨跌色反了**。 ## 许可 diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md index 1bff3f91..cc640081 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md @@ -1,6 +1,6 @@ --- name: html2video-for-mcode -description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻灯片 + TTS + ffmpeg 渲染),为 mcode 环境定制、也可在其他 Agent 环境用 mmx-cli 跑通。当用户想把内容做成视频、html 转 mp4、幻灯片口播视频、slides video、narrated video、一分钟介绍视频、抖音/视频号竖版视频时触发;财经/财报/投研/医疗/政策等**受监管题材**的视频同样触发(需要免责声明与数据口径处理)。也当症状出现时触发——TTS 念完留白过长、画面只有标题没有细节展开、部分元素 0 秒就入场、入场动画没渲染进视频、音画不同步、图片主体被裁到画面外、字幕糊在深色背景上、拼接后总时长不对、字体闪烁或方框、财经片忘了写免责声明或涨跌色用反。覆盖全流程:开工对齐(领域与免责/风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 SOP → HTML 分步入场 → 逐帧渲染 → ASR 反向校验。工具分两套:mcode 沙箱用 connector__matrix__*(TTS/ASR/音乐),其他环境用 mmx-cli(注意:mmx-cli 无 ASR 与音乐生成,见文末环境对照表)。 +description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻灯片 + TTS + ffmpeg 渲染),为 mcode 环境定制、也可在其他 Agent 环境用 mmx-cli 跑通。当用户想把内容做成视频、html 转 mp4、幻灯片口播视频、slides video、narrated video、一分钟介绍视频、抖音/视频号竖版视频时触发;**只想先看看做好的 HTML 幻灯片、想自己放映一遍或预览动效(口播还没做也行)时同样触发**——技能会生成自包含放映页,不必先花几分钟渲染成片。财经/财报/投研/医疗/政策等**受监管题材**的视频同样触发(需要免责声明与数据口径处理)。也当症状出现时触发——TTS 念完留白过长、画面只有标题没有细节展开、部分元素 0 秒就入场、入场动画没渲染进视频、音画不同步、图片主体被裁到画面外、字幕糊在深色背景上、拼接后总时长不对、字体闪烁或方框、图表或某个元素入场后永远不出现(关键帧没把 opacity 抬回来)、切了 no-fx 画面反而更空、双击 slides/*.html 看动画发现全挤在开头、财经片忘了写免责声明或涨跌色用反。覆盖全流程:开工对齐(领域与免责/风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 SOP → HTML 分步入场 → 静态闸门与放映页自检 → 逐帧渲染 → ASR 反向校验。工具分两套:mcode 沙箱用 connector__matrix__*(TTS/ASR/音乐),其他环境用 mmx-cli(注意:mmx-cli 无 ASR 与音乐生成,见文末环境对照表)。 --- # HTML 2 Video for mcode:脚本 → 口播成片 @@ -27,7 +27,7 @@ description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻 ## 目录与工具 -技能自带 13 个脚本(直接以本技能目录为路径调用,项目目录作为参数,无需复制;`tests/` 下还有一套 node:test 安全与冒烟测试,从仓库根 `node --test` 自动发现): +技能自带 **11 个命令行脚本 + 3 个内部模块**(直接以本技能目录为路径调用,项目目录作为参数,无需复制;`tests/` 下还有一套 node:test 安全与冒烟测试,从仓库根 `node --test` 自动发现): | 脚本 | 作用 | |---|---| @@ -35,10 +35,15 @@ description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻 | `scripts/plan-timings.mjs <项目目录>` | ffprobe 实测每段 TTS → 每张时长、各 stage 入场时刻、**每句 clauses 时刻** → `build/timings.json` | | `scripts/check-timing.mjs <项目目录> [--calibrate]` | 静音检测实测每句真实开口, 与估算对比;`--calibrate` 按实测校准 timings 后重渲染 | | `scripts/check-theme.mjs <项目目录>` | 校验全部主题的 WCAG 对比度(正文/次级/字幕/accent-ink), 不达标退出码 1;新增主题必须过闸 | -| `scripts/prep-image.mjs --check <图...>` / `--crop [--ratio 16:9] [--anchor ...]` | 配图 SOP 的执行辅助:查尺寸与裁切风险;按锚点裁切(强制"裁掉 ≤20%、不放大补边") | +| `scripts/check-slides.mjs <项目目录> [--quiet]` | **渲染前静态闸门**:未定义 CSS 变量、缺图/外链资源、`data-stage` 没配 fx 类、fx 关键帧不含 opacity(会永久隐形)、硬编码颜色、整片级领域自查。有 ✗ 就别截图 | +| `scripts/prep-image.mjs --check <图...>` / `--crop [--ratio 16:9] [--anchor ...] [--force]` | 配图 SOP 的执行辅助:查尺寸与裁切风险;按锚点裁切(强制"裁掉 ≤20%、不放大补边") | +| `scripts/fetch-official-images.mjs <页面URL> [--out-dir <目录>] [--allow-file] [--max-mb N] [--force]` | 从官方页/本地页面列出并下载候选配图。内网与元数据地址一律拒绝, 每跳重定向复核, 默认写在工作目录内 | | `scripts/capture.mjs <项目目录> [--mode still\|motion] [--no-subs]` | Playwright 截图。still=终态单帧;motion=逐帧步进入场动画。**字幕默认烧录**(内容取自 clauses),`--no-subs` 关闭 | | `scripts/preview-page.mjs <项目目录> [--open] [--no-script]` | 生成**放映页** `preview/play/index.html`(单文件、零依赖、file:// 双击即看):←→ 或触屏左右滑翻页、R 重播动画、X 动效/关动效对照、P 口播文案开/关、O 总览、F 全屏。**只做"放画面"这件事**:没有计时器/进度条/跟读高亮(要看时间就看成片)。快照按 `timings.json` **注入实测延迟**,所以浏览器里的动画时序 = 成片时序;还没对时则按等间隔预览并如实标注。口播面板按数据自动决定加不加载,窄窗口/手机上收成底部抽屉且默认收起 | -| `scripts/build-video.mjs <项目目录> [--asr]` | 编码每张 → 拼接 → 音轨对位 → 合成 → 自检 + 出 `out/subs.srt`;`--asr` **按句**切分音频 + 校验清单 | +| `scripts/build-video.mjs <项目目录> [--asr] [--dry-run]` | 编码每张 → 拼接 → 音轨对位 → 合成 → 自检 + 出 `out/subs.srt`;`--asr` **按句**切分音频 + 校验清单;`--dry-run` 只打印将要执行的 ffmpeg 命令(排错用) | +| `scripts/asr.mjs <项目目录> [--api-key K] [--verify-timing] [--from <转写>] [--allow-any-endpoint]` | 调 ASR 转写并按句校验音画是否念的是脚本(数字/繁体字不符判 ✗);`--verify-timing` 用字级时间戳实测句开口。Key 只发官方域 | + +内部模块(被上面的脚本 import, 不单独运行):`tools.mjs`(ffmpeg/ffprobe 探测 + 路径收监 `safeId/safeRel/inside`)、`url-policy.mjs`(ASR 端点白名单 + SSRF/重定向策略)、`nofx-css.mjs`(`no-fx` 规则的唯一来源,init-project 写入 / preview-page 兜底注入共用)。 环境要求:Node 18+(脚本用 fileURLToPath 保兼容, 不依赖 Node 20.11 的 import.meta.dirname)、`npm i playwright && npx playwright install chromium`(项目目录内)。ffmpeg/ffprobe 自动探测:PATH → node_modules(ffmpeg-static/ffprobe-static)→ 常见安装位置,找不到会给逐条诊断而不是莫名报错。 @@ -194,7 +199,7 @@ node <技能目录>/scripts/capture.mjs <项目目录> --mode still node <技能目录>/scripts/preview-page.mjs <项目目录> --open # 放映页: 让用户自己过一遍动效 ``` -**Gate 4**:`preview/.png` 逐张给用户过(看版式、字压、素材)+ **放映页 `preview/play/index.html` 交用户自己放一遍**(看动效节奏、口播与入场是否对得上、收尾静止够不够)。静态图看不出动画——而"元素永远不出现""关动效反而空白""列高塌陷"这类故障恰恰只在动起来或切对照时才露头;放映页是 30 秒的自检手段,不必等 3–6 分钟的 motion 编码。**用户过完再进 Phase 5。** +**Gate 4**:`preview/.png` 逐张给用户过(看版式、字压、素材)+ **放映页 `preview/play/index.html` 交用户自己放一遍**(看动效是不是真的都出现了、快慢能不能接受、这张的画面撑不撑得住)。静态图看不出动画——"元素永远不出现""关动效反而空白""列高塌陷"这类故障恰恰只在动起来或切对照时才露头;放映页是 30 秒的自检手段,不必等 3–6 分钟的 motion 编码。时序与口播对轴不在这一步判(放映页刻意不做计时器),以成片和 `timings.json` 为准。**用户过完再进 Phase 5。** ### Phase 5 · 渲染 + ASR 校验 diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/render.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/render.md index 10debb41..b6e8b5f4 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/render.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/render.md @@ -127,6 +127,8 @@ ffmpeg -y -framerate 30 -i build/frames/01/f%05d.png \ 音轨对位:每段 `aresample=44100,aformat=channel_layouts=mono,apad=whole_dur=<该张实测时长>` 补齐静音再 concat。**不要用 adelay+concat 的写法**(concat filter 会忽略 adelay 的偏移,所有语音堆到开头)。 +**先看命令再跑**:`node scripts/build-video.mjs <项目> --dry-run` 会把每一步要执行的 ffmpeg 命令打印出来而不真的执行 —— 想手工验证某张的参数、或怀疑是编码参数问题而不是素材问题时,先跑它,把命令原样拿去命令行改着试。 + ## 自检标准 build-video 结束前强制:成片 ffprobe 时长与 timings 总时长差 ≤0.25s;`ffmpeg -v error -i final.mp4 -f null -` 全量解码零错误。任一不过,退出码非 0——看到非 0 不要交付。 From 62816b1334b800f53d508d0bd350c9c93891f560 Mon Sep 17 00:00:00 2001 From: Hahaha Date: Fri, 18 Sep 2026 14:32:22 +0800 Subject: [PATCH 15/24] =?UTF-8?q?docs:=20=E8=8B=B1=E6=96=87=20README=20?= =?UTF-8?q?=E7=9A=84=E7=A4=BA=E4=BE=8B=E9=9C=80=E6=B1=82=E8=A1=A5=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E5=AF=B9=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/Wzdhehe/html2video-for-mcode/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/Wzdhehe/html2video-for-mcode/README.md b/plugins/Wzdhehe/html2video-for-mcode/README.md index 2e23b0fd..8941af7a 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/README.md +++ b/plugins/Wzdhehe/html2video-for-mcode/README.md @@ -10,10 +10,15 @@ Built for MiniMax Code (mcode), and runnable in any agent host through `mmx-cli` ## What the user gets -Ask in plain language, get a publishable video: +Ask in plain language, get a publishable video. Here is a real request — Chinese is the default +narration language (English and Cantonese are supported too), so the sample input is quoted as it +was typed: > 帮我把这份大纲做成一条 60 秒的中文口播视频:三张关键数字、结尾一句行动号召,用深色科技主题,加中文字幕。 +*"Turn this outline into a 60-second Chinese voiceover video: three key numbers, a closing call to +action, a dark tech theme, Chinese subtitles."* + Result: ``` From 97ad020c4cb0d103547af656ad9ce1923417dfbe Mon Sep 17 00:00:00 2001 From: Hahaha Date: Fri, 18 Sep 2026 14:35:19 +0800 Subject: [PATCH 16/24] =?UTF-8?q?feat:=20fetch-official-images=20--url(?= =?UTF-8?q?=E5=86=85=E7=BD=AE=E6=B5=8F=E8=A7=88=E5=99=A8=E5=8F=96=E5=9B=BE?= =?UTF-8?q?=E6=9C=89=E8=90=BD=E7=9B=98=E5=85=A5=E5=8F=A3)+=20=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E6=B5=8F=E8=A7=88=E5=99=A8=E7=BA=AA=E5=BE=8B=E5=86=99?= =?UTF-8?q?=E8=BF=9B=E9=85=8D=E5=9B=BE/=E8=B0=83=E7=A0=94=20SOP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Wzdhehe/html2video-for-mcode/CHANGELOG.md | 5 +- .../Wzdhehe/html2video-for-mcode/README.md | 11 +- .../html2video-for-mcode/README.zh-CN.md | 4 +- .../skills/html2video-for-mcode/SKILL.md | 534 +++++------ .../references/authoring.md | 892 +++++++++--------- .../references/image-sources.md | 217 ++--- .../references/research.md | 238 ++--- .../scripts/fetch-official-images.mjs | 423 +++++---- .../scripts/url-policy.mjs | 19 + .../tests/fetch-policy.test.mjs | 201 ++-- .../tests/no-clobber.test.mjs | 210 +++-- 11 files changed, 1447 insertions(+), 1307 deletions(-) diff --git a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md index c03dc65f..1402c2d3 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md +++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md @@ -8,7 +8,7 @@ - **覆盖拒绝**:`init-project.mjs` 对已存在且非空的目录直接拒绝(列出将被覆写的 5 个生成文件),需显式 `--force`;`fetch-official-images.mjs` 的 `--out-dir` 默认收监在工作目录内、已存在文件不覆盖;`prep-image.mjs --crop` 输出已存在需 `--force`。顺带修 `--topic` 未转义即插入模板 HTML 的问题。 - **ASR 端点白名单**:API Key 只发官方域(`api.minimaxi.com` / `api.minimax.io`);`--base-url` / `MINIMAX_BASE_URL` 指向其他地址一律硬拒绝,自建网关需显式 `--allow-any-endpoint`(打印醒目警告)。此前被偷换的环境变量可把 Key 发往任意端点。 - **抓图 SSRF 收紧**:新增 `scripts/url-policy.mjs`(纯函数)。拦 loopback / 链路本地(含云元数据 169.254.169.254)/ 私网 / CGNAT / 无点主机名;只允许 http(s),`file://` 需显式 `--allow-file`;禁带 userinfo 的 URL;`maxRedirects:0` 手动跟重定向且**逐跳**复用同一策略;响应大小上限默认 30MB(`--max-mb`);落盘文件名清洗补 Windows 保留名。 -- **可执行测试**:新增 `tests/`(node:test,零依赖,从仓库根 `node --test` 自动发现 → 被 `npm run check` 真实执行)—— `safe-paths`(恶意 id/路径 + canary 完好性 + symlink 逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(白名单拒绝 + 本地假服务器收到 Bearer 假 Key 的正向证据)、`fetch-policy`(host/URL/重定向/文件名 44 例)、`render-smoke`(init → 静音音频 → plan-timings → check-slides → capture → build-video 全链出片)。首轮 72 例(后续又加了 `preview-page` 与 `tokens-fx`,见下,现共 99 例),本地全绿。另附 scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml`(path-filter 只在本插件变更时跑,装 ffmpeg + playwright 后真实执行,含渲染冒烟)。 +- **可执行测试**:新增 `tests/`(node:test,零依赖,从仓库根 `node --test` 自动发现 → 被 `npm run check` 真实执行)—— `safe-paths`(恶意 id/路径 + canary 完好性 + symlink 逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(白名单拒绝 + 本地假服务器收到 Bearer 假 Key 的正向证据)、`fetch-policy`(host/URL/重定向/文件名 44 例)、`render-smoke`(init → 静音音频 → plan-timings → check-slides → capture → build-video 全链出片)。首轮 72 例(后续又加了 `preview-page` 与 `tokens-fx`,见下,现共 99 例;再加 `--url` 的 8 例, 现共 107 例),本地全绿。另附 scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml`(path-filter 只在本插件变更时跑,装 ffmpeg + playwright 后真实执行,含渲染冒烟)。 **图表与动效** @@ -34,7 +34,8 @@ - 新增 `scripts/nofx-css.mjs`:`no-fx` 规则的唯一来源(`init-project` 写入 tokens.css、`preview-page` 兜底注入共用一份,避免 CSS 漂移)。 - `fx-spotlight` 的关键帧补 `opacity: 1` —— 它是本技能文档里列为可用的入场类,但只做 `clip-path`,基础态 `opacity:0` 抬不回来 → 用了就永久隐形(被 `check-slides` 的 5b 项拦住,即「文档说能用、闸门说不能用」)。 - SKILL.md 的 Gate 4 增加「放映页交用户自己放一遍」;`references/render.md` 增放映页章节(定位、键位、口播三态、为什么必须用副本);`authoring.md` 的动效开关一节写明交付前用 `X` 对照验收。 -- 测试 +27 例(共 99):`preview-page`(注入/合并/no-fx/base 顺序/自包含无外链/**不做计时器**/**不得出现「配音」字样**/响应式与触摸、口播三态、等间隔兜底、越界拒绝/幂等升级)、`tokens-fx`(对模板断言**每个非无限 fx 动画的关键帧都声明 opacity**、no-fx 规则含 opacity 重置、`--upgrade-css` 幂等且不碰其他文件)。 +- 测试 +27 例(共 99):`preview-page`(注入/合并/no-fx/base 顺序/自包含无外链/**不做计时器**/**不得出现「配音」字样**/响应式与触摸、口播三态、等间隔兜底、越界拒绝/幂等升级)、`tokens-fx`(对模板断言**每个非无限 fx 动画的关键帧都声明 opacity**、no-fx 规则含 opacity 重置、`--upgrade-css` 幂等且不碰其他文件)。- `fetch-official-images.mjs` 新增 `--url <图片URL>[,...]`:内置浏览器 inspect 官网 DOM 拿到的**零散图片 URL** 现在有落盘入口(此前文档只写「再下载」却没给手段,而脚本只能"页面 URL + 序号")。走同一套纪律(host 白名单、重定向逐跳复核、30MB 上限、文件名清洗、已存在不覆盖),**且不需要 Playwright** —— 站点要登录/滚动加载、脚本打不开时这是唯一落盘手段。文件名推导(`imageNameFromUrl`:取路径末段,扩展名优先用 URL 的、否则按 content-type,都不行给 `.bin`)与 `sanitizeFilename` 同住 `url-policy.mjs`;新增 8 例测试(4 例文件名推导 + 4 例 CLI 拒绝/用法)。 + **文档** - 开工对齐"主题与受众"→"**主题与领域**"(受监管题材必问免责声明与数据出处标注);新增 `references/compliance.md`(财经口播红线、数字三要件、涨跌色按受众翻转、免责声明写法、医疗/法律/广告法、Gate 清单);`tokens.css` 增 `--up/--down` 与 `.disclaimer`;`check-slides.mjs` 增整片级财经关键词自查。 diff --git a/plugins/Wzdhehe/html2video-for-mcode/README.md b/plugins/Wzdhehe/html2video-for-mcode/README.md index 8941af7a..c8306ad8 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/README.md +++ b/plugins/Wzdhehe/html2video-for-mcode/README.md @@ -160,7 +160,8 @@ Nothing is contacted unless you invoke the step that needs it: rejected before the request is made, unless you explicitly pass `--allow-any-endpoint` (self-hosted gateway / testing, at your own risk). - `scripts/fetch-official-images.mjs` — opens the URL **you** pass (an official site, or a local - `file://` page with `--allow-file`) to list and download candidate images. Targets are checked + `file://` page with `--allow-file`) to list and download candidate images, or downloads the exact + image URLs you hand it with `--url` (what the in-app browser route uses; no Playwright needed). Targets are checked before any request: loopback, link-local (including cloud metadata `169.254.169.254`), private and CGNAT ranges, dotless hostnames, URLs with embedded credentials, and non-HTTP(S) schemes are all refused, every redirect hop is re-checked the same way, and responses are size-capped @@ -197,12 +198,14 @@ The Skill ships an executable test suite (`skills/html2video-for-mcode/tests/`, node --test "plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/*.test.mjs" ``` -99 tests in seven files: `safe-paths` (malicious slide ids / paths, canary intactness, symlink +107 tests in seven files: `safe-paths` (malicious slide ids / paths, canary intactness, symlink escapes), `no-clobber` (refusing to overwrite), `endpoint-allowlist` (key never leaves the official hosts — plus a local server that proves the gate sits before the request), `fetch-policy` (SSRF, `file://`, redirect and filename rules), `preview-page` (snapshot timing injection, `base` ordering, -self-containment, containment refusals) and `tokens-fx` (every entrance animation in the generated -`tokens.css` must declare `opacity`, `no-fx` must reset it, `--upgrade-css` is idempotent), plus +self-containment, containment refusals, the no-timer rule), `tokens-fx` (every entrance animation in +the generated `tokens.css` must declare `opacity`, `no-fx` must reset it, `--upgrade-css` is +idempotent) and `fetch-policy`/`no-clobber` (the `--url` download route obeys the same SSRF, +redirect, size and no-clobber rules), plus `render-smoke` (init → timings → static gate → capture → build, end to end). The render smoke test and three ffmpeg-dependent path checks need ffmpeg and Chromium; where those are missing they skip with a stated reason, and the scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml` installs them and runs everything for real. diff --git a/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md b/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md index 87861d2d..d55a3f09 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md +++ b/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md @@ -117,7 +117,7 @@ Windows / macOS / Linux。脚本全部是 Node ESM,不依赖特定 shell。Windo **只有你主动执行的那一步才会联网**: - `scripts/asr.mjs` —— HTTPS `POST` 到 `https://api.minimaxi.com/v1/speech_to_text`(海外套餐设 `MINIMAX_REGION=global` 时走 `https://api.minimax.io`)。仅在你运行时。**API Key 只发这两个官方域**:其他 `--base-url` / `MINIMAX_BASE_URL` 取值会在发起请求之前被拒绝,确需自定义网关要显式加 `--allow-any-endpoint`(自担风险)。 -- `scripts/fetch-official-images.mjs` —— 打开**你传入的**网址(官方网站,或加 `--allow-file` 的本地 `file://` 页面)以列出并下载候选配图。**请求之前先过目标校验**:loopback、链路本地(含云元数据 `169.254.169.254`)、私网与 CGNAT 段、无点主机名、带内嵌凭据的 URL、非 HTTP(S) 协议一律拒绝;重定向**逐跳**复用同一策略;响应大小有上限(默认 30MB,`--max-mb` 可调)。 +- `scripts/fetch-official-images.mjs` —— 打开**你传入的**网址(官方网站,或加 `--allow-file` 的本地 `file://` 页面)以列出并下载候选配图;也可以 `--url <图片URL>` 直接下载你指定的图片(内置浏览器取图那条路走的就是它,不需要 Playwright)。**请求之前先过目标校验**:loopback、链路本地(含云元数据 `169.254.169.254`)、私网与 CGNAT 段、无点主机名、带内嵌凭据的 URL、非 HTTP(S) 协议一律拒绝;重定向**逐跳**复用同一策略;响应大小有上限(默认 30MB,`--max-mb` 可调)。 - 配音合成经由 mcode connector 或 `mmx-cli`,它们会访问 MiniMax。 - 其余全部离线:对时、静态检查、截图、编码、主题对比度校验。 @@ -139,7 +139,7 @@ Windows / macOS / Linux。脚本全部是 Node ESM,不依赖特定 shell。Windo node --test "plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/*.test.mjs" ``` -七个文件共 99 例:`safe-paths`(恶意 slide id / 路径、canary 完好性、符号链接逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(Key 不离开官方域,并用本地服务器证明闸门在请求之前)、`fetch-policy`(SSRF、`file://`、重定向与文件名规则)、`preview-page`(快照注入实测延迟、`base` 顺序、自包含无外链、越界拒绝)、`tokens-fx`(模板里每个入场动画的关键帧必须声明 `opacity`、`no-fx` 必须重置基础态、`--upgrade-css` 幂等)与 `render-smoke`(init → 对时 → 静态闸门 → 截图 → 成片全链)。渲染冒烟与三例依赖 ffmpeg 的路径检查需要 ffmpeg 与 Chromium:缺失时按原因 skip,scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml` 会装齐依赖并把全部用例真跑一遍。 +七个文件共 107 例:`safe-paths`(恶意 slide id / 路径、canary 完好性、符号链接逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(Key 不离开官方域,并用本地服务器证明闸门在请求之前)、`fetch-policy`(SSRF、`file://`、重定向与文件名规则)、`preview-page`(快照注入实测延迟、`base` 顺序、自包含无外链、越界拒绝、无计时器)、`tokens-fx`(模板里每个入场动画的关键帧必须声明 `opacity`、`no-fx` 必须重置基础态、`--upgrade-css` 幂等)与 `render-smoke`(init → 对时 → 静态闸门 → 截图 → 成片全链)。渲染冒烟与三例依赖 ffmpeg 的路径检查需要 ffmpeg 与 Chromium:缺失时按原因 skip,scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml` 会装齐依赖并把全部用例真跑一遍。 ## 排错 diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md index cc640081..c9f72725 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md @@ -1,267 +1,267 @@ ---- -name: html2video-for-mcode -description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻灯片 + TTS + ffmpeg 渲染),为 mcode 环境定制、也可在其他 Agent 环境用 mmx-cli 跑通。当用户想把内容做成视频、html 转 mp4、幻灯片口播视频、slides video、narrated video、一分钟介绍视频、抖音/视频号竖版视频时触发;**只想先看看做好的 HTML 幻灯片、想自己放映一遍或预览动效(口播还没做也行)时同样触发**——技能会生成自包含放映页,不必先花几分钟渲染成片。财经/财报/投研/医疗/政策等**受监管题材**的视频同样触发(需要免责声明与数据口径处理)。也当症状出现时触发——TTS 念完留白过长、画面只有标题没有细节展开、部分元素 0 秒就入场、入场动画没渲染进视频、音画不同步、图片主体被裁到画面外、字幕糊在深色背景上、拼接后总时长不对、字体闪烁或方框、图表或某个元素入场后永远不出现(关键帧没把 opacity 抬回来)、切了 no-fx 画面反而更空、双击 slides/*.html 看动画发现全挤在开头、财经片忘了写免责声明或涨跌色用反。覆盖全流程:开工对齐(领域与免责/风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 SOP → HTML 分步入场 → 静态闸门与放映页自检 → 逐帧渲染 → ASR 反向校验。工具分两套:mcode 沙箱用 connector__matrix__*(TTS/ASR/音乐),其他环境用 mmx-cli(注意:mmx-cli 无 ASR 与音乐生成,见文末环境对照表)。 ---- - -# HTML 2 Video for mcode:脚本 → 口播成片 - -把一个主题/大纲变成一条可发布的 MP4(默认 1920×1080,可切 1080×1920 竖版):HTML 幻灯片(分步入场动画)+ 中文 TTS 口播 + ffmpeg 组装 + ASR 反向校验。 - -## 四条铁律(违反任何一条,产出必然返工) - -1. **所有时长只来自 ffprobe 实测,永远不手写。** 每张 slide 的时长 = 该段 TTS 实测时长 + 尾部留白(默认 0.8s)。不估、不凑整、不写死。 -2. **TTS 先于 HTML。** 先出音频、实测时长、算好每个视觉层的入场时刻,再写 HTML。动画延迟全部通过 CSS 变量 `--t1/--t2/--t3` 注入,HTML 里不写死秒数。这是消灭"念完留白过长"和"音画不同步"的根本手段。 -3. **每个 Gate 等用户确认,不跳步。** Gate 清单见下文工作流。 -4. **开工先对齐,不要闷头开跑。** 第一次响应就把下面这批问题一次问清(用户没答的项可用默认值,但**必须先问**),答案即后续所有 Gate 的验收基线: - -| 要问的 | 选项 / 默认 | -|---|---| -| **主题与领域** | 题材 + 给谁看 + **属于哪个领域**(财经/商业 · 科技/产品 · 科普/教育 · 品牌/营销 · 文化/历史 · 生活/消费 · 政务/政策),领域决定版式与信息密度;**落在受监管领域(财经投研 / 医疗健康 / 法律 / 政务政策 / 营销效果宣称)必须再问一句"要不要免责声明与数据出处标注"**(默认要)。这一问定三件事:结尾要不要 `.disclaimer` 行、涨跌色要不要按受众翻转、数字要不要带"截至"日期 —— 详见 `references/compliance.md` | -| **语言(必问)** | 口播说什么语言:**中文普通话 `zh`**(默认)/ **英语 `en`** / 粤语 `yue` / 其他 BCP-47。这项决定 4 件事:① 口播稿用哪种语言写;② **音色必须匹配语种**(中文用 `Chinese (Mandarin)_*`、英语用 `English_*`,错配会出怪腔调,写完必须 ASR 验语种);③ 字数/语速基准(中文字/秒 vs 英文词/秒,plan-timings 自动切换);④ ASR 识别语言头(zh 强制普通话,能识破粤语) | -| **风格与配色** | 先按受众给 2–3 个候选主题让用户挑(商务 `minimal-white`/`swiss-grid`/`corporate-clean`;编辑杂志 `editorial-serif`/`magazine-bold`;科技深色 `tokyo-night`/`catppuccin-mocha`/`nord`;消费生活 `xiaohongshu-white`/`soft-pastel`)。**再问一句配色偏好**:直接用主题自带主色,还是有品牌色要指定(给了就按 authoring.md 的"自定义主色"覆写 `--accent` 并跑 check-theme 验对比度) | -| **字幕** | 不要字幕 / 单语(**与口播同语言**,默认)/ **双语**(主行=口播语言,次行 `text2` = 另一语言,如中文口播配英文字幕)——决定要不要写 text2、要不要 `--no-subs` | -| **画布与平台** | 横屏 1920×1080(默认,适合 B站/官网)或竖版 1080×1920(抖音/视频号/小红书);竖版要换堆叠版式 | -| 时长 | 默认 ~60s(6–10s/张 × 8 张);短视频平台可压到 30s | -| 音色 | 温润男声 / 干练女声 / 其他;给出 3 个候选试听(见 tts-and-timing.md) | -| 素材边界 | 允许网络取官方图 / 只用官方素材 / 纯排版不出图 | - -## 目录与工具 - -技能自带 **11 个命令行脚本 + 3 个内部模块**(直接以本技能目录为路径调用,项目目录作为参数,无需复制;`tests/` 下还有一套 node:test 安全与冒烟测试,从仓库根 `node --test` 自动发现): - -| 脚本 | 作用 | -|---|---| -| `scripts/init-project.mjs <项目目录> [--force] [--upgrade-css]` | 生成项目骨架:目录 + tokens.css + slide 模板 + script.json 契约。**目标目录非空时拒绝执行**(会重置 5 个生成文件),要重新初始化必须显式 `--force`;`--upgrade-css` 只给老项目的 tokens.css 补新版 no-fx 规则(幂等) | -| `scripts/plan-timings.mjs <项目目录>` | ffprobe 实测每段 TTS → 每张时长、各 stage 入场时刻、**每句 clauses 时刻** → `build/timings.json` | -| `scripts/check-timing.mjs <项目目录> [--calibrate]` | 静音检测实测每句真实开口, 与估算对比;`--calibrate` 按实测校准 timings 后重渲染 | -| `scripts/check-theme.mjs <项目目录>` | 校验全部主题的 WCAG 对比度(正文/次级/字幕/accent-ink), 不达标退出码 1;新增主题必须过闸 | -| `scripts/check-slides.mjs <项目目录> [--quiet]` | **渲染前静态闸门**:未定义 CSS 变量、缺图/外链资源、`data-stage` 没配 fx 类、fx 关键帧不含 opacity(会永久隐形)、硬编码颜色、整片级领域自查。有 ✗ 就别截图 | -| `scripts/prep-image.mjs --check <图...>` / `--crop [--ratio 16:9] [--anchor ...] [--force]` | 配图 SOP 的执行辅助:查尺寸与裁切风险;按锚点裁切(强制"裁掉 ≤20%、不放大补边") | -| `scripts/fetch-official-images.mjs <页面URL> [--out-dir <目录>] [--allow-file] [--max-mb N] [--force]` | 从官方页/本地页面列出并下载候选配图。内网与元数据地址一律拒绝, 每跳重定向复核, 默认写在工作目录内 | -| `scripts/capture.mjs <项目目录> [--mode still\|motion] [--no-subs]` | Playwright 截图。still=终态单帧;motion=逐帧步进入场动画。**字幕默认烧录**(内容取自 clauses),`--no-subs` 关闭 | -| `scripts/preview-page.mjs <项目目录> [--open] [--no-script]` | 生成**放映页** `preview/play/index.html`(单文件、零依赖、file:// 双击即看):←→ 或触屏左右滑翻页、R 重播动画、X 动效/关动效对照、P 口播文案开/关、O 总览、F 全屏。**只做"放画面"这件事**:没有计时器/进度条/跟读高亮(要看时间就看成片)。快照按 `timings.json` **注入实测延迟**,所以浏览器里的动画时序 = 成片时序;还没对时则按等间隔预览并如实标注。口播面板按数据自动决定加不加载,窄窗口/手机上收成底部抽屉且默认收起 | -| `scripts/build-video.mjs <项目目录> [--asr] [--dry-run]` | 编码每张 → 拼接 → 音轨对位 → 合成 → 自检 + 出 `out/subs.srt`;`--asr` **按句**切分音频 + 校验清单;`--dry-run` 只打印将要执行的 ffmpeg 命令(排错用) | -| `scripts/asr.mjs <项目目录> [--api-key K] [--verify-timing] [--from <转写>] [--allow-any-endpoint]` | 调 ASR 转写并按句校验音画是否念的是脚本(数字/繁体字不符判 ✗);`--verify-timing` 用字级时间戳实测句开口。Key 只发官方域 | - -内部模块(被上面的脚本 import, 不单独运行):`tools.mjs`(ffmpeg/ffprobe 探测 + 路径收监 `safeId/safeRel/inside`)、`url-policy.mjs`(ASR 端点白名单 + SSRF/重定向策略)、`nofx-css.mjs`(`no-fx` 规则的唯一来源,init-project 写入 / preview-page 兜底注入共用)。 - -环境要求:Node 18+(脚本用 fileURLToPath 保兼容, 不依赖 Node 20.11 的 import.meta.dirname)、`npm i playwright && npx playwright install chromium`(项目目录内)。ffmpeg/ffprobe 自动探测:PATH → node_modules(ffmpeg-static/ffprobe-static)→ 常见安装位置,找不到会给逐条诊断而不是莫名报错。 - -## 安装到其他 Agent 环境 - -本技能就是「一个文件夹 + 根目录 SKILL.md」的标准形态(frontmatter 的 `name` / `description` 已按规范写好),放到对应技能目录即可被识别: - -```bash -# Claude Code(个人级)/ OpenClaw / 其他兼容 AgentSkills 的工具 -cp -r html2video-for-mcode ~/.claude/skills/ # 或 ~/.openclaw/skills/ -# 项目级安装 -cp -r html2video-for-mcode <你的项目>/.claude/skills/ -# 或者把它推到一个 git 仓库后走 skills CLI -npx skills add --skill html2video-for-mcode -``` - -装完只需再补两件**依赖**(装在你的视频项目里,不是技能目录里): - -```bash -cd <你的视频项目目录> -npm i playwright && npx playwright install chromium # 截图用 -# ffmpeg 任选其一: winget install Gyan.FFmpeg / brew install ffmpeg / apt install ffmpeg -# 或: npm i ffmpeg-static ffprobe-static -``` - -**为什么技能装在别处也能跑**:`scripts/tools.mjs` 按「技能自身位置 → 项目目录 → 调用时的工作目录 → npm 全局」逐个锚点解析 `playwright`;ffmpeg/ffprobe 也是四级探测(PATH → 项目 node_modules → 技能上两级 → 常见安装位置)。所以"技能在 `~/.claude/skills/`、依赖装在项目里"是受支持的用法(已实测:技能放到项目树之外仍能出图)。 - -## 运行环境:两套工具链(同一套脚本,只换工具源) - -脚本层(截图 / 渲染 / 合成 / 校验 / 配图)完全环境无关;**只有 TTS、音乐、ASR 三件事依赖平台能力**。 - -| 能力 | mcode 沙箱(首选) | 其他 Agent 环境(Claude Code / OpenClaw / Cursor 等) | -|---|---|---| -| TTS 合成 | `mcode-tools connector call connector__matrix__batch_text_to_audio --args '{...}'`(≤10 条/批,主用);单条试音用 `connector__matrix__synthesize_speech` | `mmx speech synthesize --text "第一句口播。" --voice --speed 1.0 --out audio/01.mp3`;音色列表 `mmx speech voices` | -| 结果落盘 | `get_asset_url ` → 下载到 `audio/.mp3` | `--out` 直接写盘 | -| BGM 音乐 | `connector__matrix__batch_text_to_music`(≤5 条/批) | ⚠ **mmx-cli 无音乐生成** → 让用户提供音乐文件(确认授权后登记 MANIFEST),或跳过 BGM | -| ASR 反向校验 | `mcode-tools upload_temp_url` + `connector__matrix__listen_audio` | **`node scripts/asr.mjs <项目目录>`** —— 用同一把 API Key 直调 REST(`/v1/speech_to_text`),不依赖 mcode、也不用装 whisper;**会自动与 checklist 的预期文本比对并回填,数字/繁体字(粤语)不符直接判 ✗**。想用字级时间戳实测句开口:`--verify-timing` | -| 素材配图 | 内置浏览器 inspect 官网 DOM(首选)/ 官方 brand kit | 同上;抽象配图可用 `mmx image generate --prompt "..." --aspect-ratio 16:9 --n 3`(**仅限抽象概念图,禁止生成 logo / 截图 / 真人头像**),再按 `image-sources.md` 登记 | -| 调研 | `web_search` / `web_fetch` | `mmx search "关键词"` / `mmx text chat` | - -**mmx-cli 首次配置**(非 mcode 环境):`npm install -g mmx-cli` → `mmx auth login --api-key sk-xxx` → `mmx quota` 验证。401 多半是 region 不匹配:`mmx config set --key region --value cn|global`。脚本侧用 `MINIMAX_API_KEY`(必给)与 `MINIMAX_REGION=cn|global`(可选)对齐同一套身份。 - -**纪律不因环境而变**:时长仍由 ffprobe 实测、字幕仍来自 `clauses[]`、音色仍要试听并验语种(走上面的 asr.mjs)、Gate 一个都不跳。 - -## 工作流(7 阶段 · 6 Gate) - -``` -开工对齐(铁律 4, 不设 Gate 但必须先做) -Phase 0 信息搜集 → Gate 0 事实清单 -Phase 1 脚本设计 → Gate 1 逐张口播稿 -Phase 2 TTS + 对时 → Gate 2 试听 + 时长表 -Phase 3 素材收集 → Gate 3 素材清单 + 预览 + 合规确认 -Phase 4 HTML → Gate 4 终态截图 -Phase 5 渲染 + ASR → Gate 5 成片 -Phase 6 交付 -``` - -**顺序是脚手架,不是建议。** 口播稿定了才做 TTS;TTS 时长实测了才动素材和 HTML;素材清单过了 Gate 3 才写进页面;HTML 过了终态截图才渲染。改了口播稿 = 从 Phase 2 重跑(TTS 便宜,重做不贵;带着旧时长硬改才是灾难)。每个 Gate 向用户呈现"验收物清单"里明确的东西,没收到 OK 绝不前进——即使看起来显然,也要确认。 - -### Phase 0 · 信息搜集(条件执行) - -**完整方法见 `references/research.md`**(来源分级、query 设计、矛盾处理、notes 模板)。要点: - -- 判断标准很简单:成片里会出现具体**数字、日期、名称、引语或归属关系** → 必须搜集。通用/抒情/创意题材可跳过,但"看起来像事实"的句子仍要核实。 -- **四条硬规则**:① 关键数字**至少 2 个独立来源**(只有一个就用限定措辞或降级为约数);② **一手优先**(官方公告/财报/技术报告/政府统计),二手转述要回溯原文;③ 标注**口径与日期**(年化还是单季?周活还是月活?币种?);④ **查不到出处或无法判定的,进"不确定项",绝不进口播稿**。 -- **正文怎么取**:先 `web_fetch`;SPA/JS 渲染的官网与投资者关系页常只返回空壳,改用**内置浏览器或 Playwright 渲染后取正文**(最小命令见 references/research.md);招股书/财报是 PDF,以 PDF 原文数字为准。新闻稿与媒体转述冲突时以官方原文为准。 -- **受监管领域**(财经/医疗/法律/政策/营销宣称 → 读 `references/compliance.md`)另加三道约束:① 财经数字必须带**口径 + 币种 + 时点**("二季度营收"而不是"目前营收");② 口播**不给操作建议、不预测价格、不用绝对化用语**;③ 免责声明与数据出处(要不要、怎么写、放哪)在 Gate 0 就跟用户敲定,不留到成片阶段返工。 -- 输出 `research/notes.md`:每条含 来源 URL + 口径日期 + 等级 + 第二来源;另列"不确定项"与"不该进脚本的内容";受监管题材再记一行**领域 + 免责口径的确认结果**。 -- **Gate 0**:事实清单给用户过 —— 重点让用户确认**数字、名称与口径**;受监管题材把免责声明、数据时点、涨跌色一起确认掉。 - -### Phase 1 · 脚本设计(内容量在这里控制) - -在 `script.json` 里逐张填写(契约文件,后续所有脚本都读它): - -```json -{ - "topic": "OpenAI 一分钟", "voice": "Chinese (Mandarin)_Gentleman", - "speed": {"default": 1.0, "first": 0.95, "last": 0.95}, - "fps": 30, "width": 1920, "height": 1080, - "bgm": "assets/bgm.mp3", - "slides": [ - {"id": "03", "layout": "bullets", "html": "03-lines.html", "audio": "03.mp3", - "title": "三大产品线", - "clauses": [ - {"stage": 1, "text": "三大产品线。"}, - {"stage": 2, "text": "ChatGPT 对话,GPT 系列模型,Sora 视频生成。", - "text2": "Chat, models, video generation."} - ]} - ] -} -``` - -可选字段:`clauses[].text2` = 双语字幕第二行(不写则纯中文字幕);顶层 `bgm` = `"assets/bgm.mp3"` 或 `{file, volume:0.12, fadeIn:1.5, fadeOut:2.5}`(写了由 build-video 自动循环+淡入淡出垫底)。 - -**内容量硬规则**(详细版见 `references/authoring.md`): - -- 中文口播 ≈ 4.8 字/秒。每张目标 6–10 秒 → 口播 25–48 字;首尾张 12–20 字。单张硬上限 60 字,超了拆两张。 -- **每张(除首尾)必须至少两个信息层,映射到不同 stage —— 但层的先后顺序自由**(大数字可以先入、标题后出;设问先出、答案再出;由强同步原则决定:层挂在提到它的那句口播的 stage)。只有标题、没有展开 = 违规,打回。口播也一样:每张至少两句,分别推动不同的层。 -- `clauses` 的每个元素是一句口播,`stage` 声明"这句开口时,哪个视觉层该出现"。stage 数 ≈ clause 数,一一对应。 -- 版式共 17 种(原 8 + 补充 9:kpi-grid / stat-highlight / table / timeline / roadmap / comparison / flow-diagram / terminal / big-quote),每种画面必含项与字数区间见 `references/authoring.md` 的版式表;**图表(条形/柱状/环形/折线/进度)有纯 CSS/SVG 画法速查,不用外链图表库**。 -- 开工对齐时若用户要**双语字幕**,这里就要给每句写 `text2`(同句翻译,不重排语序,≤60 字符)。 - -**Gate 1**:逐张口播稿 + 版式分配给用户过。**未逐张 OK 不进 Phase 2。** - -### Phase 2 · TTS + 实测对时 - -TTS 走哪套工具见上文"运行环境"对照表(mcode 用 `connector__matrix__*`,其他环境用 `mmx speech synthesize`);命令模板与重试纪律见 `references/tts-and-timing.md`。要点:批量 ≤10 条,部分失败 sleep 10–30s 后**只重试失败项**;首尾张 speed 0.95(但 ≤10 字的短句保持 1.0,见该文件实测坑)。产物落 `audio/.mp3`。 - -然后对时: - -```bash -node <技能目录>/scripts/plan-timings.mjs <项目目录> -``` - -它会:ffprobe 每段实测时长 → 每张时长 = 实测 + 尾部留白(默认 0.8s,可在 slide 的 `tail` 字段调:紧凑 0.4 / 舒缓 1.2)→ 每个 stage 的入场时刻 = 该句口播按字数占比估算的开口时刻 − 0.2s(视觉略提前于语音,观感同步)→ 每句的开口时刻/时长写入 `clauses[]`(字幕、ASR 按句切分、对时校准共用)→ 写 `build/timings.json`,并输出警告(语速异常、超 15s、末 stage 离收尾太近、单句超 18 字字幕会换行)。 - -可选但推荐(尤其用户反馈过"音画不同步"时):`node <技能目录>/scripts/check-timing.mjs <项目目录>` 用静音检测实测每句真实开口,输出"估算 vs 实测"对比表;偏差大就 `--calibrate` 校准后删 `build/frames/` 重渲染。注意"体感不同步"也常是设计错位——大数字/主体图必须挂在**提到它的那句**的 stage(强同步原则,见 authoring.md)。 - -**Gate 2**:8 段音频试听 + plan-timings 的时长表给用户过。 - -### Phase 3 · 素材收集(合规在这里把关) - -按 `references/image-sources.md` 的配图 SOP 执行(核心原则:**主体不全/半截/比例差的图,先重搜全貌图,别硬裁硬用**),要点: - -1. 列需求清单(哪几张要图、要什么)。 -2. **优先级:官方渠道(brand kit / simple-icons / 官方文档截图)→ 内置浏览器或 Playwright 打开官网 inspect DOM 取官方资源(实测成功率最高)→ Wikipedia CC → 纯排版降级(数字卡 + 大字 + 引言,不用图)。** 旧 image-downloader(Bing)默认不用:中文冷门题材实测 5/5 返回无关图。 -3. 搜图加正向词(全景/全貌/正面/远景),下载前用缩略图筛:主体居中、比例接近、无水印无无关 logo、≥1200px;一张不合适就换,不凑合。 -4. 拿到图先跑 `node <技能>/scripts/prep-image.mjs --check <图>` 看尺寸与裁切风险;主体贴边就换图。 -5. 每张素材查合规:无水印、商标仅限合理引用语境、照片须可授权来源、截图引用官方文档并注明。 -6. 落盘 `assets/`,每个素材一行登记 `assets/MANIFEST.md`(内容/来源/许可)。 -7. **禁止凭空生成 logo、截图、头像、二维码;禁止带水印图直接入素材;禁止裸放 ``(必须套 `.img-frame`)。** - -**Gate 3**:素材清单表 + 每张素材的预览(缩略图/说明)给用户过,明确问一句"素材来源与授权没问题吗"。**用户过完才写 HTML。** - -### Phase 4 · HTML(分步入场在这里实现) - -每张一个文件,放 `slides/`,文件名与 script.json 的 `html` 字段一致。约定: - -- 根元素 `` 选主题(初版 `a|b|c`,另有 `minimal-white / swiss-grid / corporate-clean / editorial-serif / magazine-bold / tokyo-night / catppuccin-mocha / nord / xiaohongshu-white / soft-pastel`;选主题速查与对比度校验见 authoring.md);引 `tokens.css`;画面容器 `.stage`,含 `.brand` 角标与 `.slide-num` 页码。 -- **每个要入场的块加 `data-stage="1|2|3"` + 一个 fx 工具类**(`fx-up/fx-fade/fx-grow/fx-blur/fx-rise/fx-pop/fx-spotlight/fx-ripple/fx-glitch/fx-draw`;氛围类 `fx-pulse/fx-shimmer/fx-kenburns` 不加 data-stage);延迟不用写——管线按 `timings.json` 注入 `--t1/--t2/--t3`。同层错峰用容器 `.fx-stagger`(基准 `style="--stagger-base:var(--t3)"`)或内联 `style="animation-delay:calc(var(--t2) + 150ms)"`。 -- ⚠ **`data-stage` 必须与 fx 类同时用**(只有属性没有动画类会永远停在 opacity:0);延迟实现见 authoring.md 的"stage 延迟的实现原理",改动画时不要手写 `animation-delay: var(--tN)`。 -- 图片一律套 `.img-frame`(`.contain` 给截图/图表;`--img-ratio` 定比例;`--img-pos` 保主体;图注 `.img-cap` 写在框外)。 -- 氛围动画(无限循环的呼吸/漂浮)允许,但不能承载信息、不加 `data-stage`。 -- **动效可一键关**:`` 关整个项目,任何容器加 `no-fx` 关单张,单个元素不给 fx 类即静态。关掉后 motion 捕获自动退化为静态帧出片,时长与音画同步不受影响,字幕照常(详见 authoring.md"动效开关")。 -- 禁用 transition 做入场(截图管线 seek 不到),只用 `@keyframes`。禁外部 Google Fonts(离线不稳),用系统字体栈(tokens.css 已配 CJK fallback)。 -- 素材只用 Gate 3 已确认的 `assets/` 清单,不新增未审素材。 -- ⚠ **写完 8 张后先跑静态检查再截图**:`node <技能>/scripts/check-slides.mjs <项目目录>` —— 抓未定义 CSS 变量(会导致文字隐形)、图片缺失/外链资源、data-stage 没配 fx 类、硬编码颜色。有 ✗ 就别截图,画面对但"看不见"是最难查的。 - -写完终态预览(最快路径,给 Gate 4 看): - -```bash -node <技能目录>/scripts/capture.mjs <项目目录> --mode still -node <技能目录>/scripts/preview-page.mjs <项目目录> --open # 放映页: 让用户自己过一遍动效 -``` - -**Gate 4**:`preview/.png` 逐张给用户过(看版式、字压、素材)+ **放映页 `preview/play/index.html` 交用户自己放一遍**(看动效是不是真的都出现了、快慢能不能接受、这张的画面撑不撑得住)。静态图看不出动画——"元素永远不出现""关动效反而空白""列高塌陷"这类故障恰恰只在动起来或切对照时才露头;放映页是 30 秒的自检手段,不必等 3–6 分钟的 motion 编码。时序与口播对轴不在这一步判(放映页刻意不做计时器),以成片和 `timings.json` 为准。**用户过完再进 Phase 5。** - -### Phase 5 · 渲染 + ASR 校验 - -```bash -node <技能目录>/scripts/capture.mjs <项目目录> --mode motion # 入场动画逐帧进视频 -node <技能目录>/scripts/build-video.mjs <项目目录> --asr -``` - -- `--mode motion`:逐帧步进(暂停全部动画 → 逐帧 seek → 截图 → 编码),动画窗口逐帧渲染、静止段自动补尾帧,时长精确。**字幕默认烧录**(内容取自 clauses、显示窗=该句开口到下句开口,画面底部居中,still 预览里不显示、成片里才有;`--no-subs` 关闭)。成本约 50–200ms/帧,8 张 × 30fps 约 3–6 分钟,预算进超时。赶时间可用 still 模式出片(动画不进视频,只有淡入淡出)。 -- build-video 自动:每张编码(统一参数)→ concat 拼接(时长漂移自动回退重编码)→ 音轨按每张实测时长 `apad` 对位 → **BGM 垫底(配了 bgm 才走:循环补满、淡入淡出、人声优先;混音失败自动退回纯人声)** → mux → ffprobe 时长校验 + 全量解码自检,不过关退出码非 0;同时输出 `out/subs.srt`(与烧录字幕同源同窗,中英双语按 clauses 的 text2 自动两行,供平台上传)。 -- `--asr`:**按句**切出 `asr/part--.mp3` + 生成 `asr/checklist.md`。转写与预期文本比对:数字、年份、产品名必须一致;同音字可容忍。**若某段转写混入上一句的开头,说明那句实际开口比估算晚——跑 check-timing 校准。** - - mcode:`mcode-tools upload_temp_url` 上传后交 `connector__matrix__listen_audio`。 - - **其他环境**:`MINIMAX_API_KEY=sk-xxx node scripts/asr.mjs <项目目录>` —— 直调 REST(同一把 Key),自动比对并回填 checklist;失败项(数字不符/繁体字)会以非 0 退出码报出。想拿更准的开口时刻:`--verify-timing`。 - - 不过关的 slide:改口播或重做该段 TTS → 重跑 plan-timings → 该张重渲染(帧目录删掉对应张即可)。 - -**Gate 5**:成片 `out/final.mp4` + ASR 校验表给用户过,含字幕可读性检查(静音播放一遍,字幕能否撑起理解)与 BGM 电平(人声是否始终清晰)。 - -### Phase 6 · 交付 - -``` -out/final.mp4 # 主交付 -out/slide-*.mp4 # 单段(可单独发布) -build/audio-timeline.wav # 对位后音轨 -preview/*.png preview/play/index.html slides/*.html slides/tokens.css -audio/*.mp3 assets/(含 MANIFEST.md) research/notes.md asr/(校验记录) -``` - -## 参考文件(按需读,别全读) - -- `references/authoring.md` — 17 种版式规范(每种画面必含项)+ 内容量表 + 入场系统用法(含 stage 延迟实现原理)+ 主题速查 + 竖版说明 + 可抄的 HTML 片段 -- `references/compliance.md` — 领域与合规(受监管题材必读):领域确认问法、财经口播红线、数字三要件、涨跌色按受众翻转、免责声明写法与位置、医疗/法律/广告法要点、收尾检查清单 -- `references/research.md` — 资料搜集(来源分级、交叉验证硬规则、query 设计、矛盾处理、notes 模板) -- `references/image-sources.md` — 配图与素材 SOP(三条取图路径、query 正/反词、两级筛选、图片框用法、裁切硬限制、视觉验证三件套、常见题材索引) -- `references/tts-and-timing.md` — mcode TTS connector 命令、重试纪律、对时算法、留白与语速调校、实测音色表 -- `references/render.md` — 渲染原理(为什么逐帧步进、字体怎么等)、字幕系统、BGM 混音、ffmpeg 手工命令、排错表 -- `THIRD-PARTY-NOTICES.md` — 第三方组件许可声明(10 套主题与部分 CSS 原语改编自 html-ppt-skill,MIT) - -## 常见症状 → 一句话诊断 - -| 症状 | 根因 | 动作 | -|---|---|---| -| TTS 念完画面还停很久 | 时长是估的不是实测,或 tail 过大 | 重跑 plan-timings;调该张 `tail` | -| 画面只有标题没有展开 | 违反内容量硬规则 | 补展开层 + 对应 clause,回 Phase 1 | -| 动画没进视频 | 用了 still 模式 | motion 模式重出 | -| 音画不同步(体感) | 三选一:手写了动画延迟 / 估算偏差 / 视觉锚点挂错句(强同步原则) | 先删手写延迟;跑 check-timing 拿实测数据,偏差大就 --calibrate;锚点错句则调 stage 映射 | -| 音色语种不对(粤语/繁体) | 平台 voice 标签错位,名称不可信 | Phase 1 试听必须带 ASR 验音(见 tts-and-timing.md 实测音色表) | -| 成片没字幕 | 用了 --no-subs 或 clauses 缺失 | capture 默认烧录;确认 timings.json 有 clauses | -| 要双语字幕 | — | clause 加 `text2`,画面两行 + SRT 双行自动出;第二行建议 ≤60 字符 | -| 想要背景音乐 | — | 顶层 `bgm` 配置,build-video 自动循环+淡入淡出垫底(默认音量 0.12);ASR 校验仍走纯人声轨 | -| 赶时间 / 题材要克制,不要动画 | — | `` 一键关全部动效,或单张容器加 `no-fx`;出片自动走静态帧,时长不变(见 authoring.md) | -| 拼接后总时长不对 | 混用不同编码器参数的段 | 全部段由 build-video 统一编码;已自动回退重编码 | -| 中文方框 | 系统无 CJK 字体 | Linux 装 fonts-noto-cjk;或改用已装字体 | -| 报"找不到 ffprobe/ffmpeg" | 二进制不在 PATH | 脚本已自动探测 PATH→node_modules→常见位置;装 ffmpeg-static 或 winget install Gyan.FFmpeg | -| **一部分元素 0 秒就入场、一部分按时序** | 旧版 tokens.css 的延迟被 `.fx-*` 简写覆盖 | 换用新版 tokens.css(延迟走 `--fx-delay`);原理见 authoring.md | -| 图片主体被裁到画面外 / 图片撑破版式 | 裸放 ``,或 cover 配错比例 | 套 `.img-frame` + `--img-pos` 保主体;截图类改 `.contain`;主体贴边按 SOP 重搜图 | -| 字幕在深色主题下糊在背景里 | 主题没覆写字幕钩子 | 该主题加 `--sub-bg`(更深)+ `--sub-ring: 1px solid rgba(255,255,255,.16)`;跑 check-theme 验 | -| 要出竖版(抖音/视频号) | — | `script.json` 设 `width:1080, height:1920`,版式改堆叠(见 authoring.md 竖版章节) | -| 数字/文字明明写了却看不见 | 未定义 CSS 变量 + `-webkit-text-fill-color: transparent`,整条 background 失效 | 跑 `check-slides.mjs` 定位,补定义或写 `var(--x, 默认值)` | -| 图表/折线/某个元素入场后完全不见 | 该 fx 类的关键帧没声明 opacity —— `[data-stage]` 基础态是 `opacity:0`,只做 transform/描边的动画抬不回来 | 在关键帧的 from/to 里补 `opacity:1`;`check-slides.mjs` 会直接报出来 | -| 切了 `no-fx` 画面反而更空 | 早期模板只关动画、没恢复 `[data-stage]` 的 `opacity:0` 基础态 | 项目 tokens.css 是旧版:`node scripts/init-project.mjs <项目目录> --upgrade-css` 补上(幂等);放映页遇到旧 tokens.css 会在关动效副本里兜底注入并明确提示 | -| 双击 `slides/*.html` 看动画, 发现全挤在开头 | 动画延迟由管线按 `timings.json` 注入,tokens.css 里只有占位值(`--t2:800ms`) | 别直接开原文件:跑 `preview-page.mjs` 用副本(已注入实测延迟), 或 `--mode motion` 出片 | -| 放映页里 iframe 是空白/图裂 | 副本的 `` 被清掉, 或 slides/ 被移动过 | 重跑 `preview-page.mjs` 重新生成快照;原文件不要手改(副本是快照,改 slides 后必须重跑) | -| 图片显示 broken 图标 | 文件缺失,或 SVG 本身有问题(XML 错/依赖外部资源/缺尺寸) | `check-slides.mjs` 查路径;SVG 改 inline 进 HTML;capture 也会在渲染时点名哪张没加载 | -| 财经片没免责声明 / 涨跌色反了 / 数字被质疑口径 | 开工没确认领域,默认色与默认措辞直接用了 | 读 `references/compliance.md`:结尾补 `.disclaimer` 行(停留 ≥3s)、指标卡改 `var(--up)/var(--down)` 并按受众市场翻转、每个数字补口径+币种+时点 | -| 不知道要不要写免责声明 / 算不算受监管 | 领域没确认 | 开工对齐第一批问题里问"领域 + 要不要免责";财经投研、医疗、法律、政策、营销效果宣称默认要。`check-slides.mjs` 命中财经关键词却没见免责行时会提示 | +--- +name: html2video-for-mcode +description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻灯片 + TTS + ffmpeg 渲染),为 mcode 环境定制、也可在其他 Agent 环境用 mmx-cli 跑通。当用户想把内容做成视频、html 转 mp4、幻灯片口播视频、slides video、narrated video、一分钟介绍视频、抖音/视频号竖版视频时触发;**只想先看看做好的 HTML 幻灯片、想自己放映一遍或预览动效(口播还没做也行)时同样触发**——技能会生成自包含放映页,不必先花几分钟渲染成片。财经/财报/投研/医疗/政策等**受监管题材**的视频同样触发(需要免责声明与数据口径处理)。也当症状出现时触发——TTS 念完留白过长、画面只有标题没有细节展开、部分元素 0 秒就入场、入场动画没渲染进视频、音画不同步、图片主体被裁到画面外、字幕糊在深色背景上、拼接后总时长不对、字体闪烁或方框、图表或某个元素入场后永远不出现(关键帧没把 opacity 抬回来)、切了 no-fx 画面反而更空、双击 slides/*.html 看动画发现全挤在开头、财经片忘了写免责声明或涨跌色用反。覆盖全流程:开工对齐(领域与免责/风格/字幕/画布/音色/素材边界)→ 信息搜集 → 脚本 → TTS → 实测对时 → 配图 SOP → HTML 分步入场 → 静态闸门与放映页自检 → 逐帧渲染 → ASR 反向校验。工具分两套:mcode 沙箱用 connector__matrix__*(TTS/ASR/音乐),其他环境用 mmx-cli(注意:mmx-cli 无 ASR 与音乐生成,见文末环境对照表)。 +--- + +# HTML 2 Video for mcode:脚本 → 口播成片 + +把一个主题/大纲变成一条可发布的 MP4(默认 1920×1080,可切 1080×1920 竖版):HTML 幻灯片(分步入场动画)+ 中文 TTS 口播 + ffmpeg 组装 + ASR 反向校验。 + +## 四条铁律(违反任何一条,产出必然返工) + +1. **所有时长只来自 ffprobe 实测,永远不手写。** 每张 slide 的时长 = 该段 TTS 实测时长 + 尾部留白(默认 0.8s)。不估、不凑整、不写死。 +2. **TTS 先于 HTML。** 先出音频、实测时长、算好每个视觉层的入场时刻,再写 HTML。动画延迟全部通过 CSS 变量 `--t1/--t2/--t3` 注入,HTML 里不写死秒数。这是消灭"念完留白过长"和"音画不同步"的根本手段。 +3. **每个 Gate 等用户确认,不跳步。** Gate 清单见下文工作流。 +4. **开工先对齐,不要闷头开跑。** 第一次响应就把下面这批问题一次问清(用户没答的项可用默认值,但**必须先问**),答案即后续所有 Gate 的验收基线: + +| 要问的 | 选项 / 默认 | +|---|---| +| **主题与领域** | 题材 + 给谁看 + **属于哪个领域**(财经/商业 · 科技/产品 · 科普/教育 · 品牌/营销 · 文化/历史 · 生活/消费 · 政务/政策),领域决定版式与信息密度;**落在受监管领域(财经投研 / 医疗健康 / 法律 / 政务政策 / 营销效果宣称)必须再问一句"要不要免责声明与数据出处标注"**(默认要)。这一问定三件事:结尾要不要 `.disclaimer` 行、涨跌色要不要按受众翻转、数字要不要带"截至"日期 —— 详见 `references/compliance.md` | +| **语言(必问)** | 口播说什么语言:**中文普通话 `zh`**(默认)/ **英语 `en`** / 粤语 `yue` / 其他 BCP-47。这项决定 4 件事:① 口播稿用哪种语言写;② **音色必须匹配语种**(中文用 `Chinese (Mandarin)_*`、英语用 `English_*`,错配会出怪腔调,写完必须 ASR 验语种);③ 字数/语速基准(中文字/秒 vs 英文词/秒,plan-timings 自动切换);④ ASR 识别语言头(zh 强制普通话,能识破粤语) | +| **风格与配色** | 先按受众给 2–3 个候选主题让用户挑(商务 `minimal-white`/`swiss-grid`/`corporate-clean`;编辑杂志 `editorial-serif`/`magazine-bold`;科技深色 `tokyo-night`/`catppuccin-mocha`/`nord`;消费生活 `xiaohongshu-white`/`soft-pastel`)。**再问一句配色偏好**:直接用主题自带主色,还是有品牌色要指定(给了就按 authoring.md 的"自定义主色"覆写 `--accent` 并跑 check-theme 验对比度) | +| **字幕** | 不要字幕 / 单语(**与口播同语言**,默认)/ **双语**(主行=口播语言,次行 `text2` = 另一语言,如中文口播配英文字幕)——决定要不要写 text2、要不要 `--no-subs` | +| **画布与平台** | 横屏 1920×1080(默认,适合 B站/官网)或竖版 1080×1920(抖音/视频号/小红书);竖版要换堆叠版式 | +| 时长 | 默认 ~60s(6–10s/张 × 8 张);短视频平台可压到 30s | +| 音色 | 温润男声 / 干练女声 / 其他;给出 3 个候选试听(见 tts-and-timing.md) | +| 素材边界 | 允许网络取官方图 / 只用官方素材 / 纯排版不出图 | + +## 目录与工具 + +技能自带 **11 个命令行脚本 + 3 个内部模块**(直接以本技能目录为路径调用,项目目录作为参数,无需复制;`tests/` 下还有一套 node:test 安全与冒烟测试,从仓库根 `node --test` 自动发现): + +| 脚本 | 作用 | +|---|---| +| `scripts/init-project.mjs <项目目录> [--force] [--upgrade-css]` | 生成项目骨架:目录 + tokens.css + slide 模板 + script.json 契约。**目标目录非空时拒绝执行**(会重置 5 个生成文件),要重新初始化必须显式 `--force`;`--upgrade-css` 只给老项目的 tokens.css 补新版 no-fx 规则(幂等) | +| `scripts/plan-timings.mjs <项目目录>` | ffprobe 实测每段 TTS → 每张时长、各 stage 入场时刻、**每句 clauses 时刻** → `build/timings.json` | +| `scripts/check-timing.mjs <项目目录> [--calibrate]` | 静音检测实测每句真实开口, 与估算对比;`--calibrate` 按实测校准 timings 后重渲染 | +| `scripts/check-theme.mjs <项目目录>` | 校验全部主题的 WCAG 对比度(正文/次级/字幕/accent-ink), 不达标退出码 1;新增主题必须过闸 | +| `scripts/check-slides.mjs <项目目录> [--quiet]` | **渲染前静态闸门**:未定义 CSS 变量、缺图/外链资源、`data-stage` 没配 fx 类、fx 关键帧不含 opacity(会永久隐形)、硬编码颜色、整片级领域自查。有 ✗ 就别截图 | +| `scripts/prep-image.mjs --check <图...>` / `--crop [--ratio 16:9] [--anchor ...] [--force]` | 配图 SOP 的执行辅助:查尺寸与裁切风险;按锚点裁切(强制"裁掉 ≤20%、不放大补边") | +| `scripts/fetch-official-images.mjs <页面URL> [--get 1,3] [--out-dir <目录>] [--allow-file] [--max-mb N] [--force]` / `--url <图片URL>[,...]` | 从官方页/本地页面列出并下载候选配图;**站点要登录/滚动加载时,用内置浏览器 inspect 出图片 URL,再用 `--url` 直接落盘(不需 Playwright)**。内网与元数据地址一律拒绝, 每跳重定向复核, 默认写在工作目录内 | +| `scripts/capture.mjs <项目目录> [--mode still\|motion] [--no-subs]` | Playwright 截图。still=终态单帧;motion=逐帧步进入场动画。**字幕默认烧录**(内容取自 clauses),`--no-subs` 关闭 | +| `scripts/preview-page.mjs <项目目录> [--open] [--no-script]` | 生成**放映页** `preview/play/index.html`(单文件、零依赖、file:// 双击即看):←→ 或触屏左右滑翻页、R 重播动画、X 动效/关动效对照、P 口播文案开/关、O 总览、F 全屏。**只做"放画面"这件事**:没有计时器/进度条/跟读高亮(要看时间就看成片)。快照按 `timings.json` **注入实测延迟**,所以浏览器里的动画时序 = 成片时序;还没对时则按等间隔预览并如实标注。口播面板按数据自动决定加不加载,窄窗口/手机上收成底部抽屉且默认收起 | +| `scripts/build-video.mjs <项目目录> [--asr] [--dry-run]` | 编码每张 → 拼接 → 音轨对位 → 合成 → 自检 + 出 `out/subs.srt`;`--asr` **按句**切分音频 + 校验清单;`--dry-run` 只打印将要执行的 ffmpeg 命令(排错用) | +| `scripts/asr.mjs <项目目录> [--api-key K] [--verify-timing] [--from <转写>] [--allow-any-endpoint]` | 调 ASR 转写并按句校验音画是否念的是脚本(数字/繁体字不符判 ✗);`--verify-timing` 用字级时间戳实测句开口。Key 只发官方域 | + +内部模块(被上面的脚本 import, 不单独运行):`tools.mjs`(ffmpeg/ffprobe 探测 + 路径收监 `safeId/safeRel/inside`)、`url-policy.mjs`(ASR 端点白名单 + SSRF/重定向策略)、`nofx-css.mjs`(`no-fx` 规则的唯一来源,init-project 写入 / preview-page 兜底注入共用)。 + +环境要求:Node 18+(脚本用 fileURLToPath 保兼容, 不依赖 Node 20.11 的 import.meta.dirname)、`npm i playwright && npx playwright install chromium`(项目目录内)。ffmpeg/ffprobe 自动探测:PATH → node_modules(ffmpeg-static/ffprobe-static)→ 常见安装位置,找不到会给逐条诊断而不是莫名报错。 + +## 安装到其他 Agent 环境 + +本技能就是「一个文件夹 + 根目录 SKILL.md」的标准形态(frontmatter 的 `name` / `description` 已按规范写好),放到对应技能目录即可被识别: + +```bash +# Claude Code(个人级)/ OpenClaw / 其他兼容 AgentSkills 的工具 +cp -r html2video-for-mcode ~/.claude/skills/ # 或 ~/.openclaw/skills/ +# 项目级安装 +cp -r html2video-for-mcode <你的项目>/.claude/skills/ +# 或者把它推到一个 git 仓库后走 skills CLI +npx skills add --skill html2video-for-mcode +``` + +装完只需再补两件**依赖**(装在你的视频项目里,不是技能目录里): + +```bash +cd <你的视频项目目录> +npm i playwright && npx playwright install chromium # 截图用 +# ffmpeg 任选其一: winget install Gyan.FFmpeg / brew install ffmpeg / apt install ffmpeg +# 或: npm i ffmpeg-static ffprobe-static +``` + +**为什么技能装在别处也能跑**:`scripts/tools.mjs` 按「技能自身位置 → 项目目录 → 调用时的工作目录 → npm 全局」逐个锚点解析 `playwright`;ffmpeg/ffprobe 也是四级探测(PATH → 项目 node_modules → 技能上两级 → 常见安装位置)。所以"技能在 `~/.claude/skills/`、依赖装在项目里"是受支持的用法(已实测:技能放到项目树之外仍能出图)。 + +## 运行环境:两套工具链(同一套脚本,只换工具源) + +脚本层(截图 / 渲染 / 合成 / 校验 / 配图)完全环境无关;**只有 TTS、音乐、ASR 三件事依赖平台能力**。 + +| 能力 | mcode 沙箱(首选) | 其他 Agent 环境(Claude Code / OpenClaw / Cursor 等) | +|---|---|---| +| TTS 合成 | `mcode-tools connector call connector__matrix__batch_text_to_audio --args '{...}'`(≤10 条/批,主用);单条试音用 `connector__matrix__synthesize_speech` | `mmx speech synthesize --text "第一句口播。" --voice --speed 1.0 --out audio/01.mp3`;音色列表 `mmx speech voices` | +| 结果落盘 | `get_asset_url ` → 下载到 `audio/.mp3` | `--out` 直接写盘 | +| BGM 音乐 | `connector__matrix__batch_text_to_music`(≤5 条/批) | ⚠ **mmx-cli 无音乐生成** → 让用户提供音乐文件(确认授权后登记 MANIFEST),或跳过 BGM | +| ASR 反向校验 | `mcode-tools upload_temp_url` + `connector__matrix__listen_audio` | **`node scripts/asr.mjs <项目目录>`** —— 用同一把 API Key 直调 REST(`/v1/speech_to_text`),不依赖 mcode、也不用装 whisper;**会自动与 checklist 的预期文本比对并回填,数字/繁体字(粤语)不符直接判 ✗**。想用字级时间戳实测句开口:`--verify-timing` | +| 素材配图 | 内置浏览器 inspect 官网 DOM(首选;取到的图片 URL 用 `scripts/fetch-official-images.mjs --url` 落盘)/ 官方 brand kit | 同上;抽象配图可用 `mmx image generate --prompt "..." --aspect-ratio 16:9 --n 3`(**仅限抽象概念图,禁止生成 logo / 截图 / 真人头像**),再按 `image-sources.md` 登记 | +| 调研 | `web_search` / `web_fetch` | `mmx search "关键词"` / `mmx text chat` | + +**mmx-cli 首次配置**(非 mcode 环境):`npm install -g mmx-cli` → `mmx auth login --api-key sk-xxx` → `mmx quota` 验证。401 多半是 region 不匹配:`mmx config set --key region --value cn|global`。脚本侧用 `MINIMAX_API_KEY`(必给)与 `MINIMAX_REGION=cn|global`(可选)对齐同一套身份。 + +**纪律不因环境而变**:时长仍由 ffprobe 实测、字幕仍来自 `clauses[]`、音色仍要试听并验语种(走上面的 asr.mjs)、Gate 一个都不跳。 + +## 工作流(7 阶段 · 6 Gate) + +``` +开工对齐(铁律 4, 不设 Gate 但必须先做) +Phase 0 信息搜集 → Gate 0 事实清单 +Phase 1 脚本设计 → Gate 1 逐张口播稿 +Phase 2 TTS + 对时 → Gate 2 试听 + 时长表 +Phase 3 素材收集 → Gate 3 素材清单 + 预览 + 合规确认 +Phase 4 HTML → Gate 4 终态截图 +Phase 5 渲染 + ASR → Gate 5 成片 +Phase 6 交付 +``` + +**顺序是脚手架,不是建议。** 口播稿定了才做 TTS;TTS 时长实测了才动素材和 HTML;素材清单过了 Gate 3 才写进页面;HTML 过了终态截图才渲染。改了口播稿 = 从 Phase 2 重跑(TTS 便宜,重做不贵;带着旧时长硬改才是灾难)。每个 Gate 向用户呈现"验收物清单"里明确的东西,没收到 OK 绝不前进——即使看起来显然,也要确认。 + +### Phase 0 · 信息搜集(条件执行) + +**完整方法见 `references/research.md`**(来源分级、query 设计、矛盾处理、notes 模板)。要点: + +- 判断标准很简单:成片里会出现具体**数字、日期、名称、引语或归属关系** → 必须搜集。通用/抒情/创意题材可跳过,但"看起来像事实"的句子仍要核实。 +- **四条硬规则**:① 关键数字**至少 2 个独立来源**(只有一个就用限定措辞或降级为约数);② **一手优先**(官方公告/财报/技术报告/政府统计),二手转述要回溯原文;③ 标注**口径与日期**(年化还是单季?周活还是月活?币种?);④ **查不到出处或无法判定的,进"不确定项",绝不进口播稿**。 +- **正文怎么取**:先 `web_fetch`;SPA/JS 渲染的官网与投资者关系页常只返回空壳,改用**内置浏览器或 Playwright 渲染后取正文**(最小命令见 references/research.md);招股书/财报是 PDF,以 PDF 原文数字为准。新闻稿与媒体转述冲突时以官方原文为准。 +- **受监管领域**(财经/医疗/法律/政策/营销宣称 → 读 `references/compliance.md`)另加三道约束:① 财经数字必须带**口径 + 币种 + 时点**("二季度营收"而不是"目前营收");② 口播**不给操作建议、不预测价格、不用绝对化用语**;③ 免责声明与数据出处(要不要、怎么写、放哪)在 Gate 0 就跟用户敲定,不留到成片阶段返工。 +- 输出 `research/notes.md`:每条含 来源 URL + 口径日期 + 等级 + 第二来源;另列"不确定项"与"不该进脚本的内容";受监管题材再记一行**领域 + 免责口径的确认结果**。 +- **Gate 0**:事实清单给用户过 —— 重点让用户确认**数字、名称与口径**;受监管题材把免责声明、数据时点、涨跌色一起确认掉。 + +### Phase 1 · 脚本设计(内容量在这里控制) + +在 `script.json` 里逐张填写(契约文件,后续所有脚本都读它): + +```json +{ + "topic": "OpenAI 一分钟", "voice": "Chinese (Mandarin)_Gentleman", + "speed": {"default": 1.0, "first": 0.95, "last": 0.95}, + "fps": 30, "width": 1920, "height": 1080, + "bgm": "assets/bgm.mp3", + "slides": [ + {"id": "03", "layout": "bullets", "html": "03-lines.html", "audio": "03.mp3", + "title": "三大产品线", + "clauses": [ + {"stage": 1, "text": "三大产品线。"}, + {"stage": 2, "text": "ChatGPT 对话,GPT 系列模型,Sora 视频生成。", + "text2": "Chat, models, video generation."} + ]} + ] +} +``` + +可选字段:`clauses[].text2` = 双语字幕第二行(不写则纯中文字幕);顶层 `bgm` = `"assets/bgm.mp3"` 或 `{file, volume:0.12, fadeIn:1.5, fadeOut:2.5}`(写了由 build-video 自动循环+淡入淡出垫底)。 + +**内容量硬规则**(详细版见 `references/authoring.md`): + +- 中文口播 ≈ 4.8 字/秒。每张目标 6–10 秒 → 口播 25–48 字;首尾张 12–20 字。单张硬上限 60 字,超了拆两张。 +- **每张(除首尾)必须至少两个信息层,映射到不同 stage —— 但层的先后顺序自由**(大数字可以先入、标题后出;设问先出、答案再出;由强同步原则决定:层挂在提到它的那句口播的 stage)。只有标题、没有展开 = 违规,打回。口播也一样:每张至少两句,分别推动不同的层。 +- `clauses` 的每个元素是一句口播,`stage` 声明"这句开口时,哪个视觉层该出现"。stage 数 ≈ clause 数,一一对应。 +- 版式共 17 种(原 8 + 补充 9:kpi-grid / stat-highlight / table / timeline / roadmap / comparison / flow-diagram / terminal / big-quote),每种画面必含项与字数区间见 `references/authoring.md` 的版式表;**图表(条形/柱状/环形/折线/进度)有纯 CSS/SVG 画法速查,不用外链图表库**。 +- 开工对齐时若用户要**双语字幕**,这里就要给每句写 `text2`(同句翻译,不重排语序,≤60 字符)。 + +**Gate 1**:逐张口播稿 + 版式分配给用户过。**未逐张 OK 不进 Phase 2。** + +### Phase 2 · TTS + 实测对时 + +TTS 走哪套工具见上文"运行环境"对照表(mcode 用 `connector__matrix__*`,其他环境用 `mmx speech synthesize`);命令模板与重试纪律见 `references/tts-and-timing.md`。要点:批量 ≤10 条,部分失败 sleep 10–30s 后**只重试失败项**;首尾张 speed 0.95(但 ≤10 字的短句保持 1.0,见该文件实测坑)。产物落 `audio/.mp3`。 + +然后对时: + +```bash +node <技能目录>/scripts/plan-timings.mjs <项目目录> +``` + +它会:ffprobe 每段实测时长 → 每张时长 = 实测 + 尾部留白(默认 0.8s,可在 slide 的 `tail` 字段调:紧凑 0.4 / 舒缓 1.2)→ 每个 stage 的入场时刻 = 该句口播按字数占比估算的开口时刻 − 0.2s(视觉略提前于语音,观感同步)→ 每句的开口时刻/时长写入 `clauses[]`(字幕、ASR 按句切分、对时校准共用)→ 写 `build/timings.json`,并输出警告(语速异常、超 15s、末 stage 离收尾太近、单句超 18 字字幕会换行)。 + +可选但推荐(尤其用户反馈过"音画不同步"时):`node <技能目录>/scripts/check-timing.mjs <项目目录>` 用静音检测实测每句真实开口,输出"估算 vs 实测"对比表;偏差大就 `--calibrate` 校准后删 `build/frames/` 重渲染。注意"体感不同步"也常是设计错位——大数字/主体图必须挂在**提到它的那句**的 stage(强同步原则,见 authoring.md)。 + +**Gate 2**:8 段音频试听 + plan-timings 的时长表给用户过。 + +### Phase 3 · 素材收集(合规在这里把关) + +按 `references/image-sources.md` 的配图 SOP 执行(核心原则:**主体不全/半截/比例差的图,先重搜全貌图,别硬裁硬用**),要点: + +1. 列需求清单(哪几张要图、要什么)。 +2. **优先级:官方渠道(brand kit / simple-icons / 官方文档截图)→ 内置浏览器或 Playwright 打开官网 inspect DOM 取官方资源(实测成功率最高)→ Wikipedia CC → 纯排版降级(数字卡 + 大字 + 引言,不用图)。** 旧 image-downloader(Bing)默认不用:中文冷门题材实测 5/5 返回无关图。 +3. 搜图加正向词(全景/全貌/正面/远景),下载前用缩略图筛:主体居中、比例接近、无水印无无关 logo、≥1200px;一张不合适就换,不凑合。 +4. 拿到图先跑 `node <技能>/scripts/prep-image.mjs --check <图>` 看尺寸与裁切风险;主体贴边就换图。 +5. 每张素材查合规:无水印、商标仅限合理引用语境、照片须可授权来源、截图引用官方文档并注明。 +6. 落盘 `assets/`,每个素材一行登记 `assets/MANIFEST.md`(内容/来源/许可)。 +7. **禁止凭空生成 logo、截图、头像、二维码;禁止带水印图直接入素材;禁止裸放 ``(必须套 `.img-frame`)。** + +**Gate 3**:素材清单表 + 每张素材的预览(缩略图/说明)给用户过,明确问一句"素材来源与授权没问题吗"。**用户过完才写 HTML。** + +### Phase 4 · HTML(分步入场在这里实现) + +每张一个文件,放 `slides/`,文件名与 script.json 的 `html` 字段一致。约定: + +- 根元素 `` 选主题(初版 `a|b|c`,另有 `minimal-white / swiss-grid / corporate-clean / editorial-serif / magazine-bold / tokyo-night / catppuccin-mocha / nord / xiaohongshu-white / soft-pastel`;选主题速查与对比度校验见 authoring.md);引 `tokens.css`;画面容器 `.stage`,含 `.brand` 角标与 `.slide-num` 页码。 +- **每个要入场的块加 `data-stage="1|2|3"` + 一个 fx 工具类**(`fx-up/fx-fade/fx-grow/fx-blur/fx-rise/fx-pop/fx-spotlight/fx-ripple/fx-glitch/fx-draw`;氛围类 `fx-pulse/fx-shimmer/fx-kenburns` 不加 data-stage);延迟不用写——管线按 `timings.json` 注入 `--t1/--t2/--t3`。同层错峰用容器 `.fx-stagger`(基准 `style="--stagger-base:var(--t3)"`)或内联 `style="animation-delay:calc(var(--t2) + 150ms)"`。 +- ⚠ **`data-stage` 必须与 fx 类同时用**(只有属性没有动画类会永远停在 opacity:0);延迟实现见 authoring.md 的"stage 延迟的实现原理",改动画时不要手写 `animation-delay: var(--tN)`。 +- 图片一律套 `.img-frame`(`.contain` 给截图/图表;`--img-ratio` 定比例;`--img-pos` 保主体;图注 `.img-cap` 写在框外)。 +- 氛围动画(无限循环的呼吸/漂浮)允许,但不能承载信息、不加 `data-stage`。 +- **动效可一键关**:`` 关整个项目,任何容器加 `no-fx` 关单张,单个元素不给 fx 类即静态。关掉后 motion 捕获自动退化为静态帧出片,时长与音画同步不受影响,字幕照常(详见 authoring.md"动效开关")。 +- 禁用 transition 做入场(截图管线 seek 不到),只用 `@keyframes`。禁外部 Google Fonts(离线不稳),用系统字体栈(tokens.css 已配 CJK fallback)。 +- 素材只用 Gate 3 已确认的 `assets/` 清单,不新增未审素材。 +- ⚠ **写完 8 张后先跑静态检查再截图**:`node <技能>/scripts/check-slides.mjs <项目目录>` —— 抓未定义 CSS 变量(会导致文字隐形)、图片缺失/外链资源、data-stage 没配 fx 类、硬编码颜色。有 ✗ 就别截图,画面对但"看不见"是最难查的。 + +写完终态预览(最快路径,给 Gate 4 看): + +```bash +node <技能目录>/scripts/capture.mjs <项目目录> --mode still +node <技能目录>/scripts/preview-page.mjs <项目目录> --open # 放映页: 让用户自己过一遍动效 +``` + +**Gate 4**:`preview/.png` 逐张给用户过(看版式、字压、素材)+ **放映页 `preview/play/index.html` 交用户自己放一遍**(看动效是不是真的都出现了、快慢能不能接受、这张的画面撑不撑得住)。静态图看不出动画——"元素永远不出现""关动效反而空白""列高塌陷"这类故障恰恰只在动起来或切对照时才露头;放映页是 30 秒的自检手段,不必等 3–6 分钟的 motion 编码。时序与口播对轴不在这一步判(放映页刻意不做计时器),以成片和 `timings.json` 为准。**用户过完再进 Phase 5。** + +### Phase 5 · 渲染 + ASR 校验 + +```bash +node <技能目录>/scripts/capture.mjs <项目目录> --mode motion # 入场动画逐帧进视频 +node <技能目录>/scripts/build-video.mjs <项目目录> --asr +``` + +- `--mode motion`:逐帧步进(暂停全部动画 → 逐帧 seek → 截图 → 编码),动画窗口逐帧渲染、静止段自动补尾帧,时长精确。**字幕默认烧录**(内容取自 clauses、显示窗=该句开口到下句开口,画面底部居中,still 预览里不显示、成片里才有;`--no-subs` 关闭)。成本约 50–200ms/帧,8 张 × 30fps 约 3–6 分钟,预算进超时。赶时间可用 still 模式出片(动画不进视频,只有淡入淡出)。 +- build-video 自动:每张编码(统一参数)→ concat 拼接(时长漂移自动回退重编码)→ 音轨按每张实测时长 `apad` 对位 → **BGM 垫底(配了 bgm 才走:循环补满、淡入淡出、人声优先;混音失败自动退回纯人声)** → mux → ffprobe 时长校验 + 全量解码自检,不过关退出码非 0;同时输出 `out/subs.srt`(与烧录字幕同源同窗,中英双语按 clauses 的 text2 自动两行,供平台上传)。 +- `--asr`:**按句**切出 `asr/part--.mp3` + 生成 `asr/checklist.md`。转写与预期文本比对:数字、年份、产品名必须一致;同音字可容忍。**若某段转写混入上一句的开头,说明那句实际开口比估算晚——跑 check-timing 校准。** + - mcode:`mcode-tools upload_temp_url` 上传后交 `connector__matrix__listen_audio`。 + - **其他环境**:`MINIMAX_API_KEY=sk-xxx node scripts/asr.mjs <项目目录>` —— 直调 REST(同一把 Key),自动比对并回填 checklist;失败项(数字不符/繁体字)会以非 0 退出码报出。想拿更准的开口时刻:`--verify-timing`。 + - 不过关的 slide:改口播或重做该段 TTS → 重跑 plan-timings → 该张重渲染(帧目录删掉对应张即可)。 + +**Gate 5**:成片 `out/final.mp4` + ASR 校验表给用户过,含字幕可读性检查(静音播放一遍,字幕能否撑起理解)与 BGM 电平(人声是否始终清晰)。 + +### Phase 6 · 交付 + +``` +out/final.mp4 # 主交付 +out/slide-*.mp4 # 单段(可单独发布) +build/audio-timeline.wav # 对位后音轨 +preview/*.png preview/play/index.html slides/*.html slides/tokens.css +audio/*.mp3 assets/(含 MANIFEST.md) research/notes.md asr/(校验记录) +``` + +## 参考文件(按需读,别全读) + +- `references/authoring.md` — 17 种版式规范(每种画面必含项)+ 内容量表 + 入场系统用法(含 stage 延迟实现原理)+ 主题速查 + 竖版说明 + 可抄的 HTML 片段 +- `references/compliance.md` — 领域与合规(受监管题材必读):领域确认问法、财经口播红线、数字三要件、涨跌色按受众翻转、免责声明写法与位置、医疗/法律/广告法要点、收尾检查清单 +- `references/research.md` — 资料搜集(来源分级、交叉验证硬规则、query 设计、矛盾处理、notes 模板) +- `references/image-sources.md` — 配图与素材 SOP(三条取图路径、query 正/反词、两级筛选、图片框用法、裁切硬限制、视觉验证三件套、常见题材索引) +- `references/tts-and-timing.md` — mcode TTS connector 命令、重试纪律、对时算法、留白与语速调校、实测音色表 +- `references/render.md` — 渲染原理(为什么逐帧步进、字体怎么等)、字幕系统、BGM 混音、ffmpeg 手工命令、排错表 +- `THIRD-PARTY-NOTICES.md` — 第三方组件许可声明(10 套主题与部分 CSS 原语改编自 html-ppt-skill,MIT) + +## 常见症状 → 一句话诊断 + +| 症状 | 根因 | 动作 | +|---|---|---| +| TTS 念完画面还停很久 | 时长是估的不是实测,或 tail 过大 | 重跑 plan-timings;调该张 `tail` | +| 画面只有标题没有展开 | 违反内容量硬规则 | 补展开层 + 对应 clause,回 Phase 1 | +| 动画没进视频 | 用了 still 模式 | motion 模式重出 | +| 音画不同步(体感) | 三选一:手写了动画延迟 / 估算偏差 / 视觉锚点挂错句(强同步原则) | 先删手写延迟;跑 check-timing 拿实测数据,偏差大就 --calibrate;锚点错句则调 stage 映射 | +| 音色语种不对(粤语/繁体) | 平台 voice 标签错位,名称不可信 | Phase 1 试听必须带 ASR 验音(见 tts-and-timing.md 实测音色表) | +| 成片没字幕 | 用了 --no-subs 或 clauses 缺失 | capture 默认烧录;确认 timings.json 有 clauses | +| 要双语字幕 | — | clause 加 `text2`,画面两行 + SRT 双行自动出;第二行建议 ≤60 字符 | +| 想要背景音乐 | — | 顶层 `bgm` 配置,build-video 自动循环+淡入淡出垫底(默认音量 0.12);ASR 校验仍走纯人声轨 | +| 赶时间 / 题材要克制,不要动画 | — | `` 一键关全部动效,或单张容器加 `no-fx`;出片自动走静态帧,时长不变(见 authoring.md) | +| 拼接后总时长不对 | 混用不同编码器参数的段 | 全部段由 build-video 统一编码;已自动回退重编码 | +| 中文方框 | 系统无 CJK 字体 | Linux 装 fonts-noto-cjk;或改用已装字体 | +| 报"找不到 ffprobe/ffmpeg" | 二进制不在 PATH | 脚本已自动探测 PATH→node_modules→常见位置;装 ffmpeg-static 或 winget install Gyan.FFmpeg | +| **一部分元素 0 秒就入场、一部分按时序** | 旧版 tokens.css 的延迟被 `.fx-*` 简写覆盖 | 换用新版 tokens.css(延迟走 `--fx-delay`);原理见 authoring.md | +| 图片主体被裁到画面外 / 图片撑破版式 | 裸放 ``,或 cover 配错比例 | 套 `.img-frame` + `--img-pos` 保主体;截图类改 `.contain`;主体贴边按 SOP 重搜图 | +| 字幕在深色主题下糊在背景里 | 主题没覆写字幕钩子 | 该主题加 `--sub-bg`(更深)+ `--sub-ring: 1px solid rgba(255,255,255,.16)`;跑 check-theme 验 | +| 要出竖版(抖音/视频号) | — | `script.json` 设 `width:1080, height:1920`,版式改堆叠(见 authoring.md 竖版章节) | +| 数字/文字明明写了却看不见 | 未定义 CSS 变量 + `-webkit-text-fill-color: transparent`,整条 background 失效 | 跑 `check-slides.mjs` 定位,补定义或写 `var(--x, 默认值)` | +| 图表/折线/某个元素入场后完全不见 | 该 fx 类的关键帧没声明 opacity —— `[data-stage]` 基础态是 `opacity:0`,只做 transform/描边的动画抬不回来 | 在关键帧的 from/to 里补 `opacity:1`;`check-slides.mjs` 会直接报出来 | +| 切了 `no-fx` 画面反而更空 | 早期模板只关动画、没恢复 `[data-stage]` 的 `opacity:0` 基础态 | 项目 tokens.css 是旧版:`node scripts/init-project.mjs <项目目录> --upgrade-css` 补上(幂等);放映页遇到旧 tokens.css 会在关动效副本里兜底注入并明确提示 | +| 双击 `slides/*.html` 看动画, 发现全挤在开头 | 动画延迟由管线按 `timings.json` 注入,tokens.css 里只有占位值(`--t2:800ms`) | 别直接开原文件:跑 `preview-page.mjs` 用副本(已注入实测延迟), 或 `--mode motion` 出片 | +| 放映页里 iframe 是空白/图裂 | 副本的 `` 被清掉, 或 slides/ 被移动过 | 重跑 `preview-page.mjs` 重新生成快照;原文件不要手改(副本是快照,改 slides 后必须重跑) | +| 图片显示 broken 图标 | 文件缺失,或 SVG 本身有问题(XML 错/依赖外部资源/缺尺寸) | `check-slides.mjs` 查路径;SVG 改 inline 进 HTML;capture 也会在渲染时点名哪张没加载 | +| 财经片没免责声明 / 涨跌色反了 / 数字被质疑口径 | 开工没确认领域,默认色与默认措辞直接用了 | 读 `references/compliance.md`:结尾补 `.disclaimer` 行(停留 ≥3s)、指标卡改 `var(--up)/var(--down)` 并按受众市场翻转、每个数字补口径+币种+时点 | +| 不知道要不要写免责声明 / 算不算受监管 | 领域没确认 | 开工对齐第一批问题里问"领域 + 要不要免责";财经投研、医疗、法律、政策、营销效果宣称默认要。`check-slides.mjs` 命中财经关键词却没见免责行时会提示 | diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md index af6b887c..177d0ed5 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md @@ -1,446 +1,446 @@ -# HTML 设计与内容量规范 - -## 核心原则:每张 slide 是"一句主张 + 一组展开" - -成片最常见的两个败笔,都在authoring阶段就能拦住: - -1. **只有标题没有展开** —— 观众 7 秒只看到一行大字,信息量为零。 -2. **文字一次性全糊上屏** —— 没有节奏,口播念到后半句时视觉早已无话可说。 - -解法是强制的三层结构:**标题层 + 展开层 + 视觉锚点(可选),三层分属不同 stage**。口播与画面是同一句话的两个声部。 - -**层的入场顺序不固定 —— 由叙事决定,标题先行只是默认。** 判据是强同步原则:哪个层被哪句口播提到,就挂在那个 stage。完全合法的变体: - -- **大数字先入,标题后出**(stat-highlight 的默认形态):口播第一句就砸数字,数字 s1、说明与标题 s2; -- **设问先出,答案再出**:问题大字 s1 → 答案/数据 s2 → 标题收拢 s3; -- **图先入,文字后落**:主体图 s1 → 关键词标注 s2(图片类题材常用)。 - -要拦的从来不是"标题不先行",而是**只有一层**:整张停在标题/单块内容上、没有第二个信息层推进。 - -## 内容量表(硬规则) - -中文口播 ≈ 4.8 字/秒(speed 1.0),每张目标 6–10s。**英语按 ~14 字符/秒(约 150 词/分)**——下表字数是中文口径;英语项目按"词"折半看:每张 14–26 词、硬上限 34 词(plan-timings 会按 `lang` 自动切换基准与预警区间)。 - -| 版式 | 用途 | 画面必含(缺一即违规) | 口播字数 | clauses 结构 | -|---|---|---|---|---| -| title-hero | 开场主张 | 大标题 + 副题一行 + logo/主题角标 | 12–20 | 1 句 | -| statement | 单点断言 | 断言大字(s1) + 支撑小字(s2) + 视觉锚点(s3 可选) | 18–28 | 2 句 | -| bullets | 并列要点 | 标题(s1) + 3–4 条要点,每条 ≤14 字(s2) | 25–40 | 2 句 | -| compare | 前后对比 | 标题(s1) + 左右两栏各 ≥1 项 + 分隔(s2) | 22–35 | 2 句 | -| data-viz | 数据说服 | 标题(s1) + ≥2 个数字卡/图表(s2) | 25–40 | 2 句 | -| code | 演示调用 | 标题(s1) + 代码块 ≥3 行 + 高亮注释(s2) | 25–40 | 2 句 | -| quote | 引语转场 | 引语大字(s1) + 署名/头衔(s2) | 20–32 | 2 句 | -| closing | 收束 | 一句收束 + logo/CTA | 12–20 | 1 句 | - -判定规则(写给执行者,自查用): - -- 除 title-hero/closing 外,**画面视觉块 ≥2 个且分属不同 stage**;"光标题 + 页码"直接打回。**层的先后顺序自由**(数字可以先于标题、设问可以先于答案),错的是"只有一个层",不是"标题不在 s1"。 -- **强同步原则(2026-09 实测教训):每个 stage 的视觉锚点(大数字/关键词/主体图)必须写在触发它的那句口播里,不要放在上一句。**反例:口播第一句就是"三亿人在用",但"3 亿"数字卡挂在 stage 2——观众听到"三亿"时画面没反应,数字卡进场时口播已讲到下一句,体感就是"动画对不上"。正例:第一句只铺垫("它有三个你可能用过的产品"),"3 亿"作为第二句开头,数字卡挂 stage 2 → 声画同时砸出"3 亿",强同步。 -- 口播 clauses 与 stage 的映射:**stage k 的视觉在 clause k 开口时入场**。stage 数 ≈ clause 数;多出的视觉层用 script.json 的 `stageTimes` 显式给时刻。 -- 单张口播硬上限 60 字;超了说明这张在干两张的活,拆。字幕场景下单句建议 ≤18 字(capture 烧录字幕单行展示);双语时第二行(text2)≤60 字符。 -- 数字、专有名词在口播里用中文读法(“九亿”而非“900000000”),画面上才出现阿拉伯数字。(英语项目相反:口播写英文数字读法 “nine hundred million”,画面用 900M。) -- 双语字幕(可选):每句给 `text2` 即自动两行(主行中文 + 次行小字号);text2 是同一句的翻译,不要重排语序。 - -## 分步入场系统(怎么写 HTML) - -tokens.css 已内置,规则只有四条: - -1. 要入场的块:`data-stage="1|2|3"` + fx 工具类(`fx-up/fx-fade/fx-grow/fx-draw`)。 -2. **不写任何延迟秒数**。`--t1/--t2/--t3` 由渲染管线按 TTS 实测时长注入;浏览器里直接打开时用 tokens 里的占位默认值(0/0.8/2.0s)。 -3. 同层错峰(如三条 bullets 依次出现):内联 `style="animation-delay:calc(var(--t2) + 150ms)"`,第二块 +300ms,以此类推。 -4. 氛围动画(`fx-pulse` 等无限循环)只能用于装饰(光晕、呼吸点),**不加 data-stage、不承载信息**——它们不参与时长计算,静止截图中可能停在任意相位。 - -禁令:入场效果不要用 transition(管线逐帧 seek 不到 transition 状态);不要用 JS 定时器编排(setTimeout 驱动的分层,帧步进模式下不会发生);字体不要引外部 Google Fonts(离线环境直接方框),用 tokens.css 的系统字体栈。 - -### 写 HTML 的三条硬检查(写完立刻跑 `node <技能>/scripts/check-slides.mjs <项目>`) - -这三条都是 2026-09 实测踩过的静默故障 —— 画面明显不对,但流水线一路报成功: - -1. **变量必须先定义,或带 fallback**。`background: linear-gradient(135deg, var(--coral-a), var(--coral-b))` 里只要有一个变量没定义,整条声明计算为 `none`;若该元素又用了 `-webkit-text-fill-color: transparent`(渐变数字的常规写法),**文字会彻底隐形且不报错**。从别的单页 HTML 抄配色时最容易踩:抄来的变量名(如 `--coral-*`)不在 tokens.css 里。要么在项目 tokens.css 补定义,要么写 `var(--coral-a, #ED3366)` 给默认值。 -2. **图片用相对路径且文件必须存在**;`.svg` 建议 **inline 进 HTML**。实测 file:// 下 `` 能正常加载(400px),所以 broken 图标通常不是协议问题,而是 SVG 本身:XML 有误、依赖外部资源/Web 字体、缺 width/height、或下载失败存成了 HTML。inline 一次解决全部。 -3. **不要把外部单页的配色/变量整套抄进来**。可抄版式结构,配色必须落到 tokens 变量(`--accent` / `--good` / `--grad` …);硬编码 hex/rgb 换主题时会串色。 - -## 主题与视觉基调 - -``: - -- **a · 米白+暖橙**(默认,通用商务):bg `#FAFAF7`,accent `#FF5B2E`,衬线大标题。讲述、观点、人物向。 -- **b · 深色+绿**(科技感):bg `#0E0F12`,accent `#10A37F`,高对比数据。产品、AI、开发者向。 -- **c · 黑白+蓝**(极简高端):bg `#FFFFFF`,accent `#1F6FEB`。财务、报告、严肃向。 - -字号纪律:一张 slide 上最多两个层级同屏(标题 + 一个次级);正文 ≥30px,再小就是给审查者找茬。安全边距:内容离边缘 ≥96px(brand/slide-num 除外)。 - -## 素材获取(Phase 3 · Gate 3) - -流程:列需求清单(哪几张要图、要什么)→ 按下面的优先级取材 → 合规自查 → 落盘 `assets/` 并登记 `MANIFEST.md` → **Gate 3 给用户过**。 - -**获取优先级(逐级降级,不硬找):** - -1. **官方渠道(首选)**:logo 用官方 brand kit 或 simple-icons(可直接 curl);产品截图截官方 help/文档;数据用官方报告。合规性最强,事实性题材尽量停在这级。 -2. **内置浏览器 / Playwright 访问官网提取(2026-09 实测推荐)**:用 agent 的内置浏览器或 Playwright 打开官网/官方新闻页,inspect DOM 提取 ``/`` 资源(校验 src 是官方 CDN 域名再下载)。实测中国 AI 公司题材 6/6 全相关,远好于搜索引擎图搜。开工对齐时用户选了"只用官方素材/纯排版"则整级跳过。 -3. **纯排版降级(永远可用的兜底)**:大字 + 数字卡 + 配色 + 版式本身的表现力。宁缺毋假——没有合适素材就用这级,画面依然成立。 - -> ⚠ **image-downloader(Bing 搜图)默认不用。** 2026-09-17 实测:中文 AI 公司题材 5/5 关键词返回的全部是无关图(Bing 端返回"猜你喜欢"推荐卡,脚本正则照单全收)。除非题材是英文大众品牌且愿意逐张人工筛,否则不要走这条路。 - -**合规自查(每个素材过一遍):** - -- 无水印。有水印的候选:换一张,或裁到水印外;裁不掉就不用。 -- 商标/logo 只在"谈论该品牌"的合理引用语境使用,不做装饰滥用。 -- 照片须来自可授权来源(官方新闻图、CC 授权、免费图库);查不到授权的明星/人物照宁可用纯文字引语卡。 -- 截图注明出处(官方文档名 + 日期)。 - -**登记与使用:** - -- `assets/MANIFEST.md` 每个素材一行:文件名 / 内容 / 来源 URL 或渠道 / 许可。Gate 3 连同素材预览一起给用户,明确问"来源与授权没问题吗"。 -- **禁止凭空生成 logo、截图、头像、二维码;禁止带水印图入素材。** -- **图片必须套框,禁止裸放 ``**:用 `.img-frame`(比例与裁切归框)/ `.img-frame.contain`(截图、图表、带文字的图必须用,不裁切居中留白)/ `--img-ratio`(框比例,不必强求 16:9)/ `--img-pos: top|center|bottom`(控制主体可见区,**替代硬裁的首选**)/ `.img-scrim`(压暗垫白字)/ `.img-cap`(图注,写在框外)/ `.img-tag`(角标)。选图与裁切的完整 SOP 见 `references/image-sources.md`。 -- 图片入 HTML 用相对路径 `../assets/xxx.png`;capture 会等图片加载完(每张上限 4s,加载不动就跳过——所以素材必须先落本地,绝不引外链图)。 - -## 版式片段(可抄) - -以下片段都基于 tokens.css,只列 `
` 内部结构;外层 `.stage`、`.brand`、`.slide-num` 照 `_template.html`。 - -**bullets** -```html -
-

三大产品线

-
    -
  • ChatGPT — 对话
  • -
  • GPT 系列 — 模型
  • -
  • Sora — 视频生成
  • -
-
-``` - -**compare** -```html -
-

三年,高中生 → 研究员

-
-
-

2023 · GPT-3.5

会聊天,常出错

-
-
-

今天 · GPT-5.6

可托付研究级任务

-
-
-
-``` - -**data-viz** -```html -
-

九亿人每周在用

-
-
-
9亿
周活跃用户
-
-
-
$25B
年化营收
-
-
-
92%
财富500强渗透
-
-
-
-``` - -**code** -```html -
-

一行调用

-
const answer = await openai.responses.create({
-  model: "gpt-5.6",
-  input: "帮我总结这份报告",
-});
-console.log(answer.output_text);
-
-``` - -**quote** -```html -
-
“我们想打造的是比人类更聪明的工具,而不是替代人类。”
-

— Sam Altman, OpenAI CEO

-
-``` - -**title-hero / closing**:大标题居中 + 一行副题/CTA,单 stage 即可;closing 可加 logo `fx-grow`。 - -布局 CSS(`.layout/.cols/.cards/.num/.cap` 等)每张 slide 内联在 ` - - -
- 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 — 语种必须与音色匹配, 且影响语速基准与字幕行宽校验。', - topic, - lang: "zh", - voice: 'Chinese (Mandarin)_Gentleman', - speed: { default: 1.0, first: 0.95, last: 0.95 }, - 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: 只给已有项目补新版 tokens.css 里新增的 no-fx 规则(幂等, 不碰其他文件)。 -// 老项目没有这段, 会静默失效 —— 只关动画不把 opacity 抬回来, 页面反而是空白。 -if (argv.includes('--upgrade-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 (hasNofxRules(css)) { console.log(`无需升级: ${cssPath} 已含 no-fx 规则`); process.exit(0); } - const block = `\n/* 以下由 init-project --upgrade-css 追加(2026-09-18): 老项目缺这段时 失效 */\n${NOFX_CSS}\n`; - fs.writeFileSync(cssPath, css.replace(/\s*$/, '\n') + block); - console.log(`✓ 已补:no-fx 规则 → ${cssPath}`); - console.log(' 其余新版组件(图表类 fx-grow-x/fx-grow-y 等)未自动补: 需要就在 references/authoring.md 里照抄对应块。'); - 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 3 终态预览 - node scripts/capture.mjs "${dir.replace(/\\/g, '/')}" --mode motion # 动画帧 - node scripts/build-video.mjs "${dir.replace(/\\/g, '/')}" --asr`); +#!/usr/bin/env node +// html2video-for-mcode · 生成项目骨架 +// 用法: node init-project.mjs <项目目录> [--topic "主题名"] [--force] [--upgrade-css] +// 目标目录已存在且非空时拒绝执行(会重置 script.json 等 5 个生成文件), 需显式 --force。 +// --upgrade-css: 不生成任何文件, 只给已有项目的 tokens.css 补新版规则(no-fx + 图表工具箱, 幂等可重复跑)。 +import fs from 'node:fs'; +import path from 'node:path'; +import { NOFX_CSS, hasNofxRules } from './nofx-css.mjs'; +import { CHART_CSS, hasChartKit } from './chart-css.mjs'; + +const argv = process.argv.slice(2); +const dirArg = argv.find(a => !a.startsWith('--')); +if (!dirArg) { + console.error('用法: node init-project.mjs <项目目录> [--topic "主题名"]'); + process.exit(1); +} +const topicIdx = argv.indexOf('--topic'); +const topic = topicIdx > -1 ? argv[topicIdx + 1] : ''; +// topic 会插进模板 HTML 的 .brand 角标, 插入点单独转义(script.json / notes.md 用原文, 不转义) +const escapeHtml = s => String(s).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); +const topicHtml = escapeHtml(topic); +const dir = path.resolve(dirArg); + +const TOKENS_CSS = `/* html2video-for-mcode 设计令牌 · 由 init-project 生成 + 主题切换: + + a / b / c 是初版三主题(向后兼容, 老项目继续有效); 其余 10 套于 2026-09-17 移植自 + html-ppt-skill (MIT, Copyright (c) 2026 lewis) —— 详见技能内 THIRD-PARTY-NOTICES.md。 + + 约定: 新增或覆写主题必须覆盖全部颜色令牌 + --accent-ink, 并跑 + \`node <技能>/scripts/check-theme.mjs <项目目录>\` 校验对比度(深色主题另需覆写 --sub-bg/--sub-ring)。 +*/ +:root { + /* ── 画布(渲染管线按 script.json 的 width/height 注入真值; 默认横屏 1920×1080) ── */ + --stage-w: 1920px; --stage-h: 1080px; + + /* ── 颜色 · 表面 ── */ + --bg: #FAFAF7; --bg-soft: #F4F3ED; + --panel: #F1F0EA; --panel-2: #E9E8E0; + --line: #E3E1D8; --line-strong: #CFCDC0; + + /* ── 颜色 · 文本(三级) ── */ + --fg: #1A1A1A; --muted: #6B6B66; --fg-3: #9A9A92; + + /* ── 颜色 · 强调(accent-ink = 压在 accent 上的文字色, 每条主题必须自带对比度注释) ── */ + --accent: #FF5B2E; --accent-ink: #FFFFFF; /* on --accent 3.1:1 — 仅可用于大字/图形 */ + --accent-2: #FF8A5C; --accent-3: #D94A20; + + /* ── 颜色 · 语义(数据页涨跌/正负) ── */ + --good: #1AAF6C; --warn: #C98500; --bad: #C13A3A; + + /* ── 涨跌专用(指标卡写 var(--up)/var(--down), 别直接写 good/bad): + 默认 = 欧美读法(绿涨红跌); A股/港股受众在项目 tokens.css 末尾覆写成红涨绿跌: + :root { --up: #D92B2B; --down: #12A150; } 见 references/compliance.md ── */ + --up: var(--good); --down: var(--bad); + + /* ── 渐变 ── */ + --grad: linear-gradient(135deg, #FF5B2E, #FF8A5C 55%, #D94A20); + --grad-soft: linear-gradient(135deg, #FBF0E8, #F7E7DA); + + /* ── 字幕(capture 烧录的字幕读这些钩子; 深色主题必须覆写 --sub-bg 与 --sub-ring) ── */ + --sub-bg: rgba(12,12,16,.62); --sub-fg: #FFFFFF; --sub-ring: 0 solid transparent; + + /* ── 圆角 / 阴影 ── */ + --radius: 16px; --radius-sm: 10px; --radius-lg: 24px; + --shadow: 0 8px 24px rgba(18,24,40,.07), 0 2px 6px rgba(18,24,40,.04); + --shadow-lg: 0 22px 56px rgba(18,24,40,.13), 0 6px 16px rgba(18,24,40,.06); + + /* ── 字体(全部走本地系统栈, 禁外链; 外链字体在离线沙箱会退化成 FOUT/方框) ── */ + --font-display: "Source Han Serif SC", "Noto Serif CJK SC", "Noto Serif SC", "SimSun", serif; + --font-body: "Inter", "Source Han Sans SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; + --font-mono: "JetBrains Mono", "Cascadia Code", Consolas, monospace; + + /* ── 字号(7 档 modular scale) ── */ + --fs-display: 92px; --fs-h1: 64px; --fs-h2: 44px; --fs-h3: 34px; + --fs-body: 30px; --fs-caption: 24px; --fs-tiny: 20px; + + /* ── 间距(8 的倍数) ── */ + --sp-1: 8px; --sp-2: 16px; --sp-3: 24px; --sp-4: 32px; + --sp-5: 48px; --sp-6: 64px; --sp-7: 96px; --sp-8: 128px; + + /* ── 缓动 / 时长 ── */ + --ease-out: cubic-bezier(.16, 1, .3, 1); + --ease-in-out: cubic-bezier(.65, 0, .35, 1); + --dur-fast: .4s; --dur-mid: .6s; --dur-slow: .9s; + + /* ── stage 延迟: 浏览器里直接打开时的占位值; 渲染管线会按 TTS 实测时长注入真值 ── */ + --t1: 0ms; --t2: 800ms; --t3: 2000ms; +} + +/* ══ 主题 b · 深色科技绿(初版) ══ */ +[data-theme="b"] { + --bg: #0E0F12; --bg-soft: #12141A; --panel: #171A1F; --panel-2: #1E2229; + --line: #262A31; --line-strong: #363B45; + --fg: #F4F5F3; --muted: #9A9C9F; --fg-3: #6E7276; + --accent: #10A37F; --accent-ink: #04110C; /* on --accent 7.4:1 */ + --accent-2: #3DD9AC; --accent-3: #0B7A5F; + --good: #3DD9AC; --warn: #E0AF68; --bad: #F7768E; + --grad: linear-gradient(135deg, #10A37F, #3DD9AC 55%, #0B7A5F); + --grad-soft: linear-gradient(135deg, #171A1F, #1E2229); + --sub-bg: rgba(0,0,0,.58); --sub-ring: 1px solid rgba(255,255,255,.16); + --shadow: 0 10px 30px rgba(0,0,0,.45); --shadow-lg: 0 24px 60px rgba(0,0,0,.6); +} + +/* ══ 主题 c · 极简蓝(初版) ══ */ +[data-theme="c"] { + --bg: #FFFFFF; --bg-soft: #F7F8FA; --panel: #F5F6F8; --panel-2: #EDEFF3; + --line: #E5E7EB; --line-strong: #CFD3DA; + --fg: #111111; --muted: #666666; --fg-3: #9AA0A8; + --accent: #1F6FEB; --accent-ink: #FFFFFF; /* on --accent 4.9:1 */ + --accent-2: #5B9BFF; --accent-3: #1550B8; + --good: #0E9F6E; --warn: #D97706; --bad: #DC2626; + --grad: linear-gradient(135deg, #1F6FEB, #5B9BFF 55%, #1550B8); + --grad-soft: linear-gradient(135deg, #F0F4FB, #E4ECF7); + --shadow: 0 1px 3px rgba(10,37,64,.08), 0 4px 12px rgba(10,37,64,.05); + --shadow-lg: 0 4px 12px rgba(10,37,64,.1), 0 16px 40px rgba(10,37,64,.08); +} + +/* ════════════════════════════════════════════════════════════════ + 以下 10 套主题移植自 html-ppt-skill(MIT, © 2026 lewis) + 映射规则: --surface→--panel, --surface-2→--panel-2, --border→--line, + --border-strong→--line-strong, --text-1→--fg, --text-2→--muted, --text-3→--fg-3; + 外链字体(Playfair/Space Grotesk 等)一律改映射到我们的本地字体栈。 + 每套的 --accent-ink 后保留原作者实测的对比度数值。 + ════════════════════════════════════════════════════════════════ */ + +/* 商务汇报 · 极简白, 克制高级 */ +[data-theme="minimal-white"] { + --bg: #FFFFFF; --bg-soft: #FAFAFA; --panel: #FFFFFF; --panel-2: #F5F5F6; + --line: rgba(17,18,22,.08); --line-strong: rgba(17,18,22,.16); + --fg: #0C0D10; --muted: #55596A; --fg-3: #9CA1B0; + --accent: #111216; --accent-ink: #FFFFFF; /* on --accent 18.7:1 */ + --accent-2: #3B3F4A; --accent-3: #6B6F7A; + --good: #1AAF6C; --warn: #C98500; --bad: #C13A3A; + --grad: linear-gradient(135deg, #111216, #3B3F4A); + --grad-soft: linear-gradient(135deg, #F5F5F6, #FFFFFF); + --radius: 14px; --radius-sm: 8px; --radius-lg: 22px; + --shadow: 0 1px 2px rgba(17,18,22,.04), 0 8px 24px rgba(17,18,22,.06); + --shadow-lg: 0 20px 60px rgba(17,18,22,.1); + --font-display: var(--font-body); +} + +/* 商务汇报 · 瑞士网格(Helvetica 感; .stage 带 12 栏竖线) */ +[data-theme="swiss-grid"] { + --bg: #FFFFFF; --bg-soft: #F4F4F4; --panel: #FFFFFF; --panel-2: #F4F4F4; + --line: #111111; --line-strong: #111111; + --fg: #111111; --muted: #444444; --fg-3: #888888; + --accent: #D6001C; --accent-ink: #FFFFFF; /* on --accent 5.4:1 */ + --accent-2: #111111; --accent-3: #888888; + --good: #0F8A2F; --warn: #D38A00; --bad: #D6001C; + --grad: linear-gradient(135deg, #D6001C, #111111); + --grad-soft: linear-gradient(135deg, #F4F4F4, #FFFFFF); + --radius: 0; --radius-sm: 0; --radius-lg: 0; + --shadow: none; --shadow-lg: none; + --font-display: var(--font-body); +} +[data-theme="swiss-grid"] .card { border-top: 2px solid var(--fg); border-bottom: 1px solid var(--fg); border-left: none; border-right: none; box-shadow: none; background: var(--bg); } +[data-theme="swiss-grid"] .stage { background-image: linear-gradient(90deg, rgba(0,0,0,.04) 1px, transparent 1px); background-size: calc(100% / 12) 100%; } + +/* 商务汇报 · 企业商务(深蓝) */ +[data-theme="corporate-clean"] { + --bg: #FFFFFF; --bg-soft: #F5F7FA; --panel: #FFFFFF; --panel-2: #F0F3F7; + --line: rgba(10,37,64,.12); --line-strong: rgba(10,37,64,.28); + --fg: #0A2540; --muted: #425466; --fg-3: #8898AA; + --accent: #0A2540; --accent-ink: #FFFFFF; /* on --accent 15.5:1 */ + --accent-2: #1D4ED8; --accent-3: #64748B; + --good: #0E9F6E; --warn: #D97706; --bad: #DC2626; + --grad: linear-gradient(135deg, #0A2540, #1D4ED8); + --grad-soft: linear-gradient(135deg, #F0F4FB, #E4ECF7); + --radius: 6px; --radius-sm: 4px; --radius-lg: 10px; + --shadow: 0 1px 3px rgba(10,37,64,.08), 0 4px 12px rgba(10,37,64,.05); + --shadow-lg: 0 4px 12px rgba(10,37,64,.1), 0 16px 40px rgba(10,37,64,.08); + --font-display: var(--font-body); +} +[data-theme="corporate-clean"] .kicker { color: var(--accent-2); } + +/* 编辑杂志 · 杂志风衬线(原主题给标题配斜体, 中文伪斜体观感差故略去) */ +[data-theme="editorial-serif"] { + --bg: #FAF7F2; --bg-soft: #F3EFE6; --panel: #FFFFFF; --panel-2: #F7F2E8; + --line: rgba(40,28,18,.12); --line-strong: rgba(40,28,18,.24); + --fg: #1B1410; --muted: #5C4A3E; --fg-3: #8A7868; + --accent: #8A2A1C; --accent-ink: #FFFFFF; /* on --accent 8.6:1 */ + --accent-2: #C97A4A; --accent-3: #1B1410; + --good: #3F7D4F; --warn: #B07A1F; --bad: #8A2A1C; + --grad: linear-gradient(135deg, #8A2A1C, #C97A4A); + --grad-soft: linear-gradient(135deg, #FAF7F2, #F3EFE6); + --radius: 4px; --radius-sm: 2px; --radius-lg: 8px; + --shadow: 0 2px 12px rgba(40,28,18,.06); --shadow-lg: 0 20px 50px rgba(40,28,18,.14); +} + +/* 编辑杂志 · 大标题(硬阴影 + 衬线巨字) */ +[data-theme="magazine-bold"] { + --bg: #F5EFE2; --bg-soft: #EBE4D2; --panel: #FBF6E8; --panel-2: #EDE5D0; + --line: rgba(10,10,10,.16); --line-strong: #0A0A0A; + --fg: #0A0A0A; --muted: #2A2A2A; --fg-3: #6A6458; + --accent: #EA5A1A; --accent-ink: #0B1024; /* on --accent 5.4:1 */ + --accent-2: #0A0A0A; --accent-3: #C42A10; + --good: #2A6A2A; --warn: #EA5A1A; --bad: #C42A10; + --grad: linear-gradient(135deg, #EA5A1A, #C42A10); + --grad-soft: linear-gradient(135deg, #FBE4D0, #F5D6C0); + --radius: 0; --radius-sm: 0; --radius-lg: 2px; + --shadow: none; --shadow-lg: 6px 6px 0 var(--accent); +} +[data-theme="magazine-bold"] .card { border: 1.5px solid var(--fg); } +[data-theme="magazine-bold"] .divider-accent { height: 6px; width: 90px; } + +/* 科技深色 · tokyo-night */ +[data-theme="tokyo-night"] { + --bg: #1A1B26; --bg-soft: #16161E; --panel: #24283B; --panel-2: #2F334D; + --line: rgba(192,202,245,.12); --line-strong: rgba(192,202,245,.24); + --fg: #C0CAF5; --muted: #A9B1D6; --fg-3: #565F89; + --accent: #7AA2F7; --accent-ink: #0B1024; /* on --accent 7.5:1 */ + --accent-2: #BB9AF7; --accent-3: #7DCFFF; + --good: #9ECE6A; --warn: #E0AF68; --bad: #F7768E; + --grad: linear-gradient(135deg, #7AA2F7, #BB9AF7 55%, #F7768E); + --grad-soft: linear-gradient(135deg, #24283B, #2F334D); + --sub-bg: rgba(0,0,0,.58); --sub-ring: 1px solid rgba(255,255,255,.16); + --radius: 12px; --radius-sm: 8px; --radius-lg: 20px; + --shadow: 0 10px 30px rgba(0,0,0,.45); --shadow-lg: 0 24px 62px rgba(0,0,0,.6); +} + +/* 科技深色 · catppuccin mocha */ +[data-theme="catppuccin-mocha"] { + --bg: #1E1E2E; --bg-soft: #181825; --panel: #313244; --panel-2: #45475A; + --line: rgba(205,214,244,.12); --line-strong: rgba(205,214,244,.24); + --fg: #CDD6F4; --muted: #A6ADC8; --fg-3: #7F849C; + --accent: #CBA6F7; --accent-ink: #0B1024; /* on --accent 9.3:1 */ + --accent-2: #89B4FA; --accent-3: #F5C2E7; + --good: #A6E3A1; --warn: #F9E2AF; --bad: #F38BA8; + --grad: linear-gradient(135deg, #CBA6F7, #89B4FA 50%, #94E2D5); + --grad-soft: linear-gradient(135deg, #313244, #45475A); + --sub-bg: rgba(0,0,0,.58); --sub-ring: 1px solid rgba(255,255,255,.16); + --radius: 14px; --radius-sm: 10px; --radius-lg: 22px; + --shadow: 0 10px 30px rgba(0,0,0,.35); --shadow-lg: 0 24px 60px rgba(0,0,0,.5); +} + +/* 科技深色 · nord */ +[data-theme="nord"] { + --bg: #2E3440; --bg-soft: #272B35; --panel: #3B4252; --panel-2: #434C5E; + --line: rgba(236,239,244,.12); --line-strong: rgba(236,239,244,.24); + --fg: #ECEFF4; --muted: #D8DEE9; --fg-3: #7B8394; + --accent: #88C0D0; --accent-ink: #0B1024; /* on --accent 9.4:1 */ + --accent-2: #81A1C1; --accent-3: #B48EAD; + --good: #A3BE8C; --warn: #EBCB8B; --bad: #BF616A; + --grad: linear-gradient(135deg, #88C0D0, #81A1C1 50%, #B48EAD); + --grad-soft: linear-gradient(135deg, #3B4252, #434C5E); + --sub-bg: rgba(0,0,0,.58); --sub-ring: 1px solid rgba(255,255,255,.16); + --radius: 12px; --radius-sm: 8px; --radius-lg: 20px; + --shadow: 0 10px 30px rgba(0,0,0,.35); --shadow-lg: 0 22px 60px rgba(0,0,0,.5); +} + +/* 消费生活 · 小红书白底高级感 */ +[data-theme="xiaohongshu-white"] { + --bg: #FFFDFB; --bg-soft: #FFF6F1; --panel: #FFFFFF; --panel-2: #FFF1EA; + --line: rgba(60,30,20,.1); --line-strong: rgba(60,30,20,.22); + --fg: #1A1210; --muted: #4F3A32; --fg-3: #A08D85; + --accent: #FF2742; --accent-ink: #0B1024; /* on --accent 5.0:1 */ + --accent-2: #FF7A90; --accent-3: #FFB38A; + --good: #3BA55C; --warn: #F5A524; --bad: #FF2742; + --grad: linear-gradient(135deg, #FF2742, #FF7A90 55%, #FFB38A); + --grad-soft: linear-gradient(135deg, #FFF6F1, #FFEAE0); + --radius: 20px; --radius-sm: 14px; --radius-lg: 28px; + --shadow: 0 12px 30px rgba(255,39,66,.08); --shadow-lg: 0 24px 60px rgba(255,39,66,.14); +} + +/* 消费生活 · 柔和马卡龙 */ +[data-theme="soft-pastel"] { + --bg: #FDF7FB; --bg-soft: #FBEEF3; --panel: #FFFFFF; --panel-2: #FDF0F5; + --line: rgba(120,70,110,.12); --line-strong: rgba(120,70,110,.22); + --fg: #3A1F33; --muted: #6B4D62; --fg-3: #A28A99; + --accent: #F49BB8; --accent-ink: #0B1024; /* on --accent 9.2:1 */ + --accent-2: #B5D5F0; --accent-3: #F7D08A; + --good: #9DD9A3; --warn: #F7D08A; --bad: #EF9A9A; + --grad: linear-gradient(135deg, #F49BB8, #B5D5F0 55%, #C4A0E8); + --grad-soft: linear-gradient(135deg, #FBEEF3, #EAF4FC); + --radius: 24px; --radius-sm: 16px; --radius-lg: 32px; + --shadow: 0 8px 28px rgba(244,155,184,.18); --shadow-lg: 0 24px 70px rgba(181,213,240,.3); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { width: var(--stage-w, 1920px); height: var(--stage-h, 1080px); overflow: hidden; } +.stage { + position: relative; width: var(--stage-w, 1920px); height: var(--stage-h, 1080px); overflow: hidden; + background: var(--bg); color: var(--fg); font-family: var(--font-body); +} +.brand { + position: absolute; top: 44px; left: 64px; + font-size: var(--fs-tiny); letter-spacing: .14em; color: var(--muted); +} +.slide-num { + position: absolute; bottom: 40px; right: 64px; + font-size: var(--fs-tiny); letter-spacing: .1em; color: var(--muted); +} +.accent { color: var(--accent); } +.gradient-text { background: var(--grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; } + +/* ── 排版角色 ───────────────────────────────────────────────── */ +.kicker { font-size: var(--fs-tiny); font-weight: 600; color: var(--accent); letter-spacing: .08em; } +.eyebrow { font-size: var(--fs-tiny); font-weight: 500; letter-spacing: .16em; color: var(--fg-3); } +.lede { font-size: var(--fs-h3); line-height: 1.55; color: var(--muted); font-weight: 300; max-width: 1400px; } + +/* ── 卡片 / 标签 / 分隔 ─────────────────────────────────────── */ +.card { + background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); + padding: var(--sp-4) var(--sp-5); box-shadow: var(--shadow); position: relative; overflow: hidden; +} +.card-soft { background: var(--panel-2); border-color: transparent; box-shadow: none; } +.card-outline { background: transparent; border: 1.5px solid var(--line-strong); box-shadow: none; } +.card-accent { border-top: 3px solid var(--accent); } +.pill { + display: inline-block; padding: 4px 14px; border-radius: 999px; font-size: var(--fs-tiny); + font-weight: 500; background: var(--panel-2); color: var(--muted); border: 1px solid var(--line); +} +.pill-accent { + background: var(--panel-2); + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); border-color: var(--line); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); +} +.divider-accent { height: 4px; width: 72px; background: var(--accent); border-radius: 2px; } + +/* ── 图片框(硬规则: 图片必须套框, 禁止裸放 ) ───────────── + .img-frame 比例与裁切归框所有; 图片只管填满 + .img-frame.contain 不裁切(截图/图表/logo 必须用这个), 居中留白 + --img-ratio 框比例(默认 16/10); --img-pos 主体位置(如 top 保住头部) + .img-scrim 底部渐变压暗, 让白字压在照片上仍可读 */ +.img-frame { + position: relative; margin: 0; overflow: hidden; display: block; + border-radius: var(--radius); box-shadow: var(--shadow); background: var(--panel-2); + aspect-ratio: var(--img-ratio, 16/10); +} +.img-frame > img { width: 100%; height: 100%; object-fit: cover; object-position: var(--img-pos, center); display: block; } +.img-frame.contain { background: var(--bg-soft); box-shadow: none; border: 1px solid var(--line); } +.img-frame.contain > img { object-fit: contain; } +.img-frame.fill { height: 100%; aspect-ratio: auto; } +.img-scrim { position: absolute; inset: 0; background: linear-gradient(180deg, transparent 42%, rgba(8,10,20,.72)); } +.img-cap { margin: 10px 2px 0; font-size: var(--fs-tiny); line-height: 1.5; color: var(--fg-3); } +.img-tag { + position: absolute; top: 14px; left: 14px; padding: 5px 14px; border-radius: 999px; + background: rgba(10,12,20,.55); color: #fff; font-size: var(--fs-tiny); letter-spacing: .06em; +} + +/* ── 免责声明 / 出处标注行(受监管题材: 财经/医疗/法律/政策, 见 references/compliance.md) + 小字不抢视觉; 出现在 closing 张并停留 ≥3s; 口播不念、不进字幕; 加了它也不替代核实 */ +.disclaimer { font-size: var(--fs-tiny); line-height: 1.6; color: var(--fg-3); max-width: 1240px; } +.disclaimer-box { border-left: 2px solid var(--line-strong); padding-left: var(--sp-3); } + +/* ── 分步入场系统 ────────────────────────────────────────────── + 要入场的块: data-stage="1|2|3" + fx-* 工具类, 例: +

大标题

+

展开内容

+ 延迟由管线按口播实测时长注入 --t1/--t2/--t3, HTML 里不要写死。 + 同层内错峰: 用容器 .fx-stagger(基准时刻用 style="--stagger-base:var(--t3)" 指定), + 或内联 style="animation-delay:calc(var(--t2) + 150ms)" + ⚠ data-stage 必须与 fx-* 类同时用 —— 只有 data-stage 没有动画类, 元素会永远停在 opacity:0; + 容器用 .fx-stagger 时容器自身不要再加 data-stage(子元素自己管入场)。 + 氛围动画(无限循环, 不承载信息)不加 data-stage: fx-pulse / fx-shimmer / fx-kenburns */ +[data-stage] { opacity: 0; --fx-delay: 0ms; } +[data-stage="1"] { --fx-delay: var(--t1); } +[data-stage="2"] { --fx-delay: var(--t2); } +[data-stage="3"] { --fx-delay: var(--t3); } + +/* 入场(有限时长, 可被逐帧 seek)。延迟写进 shorthand 的变量槽 —— 不能用独立的 + animation-delay 声明: 它与下面的 .fx-* 简写同优先级且更靠前, 会被简写重置为 0, + 导致所有 stage 都在 0 秒入场(2026-09-17 实测确认的坑)。inline animation-delay 仍可覆盖(错峰用)。 */ +.fx-up { animation: fx-up var(--dur-mid) var(--ease-out) var(--fx-delay, 0ms) both; } +.fx-fade { animation: fx-fade var(--dur-fast) var(--ease-out) var(--fx-delay, 0ms) both; } +.fx-grow { animation: fx-grow var(--dur-slow) var(--ease-out) var(--fx-delay, 0ms) both; } +.fx-blur { animation: fx-blur .8s var(--ease-out) var(--fx-delay, 0ms) both; } /* 模糊聚焦 */ +.fx-rise { animation: fx-rise .9s var(--ease-out) var(--fx-delay, 0ms) both; } /* 上浮+微缩放+去模糊 */ +.fx-pop { animation: fx-pop .7s cubic-bezier(.22,1.3,.36,1) var(--fx-delay, 0ms) both; } /* 过冲弹入 */ +.fx-spotlight { animation: fx-spotlight 1.1s var(--ease-out) var(--fx-delay, 0ms) both; } /* 圆形揭示 */ +.fx-ripple { animation: fx-ripple 1.2s var(--ease-out) var(--fx-delay, 0ms) both; } /* 斜角冲开 */ +.fx-glitch { animation: fx-glitch .8s steps(5, end) var(--fx-delay, 0ms) both; } /* 故障切入 */ +.fx-draw { animation: fx-draw 1.2s var(--ease-in-out) var(--fx-delay, 0ms) both; } /* 需元素自设 stroke-dasharray:800 */ +.fx-grow-x { transform-origin: left center; animation: fx-grow-x-kf .9s var(--ease-out) var(--fx-delay, 0ms) both; } /* 图表: 条形从左长出 */ +.fx-grow-y { transform-origin: bottom center; animation: fx-grow-y-kf .9s var(--ease-out) var(--fx-delay, 0ms) both; } /* 图表: 柱状从底长出 */ + +${NOFX_CSS} + +@keyframes fx-up { from { opacity: 0; transform: translateY(32px); } to { opacity: 1; transform: none; } } +@keyframes fx-fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes fx-grow { from { opacity: 0; transform: scale(.9); } to { opacity: 1; transform: scale(1); } } +@keyframes fx-blur { from { opacity: 0; filter: blur(18px); } to { opacity: 1; filter: none; } } +@keyframes fx-rise { from { opacity: 0; transform: translateY(60px) scale(.97); filter: blur(6px); } to { opacity: 1; transform: none; filter: none; } } +@keyframes fx-pop { 0% { opacity: 0; transform: scale(.6); } 60% { transform: scale(1.04); } 100% { opacity: 1; transform: scale(1); } } +@keyframes fx-spotlight { from { clip-path: circle(0% at 50% 50%); opacity: 1; } to { clip-path: circle(140% at 50% 50%); opacity: 1; } } +@keyframes fx-ripple { from { clip-path: circle(0% at 20% 80%); opacity: .4; } to { clip-path: circle(160% at 20% 80%); opacity: 1; } } +@keyframes fx-glitch { + 0% { opacity: 0; transform: translateX(0); clip-path: inset(0 0 0 0); } + 20% { opacity: 1; transform: translateX(-6px); clip-path: inset(20% 0 30% 0); } + 40% { transform: translateX(4px); clip-path: inset(50% 0 10% 0); } + 60% { transform: translateX(-3px); clip-path: inset(10% 0 60% 0); } + 80% { transform: translateX(2px); clip-path: inset(0 0 0 0); } + 100% { opacity: 1; transform: none; } +} +@keyframes fx-draw { from { stroke-dashoffset: 800; opacity: 1; } to { stroke-dashoffset: 0; opacity: 1; } } +/* 注意: 只做 transform 的动画必须显式带上 opacity:1 —— [data-stage] 的基础态是 opacity:0, + 靠动画抬回 1; 关键帧不碰 opacity 的动画会让元素永远隐形(2026-09-18 实测) */ +@keyframes fx-grow-x-kf { from { transform: scaleX(0); opacity: 1; } to { transform: scaleX(1); opacity: 1; } } +@keyframes fx-grow-y-kf { from { transform: scaleY(0); opacity: 1; } to { transform: scaleY(1); opacity: 1; } } + +/* 氛围(无限循环, 不参与时长计算) */ +.fx-pulse { animation: fx-pulse 2.4s var(--ease-in-out) infinite; } +@keyframes fx-pulse { 0%, 100% { opacity: .55; } 50% { opacity: 1; } } +.fx-shimmer { position: relative; overflow: hidden; } +.fx-shimmer::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(110deg, transparent 40%, rgba(255,255,255,.5) 50%, transparent 60%); + transform: translateX(-100%); animation: fx-shimmer-kf 2.4s var(--ease-in-out) infinite; +} +@keyframes fx-shimmer-kf { to { transform: translateX(100%); } } +.fx-kenburns { animation: fx-kenburns-kf 14s ease-in-out infinite alternate; } +@keyframes fx-kenburns-kf { from { transform: scale(1) translate(0, 0); } to { transform: scale(1.15) translate(-2%, -1%); } } + +${CHART_CSS} + +/* 依次入场容器: 子元素逐个上浮, 基准时刻取 --stagger-base(默认 --t2) */ +.fx-stagger > * { opacity: 0; animation: fx-rise .65s var(--ease-out) both; } +.fx-stagger > *:nth-child(1) { animation-delay: calc(var(--stagger-base, var(--t2)) + 0ms); } +.fx-stagger > *:nth-child(2) { animation-delay: calc(var(--stagger-base, var(--t2)) + 120ms); } +.fx-stagger > *:nth-child(3) { animation-delay: calc(var(--stagger-base, var(--t2)) + 240ms); } +.fx-stagger > *:nth-child(4) { animation-delay: calc(var(--stagger-base, var(--t2)) + 360ms); } +.fx-stagger > *:nth-child(5) { animation-delay: calc(var(--stagger-base, var(--t2)) + 480ms); } +.fx-stagger > *:nth-child(6) { animation-delay: calc(var(--stagger-base, var(--t2)) + 600ms); } +.fx-stagger > *:nth-child(7) { animation-delay: calc(var(--stagger-base, var(--t2)) + 720ms); } +.fx-stagger > *:nth-child(n+8) { animation-delay: calc(var(--stagger-base, var(--t2)) + 840ms); } +`; + +const TEMPLATE_HTML = ` + + + + + + + + +
+ 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 — 语种必须与音色匹配, 且影响语速基准与字幕行宽校验。', + topic, + lang: "zh", + voice: 'Chinese (Mandarin)_Gentleman', + speed: { default: 1.0, first: 0.95, last: 0.95 }, + 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: 只给已有项目补新版 tokens.css 里新增的规则(幂等, 不碰其他文件)。 +// 老项目缺 no-fx 规则时 会静默失效(只关动画不把 opacity 抬回来, 页面反而空白); +// 缺图表工具箱时新配方会静默半死(条形不生长 / 环形不扫出 / 数字不滚动)。 +if (argv.includes('--upgrade-css')) { + const cssPath = path.join(dir, 'slides', 'tokens.css'); + if (!fs.existsSync(cssPath)) { console.error(`✗ 找不到 ${cssPath}`); process.exit(1); } + let css = fs.readFileSync(cssPath, 'utf8'); + const needNofx = !hasNofxRules(css); + const needChart = !hasChartKit(css); + if (!needNofx && !needChart) { console.log(`无需升级: ${cssPath} 已含 no-fx 规则与图表工具箱`); process.exit(0); } + if (needNofx) css = css.replace(/\s*$/, '\n') + `\n/* 以下由 init-project --upgrade-css 追加(2026-09-18): 老项目缺这段时 失效 */\n${NOFX_CSS}\n`; + if (needChart) css = css.replace(/\s*$/, '\n') + `\n/* 以下由 init-project --upgrade-css 追加(2026-09-18): 图表动效与图表原语(缺了则条形不生长/环形不扫出/数字不滚动) */\n${CHART_CSS}\n`; + fs.writeFileSync(cssPath, css); + console.log(`✓ 已补:${needNofx ? ' no-fx 规则' : ''}${needChart ? ' 图表工具箱(fx-grow-w/h · fx-sweep · fx-count · fx-dot + .chart 原语)' : ''} → ${cssPath}`); + 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 3 终态预览 + 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/tests/chart-kit.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/chart-kit.test.mjs new file mode 100644 index 00000000..30eb76f8 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/chart-kit.test.mjs @@ -0,0 +1,77 @@ +// 图表工具箱: 模板里必须真带着这些规则, 且"关动效 = 终态"这条不变量成立。 +// 覆盖过三类静默失败: 缺 keyframes → 条形不生长; 静态默认 ≠ 终值 → 关动效后是空的; +// 柱高相对整列算 → 被 flex-shrink 压回, 柱高不再等于数值。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runSkill, tmpdir } from './helpers.mjs'; +import { CHART_CSS, hasChartKit } from '../scripts/chart-css.mjs'; + +const proj = tmpdir(); +const r = runSkill('init-project.mjs', [proj, '--topic', 'T']); +assert.equal(r.status, 0, r.stderr); +const css = fs.readFileSync(path.join(proj, 'slides', 'tokens.css'), 'utf8'); + +describe('图表工具箱 · 模板内容', () => { + test('五个新动效类都在, 且关键帧都声明了 opacity(否则入场后永远隐形)', () => { + for (const cls of ['fx-grow-w', 'fx-grow-h', 'fx-sweep', 'fx-count', 'fx-dot']) { + assert.ok(css.includes('.' + cls + ' {') || css.includes('.' + cls + '{'), `缺 .${cls}`); + } + const names = ['fx-grow-w-kf', 'fx-grow-h-kf', 'fx-sweep-kf', 'fx-count-kf', 'fx-dot-kf']; + for (const n of names) { + const i = css.indexOf('@keyframes ' + n); + assert.ok(i > -1, `缺关键帧 ${n}`); + const body = css.slice(i, css.indexOf('\n}', i)); + assert.match(body, /opacity\s*:/, `${n} 缺 opacity(该元素会永远隐形)`); + } + }); + + test('注册属性存在(没有它, 扫出/数字滚动无法被逐帧 seek)', () => { + assert.match(css, /@property\s+--pv\s*\{\s*syntax:\s*''/); + assert.match(css, /@property\s+--cnt\s*\{\s*syntax:\s*''/); + }); + + test('静态默认 = 终值(关掉动效后画面停在完成态, 不是空的)', () => { + assert.match(css, /\.fx-sweep\s*\{[^}]*--pv:\s*var\(--p-to/); + assert.match(css, /\.fx-count\s*\{[^}]*--cnt:\s*var\(--n-to/); + assert.match(css, /\.fx-grow-w\s*\{[^}]*width:\s*var\(--w/); + }); + + test('柱状三层结构齐全(绘图区 / 高度基准 / 标签), 网格在绘图区里', () => { + assert.match(css, /\.chart-plot-cell\s*\{/, '缺 .chart-plot-cell'); + assert.match(css, /\.chart-plot-cell\.grid\s*\{[^}]*repeating-linear-gradient/, '网格必须画在绘图区上(否则与柱高两把尺子)'); + assert.match(css, /\.chart-bar-wrap\s*\{/, '缺 .chart-bar-wrap(柱高的百分比基准)'); + assert.match(css, /\.chart-bar-wrap\s+\.chart-val\s*\{[^}]*position:\s*absolute/, '数值要贴各自柱顶'); + assert.match(css, /\.chart-col\s*\{[^}]*grid-template-rows/, '列必须是 [绘图区 1fr] + [轴标签]'); + assert.match(css, /\.chart-plot-cell\s+\.chart-target\s*\{/, '目标虚线要落在绘图区里(才有 0 与轴上限的参照)'); + }); + + test('横向条形的可比性: 行是三列定宽 grid, 数值外置不影响轨道长度', () => { + assert.match(css, /\.chart-row\s*\{[^}]*grid-template-columns:\s*var\(--lbl-w[^;]*var\(--val-w/, '行必须是定宽三列'); + assert.match(css, /\.chart-val\.out\s*\{/, '缺 .chart-val.out(条外数值)'); + assert.match(css, /\.chart-bar\.dim\s+\.chart-val\s*\{/, '弱化条上的数值要自动改墨色(白字读不出来)'); + }); +}); + +describe('图表工具箱 · 老项目升级', () => { + test('--upgrade-css 会把图表工具箱补进老 tokens.css, 且幂等', () => { + const old = tmpdir(); + fs.mkdirSync(path.join(old, 'slides'), { recursive: true }); + fs.writeFileSync(path.join(old, 'slides', 'tokens.css'), ':root { --accent: #111; }\n'); + assert.equal(hasChartKit(':root { --accent: #111; }'), false); + const r1 = runSkill('init-project.mjs', [old, '--upgrade-css']); + assert.equal(r1.status, 0, r1.stderr); + const after = fs.readFileSync(path.join(old, 'slides', 'tokens.css'), 'utf8'); + assert.match(after, /@property\s+--pv/); + assert.match(after, /fx-sweep-kf/); + assert.ok(after.startsWith(':root { --accent: #111; }'), '原内容必须在前'); + const r2 = runSkill('init-project.mjs', [old, '--upgrade-css']); + assert.equal(fs.readFileSync(path.join(old, 'slides', 'tokens.css'), 'utf8'), after, '第二次跑不得再追加'); + assert.ok(r2.stdout.includes('无需升级')); + }); + + test('CHART_CSS 自身通过 hasChartKit 判定(防止判定与内容脱节)', () => { + assert.equal(hasChartKit(CHART_CSS), true); + }); +}); From 472ebdf1f8ef2d6ad2e1d7fdf144b026de0398db Mon Sep 17 00:00:00 2001 From: Hahaha Date: Fri, 18 Sep 2026 15:18:50 +0800 Subject: [PATCH 19/24] =?UTF-8?q?ci:=20smoke=20=E6=B8=85=E5=8D=95=E5=8A=A0?= =?UTF-8?q?=20chart-kit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/html2video-for-mcode-smoke.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/html2video-for-mcode-smoke.yml b/.github/workflows/html2video-for-mcode-smoke.yml index 89e603bf..a24a7b69 100644 --- a/.github/workflows/html2video-for-mcode-smoke.yml +++ b/.github/workflows/html2video-for-mcode-smoke.yml @@ -45,6 +45,7 @@ jobs: 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 \ --test-timeout=60000 - name: Render smoke (init → timings → static gate → capture → build) From 52278f2f797bbc35bd58efeb78e53c316dc96f8e Mon Sep 17 00:00:00 2001 From: Hahaha Date: Fri, 18 Sep 2026 16:58:19 +0800 Subject: [PATCH 20/24] =?UTF-8?q?feat:=20=E8=A1=A8=E6=A0=BC=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=AE=B1=20+=20=E5=8F=AF=E8=AF=BB=E6=80=A7=E7=A1=AC?= =?UTF-8?q?=E6=8C=87=E6=A0=87(=E6=AD=A3=E6=96=87=E5=8F=B7/=E8=A1=8C?= =?UTF-8?q?=E9=AB=98=2072)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Wzdhehe/html2video-for-mcode/CHANGELOG.md | 12 +++- .../Wzdhehe/html2video-for-mcode/README.md | 9 ++- .../html2video-for-mcode/README.zh-CN.md | 4 +- .../skills/html2video-for-mcode/SKILL.md | 3 +- .../html2video-for-mcode/evals/evals.json | 7 ++ .../references/authoring.md | 69 ++++++++++++++++++- .../scripts/init-project.mjs | 14 ++-- .../scripts/table-css.mjs | 49 +++++++++++++ .../tests/table-kit.test.mjs | 66 ++++++++++++++++++ 9 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/table-css.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/table-kit.test.mjs diff --git a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md index 53ce3fe9..5b5d76c1 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md +++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md @@ -8,7 +8,7 @@ - **覆盖拒绝**:`init-project.mjs` 对已存在且非空的目录直接拒绝(列出将被覆写的 5 个生成文件),需显式 `--force`;`fetch-official-images.mjs` 的 `--out-dir` 默认收监在工作目录内、已存在文件不覆盖;`prep-image.mjs --crop` 输出已存在需 `--force`。顺带修 `--topic` 未转义即插入模板 HTML 的问题。 - **ASR 端点白名单**:API Key 只发官方域(`api.minimaxi.com` / `api.minimax.io`);`--base-url` / `MINIMAX_BASE_URL` 指向其他地址一律硬拒绝,自建网关需显式 `--allow-any-endpoint`(打印醒目警告)。此前被偷换的环境变量可把 Key 发往任意端点。 - **抓图 SSRF 收紧**:新增 `scripts/url-policy.mjs`(纯函数)。拦 loopback / 链路本地(含云元数据 169.254.169.254)/ 私网 / CGNAT / 无点主机名;只允许 http(s),`file://` 需显式 `--allow-file`;禁带 userinfo 的 URL;`maxRedirects:0` 手动跟重定向且**逐跳**复用同一策略;响应大小上限默认 30MB(`--max-mb`);落盘文件名清洗补 Windows 保留名。 -- **可执行测试**:新增 `tests/`(node:test,零依赖,从仓库根 `node --test` 自动发现 → 被 `npm run check` 真实执行)—— `safe-paths`(恶意 id/路径 + canary 完好性 + symlink 逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(白名单拒绝 + 本地假服务器收到 Bearer 假 Key 的正向证据)、`fetch-policy`(host/URL/重定向/文件名 44 例)、`render-smoke`(init → 静音音频 → plan-timings → check-slides → capture → build-video 全链出片)。首轮 72 例(后续又加了 `preview-page` 与 `tokens-fx`,见下,现共 114 例, 8 个文件),本地全绿。另附 scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml`(path-filter 只在本插件变更时跑,装 ffmpeg + playwright 后真实执行,含渲染冒烟)。 +- **可执行测试**:新增 `tests/`(node:test,零依赖,从仓库根 `node --test` 自动发现 → 被 `npm run check` 真实执行)—— `safe-paths`(恶意 id/路径 + canary 完好性 + symlink 逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(白名单拒绝 + 本地假服务器收到 Bearer 假 Key 的正向证据)、`fetch-policy`(host/URL/重定向/文件名 44 例)、`render-smoke`(init → 静音音频 → plan-timings → check-slides → capture → build-video 全链出片)。首轮 72 例(后续又加了 `preview-page` 与 `tokens-fx`,见下,现共 120 例, 9 个文件),本地全绿。另附 scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml`(path-filter 只在本插件变更时跑,装 ffmpeg + playwright 后真实执行,含渲染冒烟)。 **图表与动效** @@ -47,6 +47,16 @@ - 新增图表类型配方:bullet(实际 vs 目标)、slope(前后对比)、sparkline(数字旁迷你趋势),加上原有的横向条形/柱状/折线/环形/进度条,共 7 个配方 + 动效选型表。 - 测试 +7 例(共 **114** 例 / 8 个文件):`chart-kit`(动效类与关键帧 opacity、`@property`、静态默认=终值、柱状三层结构与网格位置、行三列定宽、`--upgrade-css` 补工具箱且幂等);workflow 显式清单同步。 +**表格工具箱(2026-09-18 深夜,用户问"现在有哪几类表格")** + +起因:查了一遍 —— 真 `` 版式**只有一种**(一个"季度/营收/同比"示例),`kpi-grid`/`roadmap`/`comparison` 只是卡片化的表格感, 而且 **tokens.css 里根本没有表格原语**, 每页各写一套内联样式。表格是模板系统里最薄的一环, 补上: + +- **四个表格原语**(`scripts/table-css.mjs` 唯一来源;init-project 写入 tokens.css, `--upgrade-css` 给老项目补, 幂等):`.tbl`(数据表:表头弱化、只画横线、数值列 `.num` 右对齐等宽、`.up/.down` 涨跌走令牌、`.key` 高亮行、`.sum` 合计行)、`.kv`(规格表 / 时间事件表)、`.matrix`(对比矩阵:勾叉 + `.hi` 高亮列)、`.rank`(排名表:名次 + 内联微缩条 + 数值, 条长同样 = 数值 ÷ 轴上限)。 +- **可读性硬指标写进原语并被测试盯着**:主数据 `var(--fs-body)`(30px)、表头 `--fs-caption`、行高 ≥72px、只画横线不画竖线不要斑马纹。实测教训:最初表格正文用了 `--fs-caption`(24px), 渲出来自己看着都吃力 —— 视频还要在手机上看, 已提到正文号。 +- **文档**:`references/authoring.md` 新增"表格工具箱"章节(七条纪律 + 五个配方 + 选型速查), `table` 版式行指向该章节;SKILL.md 症状表加一行(表格在手机上看不清 → 用原语, 别用 caption 号)。 +- 示例两屏(数据表 + 排名表 / 对比矩阵 + 规格表)在 `_probe/tableproj`(内部验证件);几何断言:行高 80/72、数值列各自右对齐同一竖线、无内容侵入字幕带。 +- 测试 +6 例(共 **120** 例 / 9 个文件):`table-kit`(四种原语存在、主数据是正文号、行高 72、涨跌走令牌、**不得出现竖线或斑马纹**、`--upgrade-css` 补原语且幂等);workflow 清单同步。 + **文档** - 开工对齐"主题与受众"→"**主题与领域**"(受监管题材必问免责声明与数据出处标注);新增 `references/compliance.md`(财经口播红线、数字三要件、涨跌色按受众翻转、免责声明写法、医疗/法律/广告法、Gate 清单);`tokens.css` 增 `--up/--down` 与 `.disclaimer`;`check-slides.mjs` 增整片级财经关键词自查。 diff --git a/plugins/Wzdhehe/html2video-for-mcode/README.md b/plugins/Wzdhehe/html2video-for-mcode/README.md index 274a9cf9..135e6e27 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/README.md +++ b/plugins/Wzdhehe/html2video-for-mcode/README.md @@ -73,7 +73,9 @@ policy, `no-fx` rules, chart CSS — under `skills/html2video-for-mcode/scripts/ intact. The chart toolbox enforces the data-viz basics in the template itself: bars grow by animating `width` (scaling would squash the numbers), column heights resolve against the plot area so they always match their values, the grid shares the bars' scale, and charts stay clear of - the subtitle band. + the subtitle band. Tables ship four shapes (`.tbl` data table, `.kv` spec list, `.matrix` feature + matrix, `.rank` ranking with inline bars) with type and row heights sized to survive being watched + on a phone. - **Rendering is gated.** A static check refuses to render slides with undefined CSS variables, missing images, external resources, or entrance animations without an animation class — the failure modes that otherwise ship a video that looks broken while every script reports success. @@ -201,7 +203,7 @@ The Skill ships an executable test suite (`skills/html2video-for-mcode/tests/`, node --test "plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/*.test.mjs" ``` -114 tests in eight files: `safe-paths` (malicious slide ids / paths, canary intactness, symlink +120 tests in nine files: `safe-paths` (malicious slide ids / paths, canary intactness, symlink escapes), `no-clobber` (refusing to overwrite), `endpoint-allowlist` (key never leaves the official hosts — plus a local server that proves the gate sits before the request), `fetch-policy` (SSRF, `file://`, redirect and filename rules), `preview-page` (snapshot timing injection, `base` ordering, @@ -210,7 +212,8 @@ the generated `tokens.css` must declare `opacity`, `no-fx` must reset it, `--upg idempotent) and `fetch-policy`/`no-clobber` (the `--url` download route obeys the same SSRF, redirect, size and no-clobber rules), plus `chart-kit` (the chart toolbox is really in the generated `tokens.css`: keyframes, registered -properties, and "off = final state") and `render-smoke` (init → timings → static gate → capture → +properties, and "off = final state"), `table-kit` (table primitives keep their legibility rules: +body-size type, ≥72px rows, token-driven up/down colours) and `render-smoke` (init → timings → static gate → capture → build, end to end). The render smoke test and three ffmpeg-dependent path checks need ffmpeg and Chromium; where those are missing they skip with a stated reason, and the scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml` installs them and runs everything for real. diff --git a/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md b/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md index 022cf1a8..d09128ba 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md +++ b/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md @@ -50,7 +50,7 @@ my-video/ - **动画真的进视频**:捕获用逐帧步进而不是录屏,入场动画是渲染出来的,不是冻结在终态。 - **渲染有闸门**:静态检查会拒绝未定义 CSS 变量、图片缺失、外链资源、入场动画缺动画类的页面,也会拒绝"关键帧从不把 `opacity: 0` 基础态抬回来"的入场动画(那类元素会在成片里静默隐形)—— 这些正是"视频看着坏了但每个脚本都报成功"的元凶。 - **受监管题材走合规流程**:财经 / 医疗 / 法律 / 政务类片子开工就问免责声明与出处标注,每个数字钉住口径(口径 + 币种 + 时点),涨跌色按受众市场翻转(A 股 / 港股是红涨绿跌)—— 见 `references/compliance.md`。 -- **图表纯 CSS/SVG 自绘,动效可一键关**:不引图表库、不用 canvas(离线取不到,canvas 动画也逐帧 seek 不到);根元素(或任意容器,单张生效)加 `no-fx` 即可关掉全部入场与氛围动效,渲染自动走静态帧、时长不变。图表工具箱还把数据可视化的底线写进模板:条形用动画 `width` 生长(缩放会把数字压扁)、柱高相对绘图区算(保证与数值一致)、网格与柱高共用一把尺子、图表避开字幕带。 +- **图表纯 CSS/SVG 自绘,动效可一键关**:不引图表库、不用 canvas(离线取不到,canvas 动画也逐帧 seek 不到);根元素(或任意容器,单张生效)加 `no-fx` 即可关掉全部入场与氛围动效,渲染自动走静态帧、时长不变。图表工具箱还把数据可视化的底线写进模板:条形用动画 `width` 生长(缩放会把数字压扁)、柱高相对绘图区算(保证与数值一致)、网格与柱高共用一把尺子、图表避开字幕带。表格有四种形态(`.tbl` 数据表 / `.kv` 规格表 / `.matrix` 对比矩阵 / `.rank` 排名表),字号与行高按"手机上也要看清"定死。 ## 安装 @@ -139,7 +139,7 @@ Windows / macOS / Linux。脚本全部是 Node ESM,不依赖特定 shell。Windo node --test "plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/*.test.mjs" ``` -八个文件共 114 例:`safe-paths`(恶意 slide id / 路径、canary 完好性、符号链接逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(Key 不离开官方域,并用本地服务器证明闸门在请求之前)、`fetch-policy`(SSRF、`file://`、重定向与文件名规则)、`preview-page`(快照注入实测延迟、`base` 顺序、自包含无外链、越界拒绝、无计时器)、`tokens-fx`(模板里每个入场动画的关键帧必须声明 `opacity`、`no-fx` 必须重置基础态、`--upgrade-css` 幂等)、`chart-kit`(模板里真带着图表工具箱:keyframes / 注册属性 /「关动效 = 终态」不变量)与 `render-smoke`(init → 对时 → 静态闸门 → 截图 → 成片全链)。渲染冒烟与三例依赖 ffmpeg 的路径检查需要 ffmpeg 与 Chromium:缺失时按原因 skip,scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml` 会装齐依赖并把全部用例真跑一遍。 +九个文件共 120 例:`safe-paths`(恶意 slide id / 路径、canary 完好性、符号链接逃逸)、`no-clobber`(覆盖拒绝)、`endpoint-allowlist`(Key 不离开官方域,并用本地服务器证明闸门在请求之前)、`fetch-policy`(SSRF、`file://`、重定向与文件名规则)、`preview-page`(快照注入实测延迟、`base` 顺序、自包含无外链、越界拒绝、无计时器)、`tokens-fx`(模板里每个入场动画的关键帧必须声明 `opacity`、`no-fx` 必须重置基础态、`--upgrade-css` 幂等)、`chart-kit`(模板里真带着图表工具箱:keyframes / 注册属性 /「关动效 = 终态」不变量)、`table-kit`(表格原语的可读性硬指标:正文号、行高 ≥72px、涨跌走令牌)与 `render-smoke`(init → 对时 → 静态闸门 → 截图 → 成片全链)。渲染冒烟与三例依赖 ffmpeg 的路径检查需要 ffmpeg 与 Chromium:缺失时按原因 skip,scoped workflow `.github/workflows/html2video-for-mcode-smoke.yml` 会装齐依赖并把全部用例真跑一遍。 ## 排错 diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md index 32454e35..b248ce74 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md @@ -229,7 +229,7 @@ audio/*.mp3 assets/(含 MANIFEST.md) research/notes.md asr/(校验记录) ## 参考文件(按需读,别全读) -- `references/authoring.md` — 17 种版式规范(每种画面必含项)+ 内容量表 + 入场系统用法(含 stage 延迟实现原理)+ **图表工具箱**(图表原语、九条优雅纪律、7 个配方与动效选型)+ 主题速查 + 竖版说明 + 可抄的 HTML 片段 +- `references/authoring.md` — 17 种版式规范(每种画面必含项)+ 内容量表 + 入场系统用法(含 stage 延迟实现原理)+ **图表工具箱**(图表原语、九条优雅纪律、7 个配方与动效选型)+ **表格工具箱**(四种原语、七条纪律、五种形态)+ 主题速查 + 竖版说明 + 可抄的 HTML 片段 - `references/compliance.md` — 领域与合规(受监管题材必读):领域确认问法、财经口播红线、数字三要件、涨跌色按受众翻转、免责声明写法与位置、医疗/法律/广告法要点、收尾检查清单 - `references/research.md` — 资料搜集(来源分级、交叉验证硬规则、query 设计、矛盾处理、notes 模板) - `references/image-sources.md` — 配图与素材 SOP(三条取图路径、query 正/反词、两级筛选、图片框用法、裁切硬限制、视觉验证三件套、常见题材索引) @@ -264,6 +264,7 @@ audio/*.mp3 assets/(含 MANIFEST.md) research/notes.md asr/(校验记录) | 柱状图柱高与标注数值不符(高柱子被压矮) | 柱子直接放在 flex 列里按百分比设高: 基准是**整列**高度, 超过剩余空间会被 `flex-shrink` 压回去, 静默失真 | 把柱子包进 `.chart-plot-cell`(1fr 行), 百分比就相对绘图区算; 顺带得到基线与各列等高 | | 折线图缩在版面中间一小块 | SVG 的 `viewBox` 宽高比与容器不一致, `preserveAspectRatio` 把内容等比缩小居中 | 满宽用 `width:100%;height:auto`; 半宽/固定高就把 viewBox 改成接近容器的比例 | | 图表/图注被字幕压住 | 内容排进了字幕带(底部 84–168px、居中 73% 宽) | 版面容器 `padding-bottom: 190px` 起步; 见 authoring.md 的"字幕安全区" | +| 表格在手机上看不清 | 表格文字用了 caption 号(24px), 或行高被压到 72px 以下 | 用 `.tbl/.kv/.matrix/.rank` 原语:主数据是正文号(30px)、行高内置 72px;见 authoring.md"表格工具箱" | | 放映页里 iframe 是空白/图裂 | 副本的 `` 被清掉, 或 slides/ 被移动过 | 重跑 `preview-page.mjs` 重新生成快照;原文件不要手改(副本是快照,改 slides 后必须重跑) | | 图片显示 broken 图标 | 文件缺失,或 SVG 本身有问题(XML 错/依赖外部资源/缺尺寸) | `check-slides.mjs` 查路径;SVG 改 inline 进 HTML;capture 也会在渲染时点名哪张没加载 | | 财经片没免责声明 / 涨跌色反了 / 数字被质疑口径 | 开工没确认领域,默认色与默认措辞直接用了 | 读 `references/compliance.md`:结尾补 `.disclaimer` 行(停留 ≥3s)、指标卡改 `var(--up)/var(--down)` 并按受众市场翻转、每个数字补口径+币种+时点 | diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json index d9f0075b..41c916bc 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/evals/evals.json @@ -178,6 +178,13 @@ "prompt": "你这几张图表看着有点业余, 能不能做得更讲究一点? 图表库不给用(离线), 但只要 CSS/SVG。", "expected_output": "用 tokens.css 里的图表工具箱(`.chart*` 原语 + `fx-grow-w/h · fx-sweep · fx-count · fx-dot`;老项目先 `init-project --upgrade-css` 补齐)。优雅不是加特效, 是六条纪律: ①同类同色、只强调关键一条(其余 `.dim` 用不透明同色系; 彩虹色条形=业余感来源); ②数值等宽(`.chart-num`)且绝不放进被缩放的元素(`scaleX` 会把数字拉扁 → 带标签的条用 `fx-grow-w` 按 `width` 生长); ③有基线与**看得见**的细网格(网格画在 `.chart-plot-cell` 上, 与柱高共用一把尺子, 顶边即轴上限); ④一图只讲一件事(`.chart-note` 写在标题行右侧); ⑤零值/极小值保底; ⑥图注写口径与时点(`.chart-cap`)。另有四条版面纪律: **避开字幕安全区**(1080 高下底部 84–168px、居中 73% 宽留给字幕, 容器 `padding-bottom:190px` 起步); 在安全区内**垂直居中**(别堆在顶部); 数值统一右对齐同一列(都用 `.chart-val.out`); **柱高 = 数值 ÷ 轴上限、轴从 0 起**, 并把轴上限写进标题(凭手感写百分比 = 数据失真)。动效选型: 条形 `fx-grow-w`(带标签)/`fx-grow-x`(纯色块); 柱状 `fx-grow-y`(标签在外)/`fx-grow-h`(标签在内); 折线 `fx-draw` + `fx-dot`(标记规则统一: 全标或只标末点); 环形 `fx-sweep`(**真扫出**, 不用 `fx-pop` 硬弹)+ 中心 `fx-count`; 错峰 `--fx-delay:calc(var(--t2) + N×120ms)`。禁外链图表库(离线取不到、canvas 动画逐帧 seek 不到)、禁 AI 生图当图表、数值必须来自已核实口径。可加图表类型: bullet(实际 vs 目标, `.chart-target` 虚线)、slope(前后对比)、sparkline(数字旁迷你趋势)。", "files": [] + }, + { + "id": 27, + "name": "table-forms", + "prompt": "这个季度的数据我想用表格讲, 但我们的模板里表格好像只有一种样式?", + "expected_output": "表格有五种形态, 都走 tokens.css 的原语(老项目先 `init-project --upgrade-css` 补): ① 数据表 `.tbl`(多期/多主体对比: 表头弱化、数值列 `.num` 右对齐 + 等宽数字、涨跌 `.up/.down` 走 `var(--up)/var(--down)` 并按受众市场翻转、当前行 `.key` 高亮、合计行 `.sum`); ② 规格表 `.kv`(无表头, 左标签右值 —— 参数/条款); ③ 对比矩阵 `.matrix`(行维度 × 列方案, 单元格只放勾叉, `.hi` 高亮本方案列); ④ 排名表 `.rank`(名次 + 内联微缩条 + 数值, 条长 = 数值 ÷ 轴上限); ⑤ 时间事件表(用 `.kv` 两列)。七条纪律: 主数据正文号 ≥30px(视频要在手机上看; 实测 caption 24px 吃力)、行高 ≥72px、一页一表 ≤6 行、数值右对齐等宽、涨跌走令牌、只画横线不画竖线不要斑马纹、高亮只给一行、表注写口径与时点。表格不要和图表放同一页(一页一件事)。", + "files": [] } ] } diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md index 62dcc69e..cb286901 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/references/authoring.md @@ -181,7 +181,7 @@ console.log(answer.output_text); |---|---|---|---|---| | kpi-grid | 一组指标 | 标题(s1) + 3–4 张指标卡,带涨跌与语义色(s2)。**涨跌色用 `var(--up)/var(--down)`,A 股/港股受众要按红涨绿跌覆写(见 compliance.md)** | 25–40 | 2 句 | | stat-highlight | 一个数字定生死 | 巨数字(≥200px,可用 .gradient-text)(s1) + 一句说明(s2) | 15–28 | 2 句 | -| table | 多行对比 | 标题(s1) + ≥3 行数据表,数值右对齐(s2) | 25–40 | 2 句 | +| table | 多行对比 | 标题(s1) + ≥3 行数据表,数值右对齐(s2)。**用 `.tbl` 原语;五种形态与纪律见"表格工具箱"** | 25–40 | 2 句 | | timeline | 发展历程 | 标题(s1) + ≥4 个时间点(横轴 + 标签)(s2) | 25–40 | 2 句 | | roadmap | 阶段规划 | 标题(s1) + 3–4 列(NOW/NEXT/LATER 之类)(s2) | 22–35 | 2 句 | | comparison | 前后 / 优劣对照 | 标题(s1) + 左右两栏加竖分隔(s2) | 22–35 | 2 句 | @@ -474,6 +474,73 @@ console.log(answer.output_text); --- +# 表格工具箱(数据表的五种形态) + +表格是最容易做丑的一类:行高不够、数字不右对齐、涨跌色用错、满屏网格线。原语都在 `tokens.css` 里(`.tbl / .kv / .matrix / .rank`),**老项目先 `node scripts/init-project.mjs <项目> --upgrade-css` 补上**(幂等),否则这些类全不生效。 + +## 表格纪律(七条) + +1. **主数据用正文号、行高 ≥72px**:`.tbl`/`.kv`/`.matrix`/`.rank` 的正文已是 `var(--fs-body)`(1080p 与手机上都读得清),表头才降到 `--fs-caption`;行高已内置 72px —— 别为了多塞一行把字号或行高压掉(实测 24px 表格在手机上看不清)。 +2. **一页一表, 最多 6 行**:超了就拆页或只留 top 5 + "其他"。表格页信息密度天然高,再挤就没人看。 +3. **数值右对齐 + 等宽**(`.num`/`.val`):小数点对齐是"专业感"的主要来源;数字用 `tabular-nums`,滚动/切换时不会抖。 +4. **涨跌色走令牌**(`.up`/`.down` → `var(--up)/var(--down)`):财经题材按受众翻转(A 股/港股红涨绿跌),见 compliance.md;非财经题材也别用绿红以外的自造色。 +5. **只画横线,不画竖线,不要斑马纹**:`.tbl` 的表头下边线 + 行间细线就够了;斑马纹和满格线会让画面变脏。 +6. **高亮只给一行**(`.key`:左侧 accent 竖条 + 8% 底色),通常就是"我们/当前阶段";合计行用 `.sum`(上边线加粗)。两者不要同时用在多行上。 +7. **表注写口径与时点**(用 `.chart-cap` 同一套):和图表一样,数字的可信度来自"什么时候、怎么算的"。 + +## 五种形态与配方 + +```html + +
+ + + + + + + +
季度营收同比毛利率
2026 Q13.2 亿+18%61.4%
2026 Q24.1 亿+27%63.8%
2026 Q33.9 亿-4%62.1%
合计11.2 亿+14%62.4%
+

口径: 集团合并报表 · 单位: 人民币 · 截至 2026-09-30

+ + + + + + + +
上下文窗口10,000,000 token
单次最长输出128,000 token
定价(输入 / 输出)¥0.8 / ¥2.4 每百万 token
上线时间2026-08-15
+ + + + + + + + + +
能力方案 A方案 B(本方案)方案 C
离线可用
成本可控
交付周期 ≤ 2 周
+

口径: 内部评测 · 2026-09 · 勾叉按"是否满足该维度硬指标"判定

+ + + + + + +
1产品 A1.86 亿
2产品 B1.17 亿
3产品 C0.67 亿
+ + + + + + + +
2021团队成立, 首个原型
2023产品上线, 首批 1 万用户
2026 Q2通过聆讯, 递交港股上市申请
+ + + +选型速查:多期/多主体对比 → ① 数据表;一对一条目 → ② 规格表(或 ⑤ 时间事件表);几套方案打勾叉 → ③ 对比矩阵;排名看差距 → ④ 排名表;要合计/小计 → ① + `.sum`;价格/评分分档 → ① 的 `.key` 高亮当前档。**表格和图表不要放同一页**(一页一件事);表格页的口播照旧要"读表",别把表格当背景。 + # 动效开关(三种粒度) | 粒度 | 做法 | 效果 | diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/init-project.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/init-project.mjs index 7240dad9..71be0a5f 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/init-project.mjs +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/init-project.mjs @@ -7,6 +7,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { NOFX_CSS, hasNofxRules } from './nofx-css.mjs'; import { CHART_CSS, hasChartKit } from './chart-css.mjs'; +import { TABLE_CSS, hasTableKit } from './table-css.mjs'; const argv = process.argv.slice(2); const dirArg = argv.find(a => !a.startsWith('--')); @@ -415,6 +416,8 @@ ${NOFX_CSS} ${CHART_CSS} +${TABLE_CSS} + /* 依次入场容器: 子元素逐个上浮, 基准时刻取 --stagger-base(默认 --t2) */ .fx-stagger > * { opacity: 0; animation: fx-rise .65s var(--ease-out) both; } .fx-stagger > *:nth-child(1) { animation-delay: calc(var(--stagger-base, var(--t2)) + 0ms); } @@ -497,11 +500,14 @@ if (argv.includes('--upgrade-css')) { let css = fs.readFileSync(cssPath, 'utf8'); const needNofx = !hasNofxRules(css); const needChart = !hasChartKit(css); - if (!needNofx && !needChart) { console.log(`无需升级: ${cssPath} 已含 no-fx 规则与图表工具箱`); process.exit(0); } - if (needNofx) css = css.replace(/\s*$/, '\n') + `\n/* 以下由 init-project --upgrade-css 追加(2026-09-18): 老项目缺这段时 失效 */\n${NOFX_CSS}\n`; - if (needChart) css = css.replace(/\s*$/, '\n') + `\n/* 以下由 init-project --upgrade-css 追加(2026-09-18): 图表动效与图表原语(缺了则条形不生长/环形不扫出/数字不滚动) */\n${CHART_CSS}\n`; + const needTable = !hasTableKit(css); + if (!needNofx && !needChart && !needTable) { console.log(`无需升级: ${cssPath} 已含 no-fx 规则、图表工具箱与表格原语`); process.exit(0); } + const stamp = '以下由 init-project --upgrade-css 追加(2026-09-18)'; + if (needNofx) css = css.replace(/\s*$/, '\n') + `\n/* ${stamp}: 老项目缺这段时 失效 */\n${NOFX_CSS}\n`; + if (needChart) css = css.replace(/\s*$/, '\n') + `\n/* ${stamp}: 图表动效与图表原语(缺了则条形不生长/环形不扫出/数字不滚动) */\n${CHART_CSS}\n`; + if (needTable) css = css.replace(/\s*$/, '\n') + `\n/* ${stamp}: 表格原语(缺了则 .tbl/.kv/.matrix/.rank 都不生效) */\n${TABLE_CSS}\n`; fs.writeFileSync(cssPath, css); - console.log(`✓ 已补:${needNofx ? ' no-fx 规则' : ''}${needChart ? ' 图表工具箱(fx-grow-w/h · fx-sweep · fx-count · fx-dot + .chart 原语)' : ''} → ${cssPath}`); + console.log(`✓ 已补:${needNofx ? ' no-fx 规则' : ''}${needChart ? ' 图表工具箱' : ''}${needTable ? ' 表格原语(.tbl/.kv/.matrix/.rank)' : ''} → ${cssPath}`); process.exit(0); } diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/table-css.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/table-css.mjs new file mode 100644 index 00000000..66eb31c9 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/table-css.mjs @@ -0,0 +1,49 @@ +// html2video-for-mcode · 表格原语的唯一来源。 +// init-project 把它写进 tokens.css; `--upgrade-css` 给老项目补这一段。 +// 为什么要有原语: 以前每种表都靠内联样式各写一套, 行高/对齐/涨跌色各页不一致 —— +// 而表格最容易出的问题正是"行高压不住、数字不右对齐、涨跌色用错"(见 authoring.md 表格纪律)。 + +export const TABLE_CSS = `/* ── 表格原语(数据表/规格表/对比矩阵/排名表统一用它) ───────────── + 1080p 下远处要读得清: 行高 ≥72px、数值右对齐且等宽、表头弱化、只留横线不画竖线。 + 涨跌色一律 var(--up)/var(--down)(财经按受众翻转, 见 compliance.md)。 */ +.tbl { width: 100%; border-collapse: collapse; font-size: var(--fs-body); } /* 主数据用正文号(≥30px): 视频在手机上也要读得清 */ +.tbl th { text-align: left; font-weight: 500; color: var(--fg-3); font-size: var(--fs-caption); + letter-spacing: .04em; padding: 0 0 var(--sp-2); border-bottom: 1px solid var(--line-strong); } +.tbl td { padding: var(--sp-3) var(--sp-4) var(--sp-3) 0; border-bottom: 1px solid var(--line); } +.tbl tr:last-child td { border-bottom: 0; } +.tbl tbody tr { height: 72px; } /* 行高保底: 再挤就远处读不出 */ +.tbl .num { text-align: right; padding-right: 0; font-family: var(--font-mono); + font-variant-numeric: tabular-nums; } +.tbl .up { color: var(--up); } +.tbl .down { color: var(--down); } +.tbl .muted { color: var(--muted); } +/* 高亮行(通常是"我们"或当前阶段)与合计行 */ +.tbl tr.key td { background: color-mix(in srgb, var(--accent) 8%, transparent); } +.tbl tr.key td:first-child { box-shadow: inset 3px 0 0 var(--accent); padding-left: var(--sp-4); } +.tbl tr.sum td { border-top: 1px solid var(--line-strong); font-weight: 600; color: var(--fg); } +/* 无表头规格表: 左标签右值, 只有细线 */ +.kv { width: 100%; border-collapse: collapse; font-size: var(--fs-body); } +.kv td { padding: var(--sp-3) 0; border-bottom: 1px solid var(--line); vertical-align: top; } +.kv td:first-child { color: var(--muted); width: 32%; } +.kv td:last-child { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +/* 对比矩阵: 行是维度、列是方案, 单元格只放勾叉/等级 */ +.matrix { width: 100%; border-collapse: collapse; font-size: var(--fs-body); text-align: center; } +.matrix th { color: var(--fg-3); font-size: var(--fs-caption); font-weight: 500; padding: 0 0 var(--sp-2); + border-bottom: 1px solid var(--line-strong); } +.matrix th:first-child, .matrix td:first-child { text-align: left; } +.matrix td { padding: var(--sp-3) 0; border-bottom: 1px solid var(--line); height: 72px; } +.matrix .yes { color: var(--accent); font-size: var(--fs-h3); } +.matrix .no { color: var(--fg-3); } +.matrix td.hi { background: color-mix(in srgb, var(--accent) 8%, transparent); } +/* 排名表: 名次 + 微缩条 + 数值(表格里的条形, 复用 --w 定长) */ +.rank { width: 100%; border-collapse: collapse; font-size: var(--fs-body); } +.rank td { padding: var(--sp-2) var(--sp-3) var(--sp-2) 0; border-bottom: 1px solid var(--line); height: 72px; } +.rank .no { color: var(--fg-3); font-family: var(--font-mono); width: 3em; } +.rank .bar { width: 46%; } +.rank .bar span { display: block; height: 12px; border-radius: 999px; background: var(--accent); } +.rank .bar span.dim { background: color-mix(in srgb, var(--accent) 30%, var(--bg)); } +.rank .val { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }`; + +export function hasTableKit(css) { + return /\.tbl\s+tbody\s+tr\s*\{[^}]*height:\s*72px/.test(String(css ?? '')) && /\.kv\s*\{/.test(String(css ?? '')); +} diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/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..2c60131e --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/table-kit.test.mjs @@ -0,0 +1,66 @@ +// 表格工具箱: 原语必须真在生成的 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'); + }); + + 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); + }); +}); From 4089ba5c898d0dc42401b205a87954e2d8687530 Mon Sep 17 00:00:00 2001 From: Hahaha Date: Fri, 18 Sep 2026 16:58:24 +0800 Subject: [PATCH 21/24] =?UTF-8?q?ci:=20smoke=20=E6=B8=85=E5=8D=95=E5=8A=A0?= =?UTF-8?q?=20table-kit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/html2video-for-mcode-smoke.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/html2video-for-mcode-smoke.yml b/.github/workflows/html2video-for-mcode-smoke.yml index a24a7b69..3134599f 100644 --- a/.github/workflows/html2video-for-mcode-smoke.yml +++ b/.github/workflows/html2video-for-mcode-smoke.yml @@ -46,6 +46,7 @@ jobs: 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 \ --test-timeout=60000 - name: Render smoke (init → timings → static gate → capture → build) From 8648e40252945b73cb7c280d95dfbe240dc4240a Mon Sep 17 00:00:00 2001 From: Hahaha Date: Fri, 18 Sep 2026 17:00:48 +0800 Subject: [PATCH 22/24] =?UTF-8?q?docs:=20=E6=A8=A1=E5=9D=97=E6=95=B0?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3(11=20CLI=20+=205=20=E5=86=85=E9=83=A8?= =?UTF-8?q?=E6=A8=A1=E5=9D=97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/Wzdhehe/html2video-for-mcode/README.md | 4 ++-- plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md | 2 +- .../html2video-for-mcode/skills/html2video-for-mcode/SKILL.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/Wzdhehe/html2video-for-mcode/README.md b/plugins/Wzdhehe/html2video-for-mcode/README.md index 135e6e27..e3937c5b 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/README.md +++ b/plugins/Wzdhehe/html2video-for-mcode/README.md @@ -36,8 +36,8 @@ my-video/ ## Pipeline -One Skill drives an 11-script pipeline (plus four internal modules — path containment, URL -policy, `no-fx` rules, chart CSS — under `skills/html2video-for-mcode/scripts/`): +One Skill drives an 11-script pipeline (plus five internal modules — path containment, URL +policy, `no-fx` rules, chart CSS, table CSS — under `skills/html2video-for-mcode/scripts/`): | Stage | What happens | |---|---| diff --git a/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md b/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md index d09128ba..8ac1b529 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md +++ b/plugins/Wzdhehe/html2video-for-mcode/README.zh-CN.md @@ -29,7 +29,7 @@ my-video/ ## 流水线 -一个技能驱动 11 个脚本,外加四个内部模块(路径收监 / URL 策略 / `no-fx` 规则 / 图表 CSS,都在 `skills/html2video-for-mcode/scripts/` 下): +一个技能驱动 11 个脚本,外加五个内部模块(路径收监 / URL 策略 / `no-fx` 规则 / 图表 CSS / 表格 CSS,都在 `skills/html2video-for-mcode/scripts/` 下): | 阶段 | 做什么 | |---|---| diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md index b248ce74..4bb42432 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/SKILL.md @@ -27,7 +27,7 @@ description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻 ## 目录与工具 -技能自带 **11 个命令行脚本 + 4 个内部模块**(直接以本技能目录为路径调用,项目目录作为参数,无需复制;`tests/` 下还有一套 node:test 安全与冒烟测试,从仓库根 `node --test` 自动发现): +技能自带 **11 个命令行脚本 + 5 个内部模块**(直接以本技能目录为路径调用,项目目录作为参数,无需复制;`tests/` 下还有一套 node:test 安全与冒烟测试,从仓库根 `node --test` 自动发现): | 脚本 | 作用 | |---|---| @@ -43,7 +43,7 @@ description: 把脚本/大纲/主题变成带中文口播的成片 MP4(HTML 幻 | `scripts/build-video.mjs <项目目录> [--asr] [--dry-run]` | 编码每张 → 拼接 → 音轨对位 → 合成 → 自检 + 出 `out/subs.srt`;`--asr` **按句**切分音频 + 校验清单;`--dry-run` 只打印将要执行的 ffmpeg 命令(排错用) | | `scripts/asr.mjs <项目目录> [--api-key K] [--verify-timing] [--from <转写>] [--allow-any-endpoint]` | 调 ASR 转写并按句校验音画是否念的是脚本(数字/繁体字不符判 ✗);`--verify-timing` 用字级时间戳实测句开口。Key 只发官方域 | -内部模块(被上面的脚本 import, 不单独运行):`tools.mjs`(ffmpeg/ffprobe 探测 + 路径收监 `safeId/safeRel/inside`)、`url-policy.mjs`(ASR 端点白名单 + SSRF/重定向策略 + 下载文件名)、`nofx-css.mjs`(`no-fx` 规则的唯一来源,init-project 写入 / preview-page 兜底注入共用)、`chart-css.mjs`(图表动效与图表原语的唯一来源,`--upgrade-css` 给老项目补)。 +内部模块(被上面的脚本 import, 不单独运行):`tools.mjs`(ffmpeg/ffprobe 探测 + 路径收监 `safeId/safeRel/inside`)、`url-policy.mjs`(ASR 端点白名单 + SSRF/重定向策略 + 下载文件名)、`nofx-css.mjs`(`no-fx` 规则的唯一来源,init-project 写入 / preview-page 兜底注入共用)、`chart-css.mjs`(图表动效与图表原语的唯一来源)、`table-css.mjs`(表格原语:`.tbl/.kv/.matrix/.rank`)。后两者由 `--upgrade-css` 给老项目补。 环境要求:Node 18+(脚本用 fileURLToPath 保兼容, 不依赖 Node 20.11 的 import.meta.dirname)、`npm i playwright && npx playwright install chromium`(项目目录内)。ffmpeg/ffprobe 自动探测:PATH → node_modules(ffmpeg-static/ffprobe-static)→ 常见安装位置,找不到会给逐条诊断而不是莫名报错。 From b66404d52f7fee89feabc4d9e7015569a8d0e1f5 Mon Sep 17 00:00:00 2001 From: Hahaha Date: Fri, 18 Sep 2026 18:11:52 +0800 Subject: [PATCH 23/24] =?UTF-8?q?html2video-for-mcode=201.2.0:=20CSS=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=AE=B1=E5=8F=97=E7=AE=A1=E5=9D=97(rev=20?= =?UTF-8?q?=E5=8E=9F=E5=9C=B0=E6=9B=BF=E6=8D=A2=20+=20--check-css),=20?= =?UTF-8?q?=E4=BF=AE=20.matrix=20=E8=A1=A8=E5=A4=B4=E9=AB=98=E4=BA=AE,=201?= =?UTF-8?q?32=20=E4=BE=8B=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.claude-plugin/plugin.json | 2 +- .../Wzdhehe/html2video-for-mcode/CHANGELOG.md | 12 ++ .../Wzdhehe/html2video-for-mcode/README.md | 17 +- .../html2video-for-mcode/README.zh-CN.md | 6 +- .../Wzdhehe/html2video-for-mcode/plugin.json | 2 +- .../skills/html2video-for-mcode/SKILL.md | 11 +- .../references/authoring.md | 31 +-- .../html2video-for-mcode/references/render.md | 2 +- .../scripts/chart-css.mjs | 14 +- .../scripts/check-slides.mjs | 40 +++- .../html2video-for-mcode/scripts/css-kit.mjs | 117 +++++++++++ .../scripts/init-project.mjs | 60 +++--- .../scripts/preview-page.mjs | 39 ++-- .../scripts/table-css.mjs | 5 +- .../tests/css-kit.test.mjs | 194 ++++++++++++++++++ .../tests/preview-page.test.mjs | 24 ++- .../tests/table-kit.test.mjs | 2 + 17 files changed, 494 insertions(+), 84 deletions(-) create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/scripts/css-kit.mjs create mode 100644 plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/css-kit.test.mjs diff --git a/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json index 1f52d238..ff15b1ba 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json +++ b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "html2video-for-mcode", - "version": "1.1.0", + "version": "1.2.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 index 5b5d76c1..07c28460 100644 --- a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md +++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.2.0 — 2026-09-18 + +**CSS 工具箱受管块:把「改了 CSS 但项目里没生效」连根修掉** + +- 三个工具箱(`nofx-css.mjs` / `chart-css.mjs` / `table-css.mjs`)在项目 `tokens.css` 里改为**带内容 rev 的受管块**(`/* >>> html2video: rev= >>> */ … /* <<< html2video: <<< */`),rev 对三段 CSS 内容求哈希 —— 内容变则 rev 变。 +- `init-project --upgrade-css` 从「存在性探针 + 追加」重写为**按 rev 原地替换**:块旧 / 被手工改过 → 原位换新;无定界但内容当前 → 原地包裹(位置不变);缺失 → 文件尾追加并提示旧规则会被后写覆盖。受管块之外的规则(含项目端覆写)一律不动,写前先备份 `tokens.css.bak`。旧实现的两类缺陷由此消除:补过一次就永远报「无需升级」、源模块后续改动永不传播;追加式升级压掉用户覆写并留下整份重复块。 +- 新增 `init-project --check-css`(只查不改,落后 / 缺失逐项报出并以退出码 1 结束);`check-slides` 增加两项提示级检查:tokens.css 工具箱落后(点名哪一段)、`class` 用了 `tokens.css` 与本张 `\n`; +// 兜底: 项目 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); } @@ -112,7 +113,7 @@ export function injectStyle(html, css) { // ─────────────────────────── 放映页 ─────────────────────────── export function buildPlayPage({ - topic = '', lang = 'zh', slides = [], noFxNote = '', generatedAt = '', + topic = '', lang = 'zh', slides = [], cssNote = '', generatedAt = '', narration = true, // 是否加载口播文案 UI(没有 clauses 或 --no-script 时为 false) timing = false, // 是否有对时数据(只影响标题上的"(未对时)"标注) fallbackNote = '', // "还没对时/等间隔预览"的如实说明(与口播 UI 无关, 画面上也要说清) @@ -124,7 +125,7 @@ export function buildPlayPage({ })); const json = JSON.stringify(model).replace(/⚠ ${esc(noFxNote)}

` : ''; + const cssWarn = cssNote ? `

⚠ ${esc(cssNote)}

` : ''; const notice = fallbackNote ? `
${esc(fallbackNote)}
` : ''; return ` @@ -225,7 +226,7 @@ export function buildPlayPage({ ${narration ? `` : ''}