# Videohand

> 把一段文字做成纸面马克笔手绘风格的小视频（videohand）。逐句按语义选画面卡（64 张可选，相邻不重样），rough.js 笔画原语建帧，安全区 + 三画幅自适应（9:16 / 16:9 / 1:1），可选配音与词级手绘字幕，渲染前过五道闸。当用户要做手绘 / 涂鸦 / 白板 / sketch 风格的解说短片、观点片、上新片、知识拆解、教程视频时用它，或用户直接点名 videohand / handdrawn 时用它。

- Skill: `chuanyue98/videohand` (Agent Skill, multi-file: 113 files)
- Install (CLI): `npx skillmds@latest add chuanyue98/videohand`
- Raw SKILL.md: https://api.skillmd.com/api/skills/chuanyue98/videohand/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: chuanyue98 (https://skillmd.com/u/chuanyue98)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/chuanyue98/videohand

---


# videohand

给一段文字，出一支手绘风格的小视频。

```
一段文字 → 逐句选卡（64 张） → 笔画原语建帧 → MP4
```

**不给固定结构。** 只锁两端——开场一张、落版一张——中间几镜完全跟着稿子走。同一支片里不会有两个镜头长得一样，不同的片子也不会长成一个样。

底座是 **rough.js**（Excalidraw 自己用的手绘渲染引擎）+ **Excalifont / 小赖字体**（Excalidraw 官方字体搭配）。

---

## 先看有哪些画面可选

`playground/index.html` 是 64 张卡的动图墙，每格跑的是那张卡的**真实代码** ——
所以它同时是 64 张卡的冒烟测试，哪张写错了那格会红着报错。
没生成过就先跑 `node scripts/build-gallery.mjs`。

**选卡之前先看一眼这面墙，比读文档快。**

---

## 做一支片的流程

### 1 · 拆语义单元 → 定形状

把稿子拆成**语义单元**，不是拆成句子 —— 通常是「命题 → 机制 → 结论」三层。
**语义相同的相邻句子合并成一个单元**：一个语义一张卡，画面跟着语音慢慢长出来，
不是每句话都翻一张卡。

每个单元问一次：**这个语义是什么形状？**

断言 / 列举 / 流程 / 对比 / 数据 / 界面证据 / 隐喻 —— 形状决定选哪张卡，**不是先挑好看的画面再往里塞字**。

中间一般 3–5 个单元，加开场和落版共 5–7 镜。

### 2 · 选卡

打开 `references/scenes-index.md`，按形状定位卡名。

**选卡纪律**（写完自查）：

| 规矩 | 细则 |
|---|---|
| 两端锁死 | 首格 A 族开场、末格 I 族落版 |
| 相邻不重 | 相邻两格禁用同一张卡；全片同卡 ≤1 次（≥6 格时 ≤2 次且不相邻） |
| 高能配额 | `one-word-explode` / `cross-out-correct` / `title-scribble-reveal` / `torn-paper-reveal` / `explode-parts` 合计 ≤2 处且不相邻 |
| 呼吸帧 | ≥1 处低能镜头，`quote-bracket-hold` 是标准答案 |
| 转场 | 只用手绘转场（8 种见 `references/transitions.md`），相邻两条缝不许同一种，全片种类 ≥ ⌈缝数/2⌉ |

**选完卡、写完 STORYBOARD，立刻跑：**

```bash
node scripts/scene-lint.mjs <片目录>
```

它只读 STORYBOARD，几秒出结果。**别等建完帧再跑** —— 选卡纪律违规在这一步修
是改一行计划，拖到五道闸那一步修是重建整格帧。同一道闸，提前跑，省掉最贵的一类返工。

### 3 · 建帧

**默认路径：写 spec，让生成器出帧。** 每帧一段 ~15 行的 JSON
（卡名 / cfg 文案 / 时长 / seed / 缝 / 字幕 / **支撑层**），批量生成：

```bash
node scripts/make-frame.mjs film-spec.json --dir <片目录>
```

生成器从 `assets/hw-cards.js` 现场提取卡体（不会与卡库漂移）；样板、四条引用红线、
`#root`、stage id、字幕、缝、**支撑层**、逐文本 `wordsOut` 出场全部自动就位；cfg 缺键、
字幕超 14 字、key 落空、转场名不存在、画面复读字幕会当场报出来。spec 格式见脚本头部注释。

