| name | videohand |
| description | 把一段文字做成纸面马克笔手绘风格的小视频(videohand)。逐句按语义选画面卡(64 张可选,相邻不重样),rough.js 笔画原语建帧,安全区 + 三画幅自适应(9:16 / 16:9 / 1:1),可选配音与词级手绘字幕,渲染前过五道闸。当用户要做手绘 / 涂鸦 / 白板 / sketch 风格的解说短片、观点片、上新片、知识拆解、教程视频时用它,或用户直接点名 videohand / handdrawn 时用它。 |
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,立刻跑:
node scripts/scene-lint.mjs <片目录>
它只读 STORYBOARD,几秒出结果。别等建完帧再跑 —— 选卡纪律违规在这一步修
是改一行计划,拖到五道闸那一步修是重建整格帧。同一道闸,提前跑,省掉最贵的一类返工。
3 · 建帧
默认路径:写 spec,让生成器出帧。 每帧一段 ~15 行的 JSON
(卡名 / cfg 文案 / 时长 / seed / 缝 / 字幕 / 支撑层),批量生成:
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:
HW.audit(S)
HW.auditLayout(S)
HW.auditMotion(S, tl)
第三道有必要:门滑开、盖子翻转、碎片飞出,在第一帧全都还老实待着。
5 · 渲染前五道闸
node scripts/portability-lint.mjs <片目录>
npm run check
node scripts/scene-lint.mjs <片目录>
node scripts/motion-lint.mjs <片目录>
node tools/gate.mjs . --stage 2 --spans <每格秒数> --captions 1
五个都得退出 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 族落版卡最容易做成孤零零一行字加个箭头,那撑不起收尾。落版格至少三层:
- 主视觉(不是装饰性几何体)
- 落版字,重心在
S.hero 里
- 一层支撑信息 —— 真字段、一句延伸、或一组并列
语义图解 —— 字幕和画面的分工(硬规则)
字幕层负责原话,画面层负责抽象。 这条是真实客户多轮验收定稿的
(口播海报版式那条线,同样的反馈原话是「还是在重复下面字幕内容……
用抽象或者视觉的方式来展示」),在这条线上同样成立。
违反它的样子一眼可认:主画面把字幕那句话放大写一遍 ——
字幕写「把想法变成能跑的产品」,画面大字也写「把你的想法变成能跑的产品」。
画面成了字幕的放大复读机,两条信息通道在说同一句话,等于浪费一条。
四条细则:
- 画面上的文字只许是锚点级短语:一个数字、一个 ≤6 字的关键词、一条标注。
与本帧字幕连续重合 ≥6 字即违规(
make-frame 生成时会当场报)。
整句话只能出现在字幕带里。
- 画面演的是语义,不是词。「想法 → 产品」是一条箭头两个端点,
不是那句话的大字版;「不是技术是执行力」是天平或划掉重写,不是两行文字。
选卡(第 2 步)按形状选,正是为了这一步有的画。
- 抽象要有指向:画面元素必须能回答「它对应口播里的哪个概念」。
孤零零一个「?」或一个装饰性图形不算图解,算占位(判据③)。
- 尽量与前一帧视觉连续:能复用上一帧的视觉语言(同一条时间轴、同一组格子)
就复用 —— 观众不用每帧重新学一遍画面怎么读。
语义先行 —— 画面提前就位,拐点才动(硬规则)
一句开口时,它的画面主形已经在那儿了。 观众先看见图,再听见话,
话音落在已有的画面上 —— 而不是画面逐词跟着口播蹦。
- 主形 ≤0.5s 就位:本帧的主视觉在开口后半秒内完成建立(描线可以还在走,
但形状和位置已定)。
- 句中只在语义拐点动:转折(「但是」)、报数、点名对象 —— 这些时刻加
强调动效(指向、圈注、morph、点亮)。不逐词跟读,不按节奏均匀变化 ——
节奏感来自语义拐点的动效,不是来自画面一直在动。
- 卡内节拍:相邻两次语义变化之间留 ≥0.9s 停顿;连环入场的多个元素
归成一个手势(组内 stagger 0.15–0.2s)再一起停 —— 五连发会把观众打散。
- 聚合不散点:元素之间要有锚定关系 —— 支撑层贴着主体、标注带引线指到位。
各占一角互不相干的排版读起来就是「散」。
字幕(HW.captions)
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 一起引进帧里,每格就只剩两行:
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 |
加一张新卡
- 在
assets/hw-cards.js 里加一条(照抄邻居的结构,cfg 双语走 pick())
- 在
references/scenes-index.md 的表里加一行
node scripts/build-gallery.mjs 重建 playground/index.html,打开看那格红不红
改完 skill 本身,跑一遍 evals/ 里的三个场景对照基线 —— 视频 skill 的失败方式
(镜头重复、跑偏画风、拿占位符充数)单元测试抓不到。