**支撑层是 spec 的必填项**（`"support": { "text": "…", "at": 2.5 }`）——
版面三层里的第二层归脚本管，不靠人每次记得：

- 缺 `support` 又没写 `null` → 普通格报一行 ℹ，**I 族落版格直接失败**（「落版格不是一行字」是硬规则）
- `"support": null` = 明确声明这一格不需要（卡自己把版面铺满了），不再报
- `zone: "under"`（默认，SAFE 78%–87% = 画面 58%–72% 那条空带）/ `"kicker"`（顶部眉标）
- 一格要几层就给几层：`"support": [ { "text": "为什么", "zone": "kicker" }, { "text": "脸是可选项" } ]`

**生成的帧是普通 HTML，随便手改** —— 调节奏、加自定义元素、给支撑层换位置，
都直接编辑输出文件。需要多卡拼合或全自定义画面时，才从
`templates/frame-boilerplate.html` 手写整帧。

手写或手改时，卡的代码是唯一真源（打开 `assets/hw-cards.js` 搜卡名抄 `build`），
且这些红线一条不能破：

- **hw-kit.js / rough.js / gsap 的 `<script>` 必须引在 `<template>` 内**——引在外面永远不执行，且不报错
- **`HW.stage` 必须收本帧的合成 id**：`HW.stage("#root", { w, h, id: "03-visualize" })`，
  收尾写 `HW.frame(tl, S, DUR)`（收 stage，不收选择器）
- **帧里的根选择器只能是 `#root`**，不许改名、不许挂 class 再从 class 起头写规则
- **脚本和资产一律本地 + 根相对**：`assets/vendor/gsap.min.js`，不许 CDN、不许 `../`
- 卡里**不许出现像素数字**，一律走 `S.safe` 的比例和 `S.type(role)`（见 `references/layout.md`）
- 卡里**不许出现 hex**，一律 `var(--hw-*)`（见 `references/palette.md`）
- 要动东西一律 `HW.host(el)`，别直接动 path——GSAP 会替换 transform 把 boil 抹掉
- 界面证据类的卡（`screen-frame` / `terminal-scribble` / `chat-bubble-thread` / `tabs-switch`）每一行都要是**可读真字段**：真来源 + 真标题 + 真时间戳，数字现读。骨架灰条和装饰性几何体一律算占位符

> 中间四条为什么是红线：它们**只在子合成被合进主合成之后才发作**，
> 单帧预览和 playground 永远是对的。一次实测的后果是整支片手绘笔画全灭、
> 七帧叠成一坨、两帧直接空白，而当时四道闸全绿。账见 `references/pitfalls.md` 第八节。

### 4 · 自检

建卡后在页面里跑，三道都要 0：

```js
HW.audit(S)            // 建了没动的形状（它们永远不可见）
HW.auditLayout(S)      // t=0 越界 / 文字出框
HW.auditMotion(S, tl)  // 扫全时间线，抓动画途中才越界的
```

第三道有必要：门滑开、盖子翻转、碎片飞出，**在第一帧全都还老实待着**。

### 5 · 渲染前五道闸

```bash
node scripts/portability-lint.mjs <片目录>   # 跨平台（合成之后才发作的那一类）
npm run check                         # 渲染错误 + 对比度 —— 连 Runtime 段一起看
node scripts/scene-lint.mjs  <片目录>   # 选卡纪律复核（第 2 步已跑过一遍，这里保底）
node scripts/motion-lint.mjs <片目录>   # 动效尺度
node tools/gate.mjs . --stage 2 --spans <每格秒数> --captions 1   # 画面审计（随包在 tools/）
```

**五个都得退出 0 才能渲。**

它们查的是**四个互不重叠的层**，缺一层就有一整类问题没人看：

| 闸 | 看的是 | 漏了它会怎样 |
|---|---|---|
| portability-lint | 合成之后的结构（根 id、CSS 作用域、外链、`../`） | 单帧全对、成片画面全灭，且四道闸全绿 |
| check（含 Runtime） | 浏览器真的报没报错 | 建帧抛异常 → 那一格成片里是一整段纯白 |
| scene-lint | STORYBOARD 的选卡纪律 | 相邻重样、高能扎堆 |
| motion-lint | 动效尺度 | 生硬、错峰读不出来 |
| gate | 真实像素 | 坠底、缝里空帧、字幕带空着 |

前面几道都只读源码 —— **它们看不见画面，也看不见运行时**。
`check` 的 Runtime 段是唯一会告诉你"某一帧的脚本抛了异常"的地方，
**别只看总退出码**：那一格什么都没有，前面建好的形状也一个都不会动。

> 判闸一律看退出码，**不许 `命令 | tail -N`** —— 那拿到的是 tail 的 0，永远是「过」。

> **为什么闸长这样、怎么给别的出片线套一套同样的闸**：见 `tools/README.md`。
> 这条 skill 踩过的具体坑在 `references/pitfalls.md`：
> 版面 / 转场 / 验收在第六节，**合成之后才发作的那一类在第八节**，
> **工具之间互相不认账的那一类在第九节** —— 生成器生成的代码过不了闸、
> 卡库自带的参数过不了闸，你什么都没做错也会中，第一次遇到会以为是自己写错了。

---

## 版面三层（这是硬约束，不是建议）

竖屏 1080×1920 从上到下切三层，**互不重叠**：

| 层 | 位置 | 谁的地盘 |
|---|---|---|
| `S.safe` | 4% – 74% | 内容住这儿 |
| `S.caption` | 75% – 86% | 字幕带，只归 `HW.captions` |
| 平台 UI | 86% – 100% | 抖音/小红书的作者名、话题、按钮 —— **谁也不许进** |

### 主体重心必须落在 `S.hero`（30% – 58%）

`S.safe` 只说「别出界」，不说「别坠底」。一张落版卡把字放在 safe 的最下沿是**完全合法**的 ——
实测就这么翻过车：落版格「有观点，就够了」重心落在画面 **70%**，上方 53% 全空，读起来像掉下去了。

竖屏的光学中心比几何中心高，在 38%–45%。所以：

- **主体重心落在 30%–58%**，`gate.mjs` 的画面审计按这个区间检出，别只靠肉眼
- 重心只在**落定的那一帧**判（每格 85% 采样点）—— 动画进行到一半时重心本来就是偏的

### 每格至少两层：主视觉 + 支撑层

`S.hero` 管重心，但**它不是内容唯一能待的地方**。只往 hero 里放一个主体，
会得到这样的结构：主体挤在 30%–58%，字幕在 75%–86%，**中间 58%–75% 系统性地空着** ——
画面从中间断成两截，读起来就是「留白太多」。

这跟「疏」不是一回事。手绘片墨覆盖 1%–3% 是正常的，疏是风格；
**断层是缺陷** —— 一条 30% 高的连续空带把画面劈开，眼睛找不到从主体到字幕的路。

实测同一支片的七格：

| | 最大连续空带 | |
|---|---|---|
| 只有一个主体块的格 | 29% / 33% | ✗ 断层 |
| 铺了支撑层的格 | 7% / 12% / 20% / 20% / 22% | ✓ |

**规矩：`S.safe` 里不许有超过 25% 的连续空带。**
`gate.mjs` 的画面审计会检出（只在落定的那一帧判 —— 动画演到一半时下半截本来就还没长出来）。

**但别等 gate 抓。** 支撑层是 spec 的 `support` 字段（见第 3 步），建帧时就要写 ——
靠画面审计事后抓到再回头补，一支片会为此返工两次（实测账）。
闸是保底，不是设计工序。

**解法是补一层支撑信息，不是把主体放大。** 放大主体只会让它更孤立。
支撑层放在主体和字幕之间（约 58%–72%），内容可以是：

- 真字段（数字、时间戳、来源）—— 最好使，顺带满足判据③
- 一句延伸 / 反问 / 补充断言
- 一组并列的小项（三到四条，错峰落进来）
- 主体的注解引线 + 短标签

### 落版格不是「一行字」

I 族落版卡最容易做成孤零零一行字加个箭头，那**撑不起收尾**。落版格至少三层：

1. **主视觉**（不是装饰性几何体）
2. **落版字**，重心在 `S.hero` 里
3. **一层支撑信息** —— 真字段、一句延伸、或一组并列

---

## 语义图解 —— 字幕和画面的分工（硬规则）

**字幕层负责原话，画面层负责抽象。** 这条是真实客户多轮验收定稿的
（口播海报版式那条线，同样的反馈原话是「还是在重复下面字幕内容……
用抽象或者视觉的方式来展示」），在这条线上同样成立。

违反它的样子一眼可认：主画面把字幕那句话放大写一遍 ——
字幕写「把想法变成能跑的产品」，画面大字也写「把你的想法变成能跑的产品」。
**画面成了字幕的放大复读机**，两条信息通道在说同一句话，等于浪费一条。

四条细则：

1. **画面上的文字只许是锚点级短语**：一个数字、一个 ≤6 字的关键词、一条标注。
   与本帧字幕**连续重合 ≥6 字即违规**（`make-frame` 生成时会当场报）。
   整句话只能出现在字幕带里。
2. **画面演的是语义，不是词**。「想法 → 产品」是一条箭头两个端点，
   不是那句话的大字版；「不是技术是执行力」是天平或划掉重写，不是两行文字。
   选卡（第 2 步）按形状选，正是为了这一步有的画。
3. **抽象要有指向**：画面元素必须能回答「它对应口播里的哪个概念」。
   孤零零一个「?」或一个装饰性图形不算图解，算占位（判据③）。
4. **尽量与前一帧视觉连续**：能复用上一帧的视觉语言（同一条时间轴、同一组格子）
   就复用 —— 观众不用每帧重新学一遍画面怎么读。

## 语义先行 —— 画面提前就位，拐点才动（硬规则）

**一句开口时，它的画面主形已经在那儿了。** 观众先看见图，再听见话，
话音落在已有的画面上 —— 而不是画面逐词跟着口播蹦。

- **主形 ≤0.5s 就位**：本帧的主视觉在开口后半秒内完成建立（描线可以还在走，
  但形状和位置已定）。
- **句中只在语义拐点动**：转折（「但是」）、报数、点名对象 —— 这些时刻加
  强调动效（指向、圈注、morph、点亮）。**不逐词跟读，不按节奏均匀变化** ——
  节奏感来自语义拐点的动效，不是来自画面一直在动。
- **卡内节拍**：相邻两次语义变化之间留 **≥0.9s** 停顿；连环入场的多个元素
  归成**一个手势**（组内 stagger 0.15–0.2s）再一起停 —— 五连发会把观众打散。
- **聚合不散点**：元素之间要有锚定关系 —— 支撑层贴着主体、标注带引线指到位。
  各占一角互不相干的排版读起来就是「散」。

## 字幕（`HW.captions`）

```js
HW.captions(tl, S, [
  { t: 0.0, d: 2.1, text: "做内容不用会剪辑", key: "不用会剪辑" },
  { t: 2.2, d: 1.9, text: "你只要把观点说清楚", key: "说清楚" },
]);
```

三条纪律：

- **玻璃拟态在纸上要重新解释。** 标准玻璃拟态靠背后的花花绿绿折射出层次；
  纸面手绘片背景接近纯白，直接套 `backdrop-blur` 只会得到一个灰方块。
  这里做的是「磨砂胶带 / 硫酸纸条」：半透明暖白 + 真的 `backdrop-filter`
  （笔画扫过带子时会被糊开，玻璃感就成立）+ 一根发丝亮边 + 一道软阴影把它从纸上抬起来。
  玻璃的**行为**留着，材质换成纸。
- **一条字幕 = 一个口播短句，不是一整句话。** 按停顿切，一条 ≤14 字。
  `HW.wrapZh` 会在标点和连词处断（中文没有词间空格，按字数硬折会把词劈开 ——
  实测出过「真实的观 / 点 / 细节」），但它只保证不劈词，**不负责替你把长句切短**。
- **一条里只有一个重点词**（`key`）。全高亮等于没高亮。
- **字号比主体明显小一档**：`S.short * 0.038`（竖屏约 41px；此值已按实测反馈降过两次，
  方向始终是「再小一点」）。字幕是跟读用的第二条通道，不是标题。
  长句别靠调大字号救，靠 `HW.wrapZh` 断成两行短句。
- **胶带底要够实**（0.78）：太透的底会让字和透过来的笔画糊在一起；
  投影收小压淡 —— 浮起感靠发丝亮边和实底，不靠大影子。

---

## 转场（`hw-trans.js`）

**一条缝是两半：上一格盖上、下一格揭开，两半同一个 SEED。**
抄漏「揭开」那半是最常见的翻车 —— 涂满 → 硬切 → 半秒白场。

把 `assets/hw-trans.js` 跟 kit 一起引进帧里，每格就只剩两行：

```js
var X = HWT(S, tl);
X.TI["paper-slide"](SEAM_IN);              // 开头：把上一格的盖子揭开
/* …本格内容… */
X.T ["ink-blot"](DUR - 0.40, SEAM_OUT);    // 结尾：给下一格盖上
```

**opacity 闸长在 `HW.draw` 里，直接用就行**（`X.D` 只为兼容保留，不必用）。

一格的两半互不知情，所以缝的账要在 STORYBOARD 里记清楚：
哪条缝用哪种、SEAM SEED 是几。硬切两侧都不写。

---

## 画风契约

| | |
|---|---|
| 纸面 | `#FFFFFC` + 26px 点阵 |
| 墨线 | `#003E1F` 深绿 |
| 淡墨 | `rgba(0,62,31,.68)` |
| 强调 | `#53A548` 马克笔绿——**只做笔画**，写字用 `#3C7A33` |
| 字体 | Excalifont（拉丁）+ 小赖字体（中文，按本片字符重新子集化） |

**这份配色的真源在 `hw-kit.js` 的 `HW.PALETTE`，不在帧的 CSS 里。**
`HW.stage` 开场会把它内联写到根元素上，所以帧的 `<style>` 整段失效时笔画也还在。
帧里那份 `#root { --hw-* }` 保留是为了可覆盖、可读 —— 读得到就用读到的。
为什么要这么绕：`references/pitfalls.md` 第 35 条。

动效三件套：**描线进场 + 沸腾抖动 + 逐词错峰**。boil 用默认值（`amp 0.5–0.6` / `rot 0.12–0.18` / `frameDrop 4`），再大会抖，再小会死。

> 绿色为什么不能写字：`#53A548` 在纸面上只有 2.75:1，过不了 3:1 的闸。算账见 `references/palette.md`。

---

## 什么时候读哪个文件

**别预读。** 下面每一份都只在真的走到那一步时才打开：

| 你正要做的事 | 读这个 |
|---|---|
| 选卡（第 2 步） | `references/scenes-index.md` — 按形状查卡名 |
| 建帧（第 3 步） | `scripts/make-frame.mjs` 头部注释 — spec 格式 |
| 抄某张卡的实现 | `assets/hw-cards.js` — **卡的代码是唯一真源** |
| 算版面 / 画幅 / 槽位 | `references/layout.md` |
| 配色、对比度、字体子集化 | `references/palette.md` |
| 挑转场、算 SEED | `references/transitions.md` |
| 配音、字幕时间戳 | `references/voice-pipeline.md` |
| 出了怪事，尤其是「合成之后才发作」的 | `references/pitfalls.md` 第八节 |
| 生成器 / 卡库 / 闸互相打架（明明照文档走还是不过） | `references/pitfalls.md` 第九节 |
| 想给别的出片线套同一套闸 | `tools/README.md` |

`assets/hw-kit.js` 是引擎（笔画原语 + 槽位 + 编排器 + 审计 + 字幕），
接口在本文件里已经写全，**不必整份读它**。

---

## 出片参数

| | |
|---|---|
| 画幅 | 9:16 为主，16:9 / 1:1 同样成立 |
| 每帧时长 | = 该句口播的音频实际时长（`audio_meta.json` 的 `duration_s`），直接抄；无配音按中文 ~4 字/秒估 |
| 成片速度 | 1.2x，**只提一层**。默认提在 TTS（`tts.sh clone --speed 1.2`），渲染后不再 setpts —— 建帧时看到的秒数就是成片里的秒数 |
| 配音 | 任何满足产物契约（`audio/NN.wav` + `audio_meta.json`）的 TTS 都行；音色走环境变量 `VOLC_SPEAKER_ID`，换音色只换这一个值；没凭证走无配音模式。契约见 `references/voice-pipeline.md` |
| 音轨 | 渲完用 ffmpeg 按帧序 concat 各段 wav 再 mux（每段时长 = 对应帧时长，天然对齐）。细节见 `references/voice-pipeline.md` |

---

## 加一张新卡

1. 在 `assets/hw-cards.js` 里加一条（照抄邻居的结构，`cfg` 双语走 `pick()`）
2. 在 `references/scenes-index.md` 的表里加一行
3. `node scripts/build-gallery.mjs` 重建 `playground/index.html`，打开看那格红不红

改完 skill 本身，跑一遍 `evals/` 里的三个场景对照基线 —— 视频 skill 的失败方式
（镜头重复、跑偏画风、拿占位符充数）单元测试抓不到。

