MAKING OF / 二十二
2026.10 · 制作手记 + 新手教程
A HAND-DRAWN MV · 13 SCENES

用 9,130 行代码,
画完一个女孩的二十二岁

这支 MV 没有一张原画。40 句歌词、13 个场景、7,859 帧, 都是在一块 1920×1080 的画布上现画的。

4:21.97 30 FPS 13 SCENES 7,859 FRAMES 0 张外部画面
《二十二》MV 片头
FIG.00片头。前奏 21.53 秒,足够让一个镇子亮起来。
4:22Runtime
13Scenes
40Lyric Lines
7,859Frames
9,130Lines of Code
完整成片 · 4:22
播放器没加载出来,就去 B 站看。

最开始我想偷个懒:用 ImageGen 出十张关键帧,CSS 推拉几下,再撒点粒子,应该就差不多了。

真开工才发现不行。十张图不够 13 个场景用,也不够让一个女孩从春天走到冬天。 后来索性换了个笨办法:开一块 1920×1080 的画布,每一帧都现画。

最后成片 4 分 21 秒 97,13 个场景,7,859 帧。代码 9,130 行,大半是场景。 没有一张外部图片,歌是原曲。

开工时拿到的几篇参考——一篇讲 Claude Opus 5.5 怎么用代码做动画 MV 的文章、一份整理 视频提示词 的仓库,还有一篇 动画 MV 拆解——基本决定了后来的做法: 画面用代码画,歌决定切点,歌词先校对再上场。

01

先听见画面:40 句歌词,13 个场景

Structure

分镜稿先定的是气质:手绘绘本、水彩、蜜桃橙和鼠尾草绿,一个齐肩发的 22 岁女孩。 分镜里她叫小满。真正排时间的时候,我基本是跟着歌词走的。

整首 40 句,每句落在哪一段,切点就差不多定了。除了片头片尾,每个场景都从某一句的起唱时间开始。 前奏 21.53 秒最长,留给标题和一小段小镇蒙太奇——那是不用赶歌词的 21 秒。

所以这片子没什么“剪辑点”可设计。唱到哪句,画面就得在那口气换过来之前接上。

#场景时间这一段发生什么
01前奏0:00–0:21.5标题浮现,小镇从夜色里亮起来
02春天0:21.5–0:42.1她走在花街,风把头发和花瓣一起吹乱
03教室0:42.1–1:02.4午休前的十五分钟,她趴在桌上
04生日1:02.4–1:23.1两根数字蜡烛拼成 22,然后房间被“为你好”的声音占满
05闪回1:23.1–1:33.0十二岁,操场和纸飞机
06花苞1:33.0–1:44.1一朵花在阳光里慢慢打开
07秋天1:44.1–2:04.7林荫道奔跑,长椅上的告别
08公交站2:04.7–2:25.022 路一直不来,她决定走一走
09旅途2:25.0–2:45.6火车、城市、霓虹和从右边飘进来的弹幕
10记忆2:45.6–3:01.1圆形记忆窗里的学校和操场
11希望3:01.1–3:22.4夜路、地图,和那些“该知足”的声音
12二重唱3:22.4–3:52.4她与十二岁的自己并肩站着
13终章3:52.4–4:22.0星空、草原、日出,和一句祝愿
春天场景:女孩走在花街
FIG.0102 / 春天。第一句歌词进来时,她刚好走到画面中央。
02

手绘感是画出来的

Engine

一开始我给画面盖了层纸纹,看着像给照片蒙了灰。后来发现,手绘感主要不在纸,在线条和上色。

引擎里没有一条规整的线。每一笔都画两遍:第一遍淡、细,第二遍实,两次抖动的种子不一样,叠起来有点像铅笔反复描。 圆和方也都留了点偏差,不会太圆、太方。

上色是叠出来的:同一个形状铺三层半透明色,每层边缘都有一点不同,交界处会自然变深。 随机数跟着时间走,每 1/9 秒换一次,线条会轻轻动一下,但不至于像电视雪花。

// 手绘线条的“沸腾”时钟:每秒重抖 9 次 function setBoil(seconds, boilFps = 9) { BOIL = Math.floor(seconds * boilFps); } // 每一笔都画两遍,第二遍更实 if (double) { draw(jitter, alpha * .55, width * .78, 0); draw(jitter, alpha, width, 300); }

纸是最后加的。512×512 的噪点,加 260 根短纤维,正片叠底,再压 16% 的暗角,四边留 16 像素的手绘边框。 纸纹的随机种子是 20260306——三月六号,歌里她刚满二十二岁。

教室场景
FIG.0203 / 教室。黑板、挂钟、窗台上的绿植,全是同一套抖动线条画出来的。
花苞绽放场景
FIG.0306 / 花苞。花瓣的粉不是平涂,是一层层叠出来的。
03

一个女孩,一套骨架

Character Rig

她穿过春天、教室、生日、秋天,也走过夜路和草地,但身体只有一套。 drawGirl(x, y, s, pose) 接一堆参数:走路的幅度、头的角度、眼睛、笑、头发被风吹多少、坐还是站。

同一副骨架,换个姿势就是另一个阶段。改一个动作,所有场景里的她都会跟着变,省了很多事。

麻烦的是骨架得站得住。最早那版坐姿,腿还是按站姿画的,大腿和小腿从身体上分了出去,像被剪开贴在椅子上。 后来给坐姿单独写了一条分支:大腿向前、小腿向下,鞋子重新找位置。 1:48 的奔跑也是,镜头先加上去,手臂比例不对,再回头把肩、肘、腕三个点重摆一遍。

还有一些小东西反复出现:蛋糕上拼成 22 的两根数字蜡烛、22 路公交、车厢侧面的 22、歌词里的“二十二个月”。 数字算是这片子的暗号,也是她的年纪。

生日蛋糕场景
FIG.0404 / 生日。两根数字蜡烛,拼成 22。
22 路公交站场景
FIG.0508 / 公交站。22 路,一直不来。
04

每一帧都从 t 开始

renderAt(t)

整支片子只有一个入口:renderAt(t)。给它一个秒数,它先找当前场景,再把“进场多久”交给场景函数。 每一帧都从头算,所以可以随便 seek、抽帧、重导,不用“从头播一遍才能看到”。

歌词也在这根轴上。40 句,逐字排,逐字抖一下;每句的淡入淡出看它和下一句隔多久,隔得短就快进快出,隔得长就多停一会儿。 人声检测发现实际起唱比歌词表早 0.2 秒左右,所以整体往前挪了 0.2 秒。字比声音早到一点点,看起来才像同步。

// 人声比 QQ 歌词时间戳早约 0.2s const LYRIC_SHIFT = -.2; function renderAt(t) { t = clamp(t || 0, 0, DURATION); setBoil(t); const scene = sceneAt(t); scene.draw(t - scene.start, scene.end - scene.start); drawCutFlash(t); drawOpeningVeil(t); drawLyric(t); }

终章还有个小判断:日出前歌词是浅色,天亮后同一句会自动变深。没有单独的字幕层,颜色是从场景状态里读的。

生日场景后半段,屏幕被现实的声音占满
FIG.0604 / 生日。蛋糕之后,屏幕被“为你好”的声音占满。
05

播放器也得自己写

Player

网页播放器我没用 video 标签。它和成片共用一套绘制代码:你在网页里看到的每一帧,和导出的每一帧,都是同一个 renderAt(t)。

4 分 22 秒的 MP3 有 10.5 MB。直接给 <audio> 一个地址,拖进度条时浏览器会去请求字节区间,网一慢,声音和画面就分家。 我的做法是先把整首歌 fetch 成 Blob,再换成 object URL。拖的时候音频已经在本地,不用等网。

开始页也不是摆设。它挡着加载,等音频元数据好了才让按播放。进度条能拖,空格暂停,左右键各跳 5 秒,F 全屏,R 重来。 这些东西不会出现在片尾字幕里,但少一个,看片的人就会烦。

夜色中的发光小路
FIG.0711 / 希望。任何一帧都可以被直接找到,包括这一条夜路。
06

7,859 帧

Pipeline

导出的时候,浏览器就是渲染器。脚本用 CDP 打开 ?render=1 的页面,把 requestAnimationFrame 停掉,逐帧调 renderAt(t), 把画布抓成 JPEG,直接塞进 ffmpeg 的 stdin。中间不落帧文件。

renderAt(t)→ Canvas 1920×1080→ JPEG q=0.95→ image2pipe→ x264 crf 18→ MP4
ffmpeg -f image2pipe -framerate 30 -vcodec mjpeg -i pipe:0 \ -i mv/二十二.mp3 \ -map 0:v:0 -map 1:a:0 \ -c:v libx264 -preset medium -crf 18 \ -pix_fmt yuv420p -profile:v high -level 4.1 \ -c:a aac -b:a 192k -af apad=pad_dur=1 \ -movflags +faststart -shortest out.mp4

实测大概 19 帧/秒,7,859 帧七分钟左右,最后 133.3 MB。apad=pad_dur=1 给音频补 1 秒尾巴,免得最后一帧被 AAC 的编码延迟吃掉; -shortest 再把它裁回画面长度。这两个参数最容易忘,忘了结尾就会少半秒。

项目规格
时长 / 帧数261.9667 s / 7,859 帧
画面1920×1080 · 30 fps · H.264 High@4.1 · crf 18
声音AAC 192 kbps · 44.1 kHz · stereo(源 MP3 320 kbps)
体积 / 码率139,739,751 B · 4.27 Mbps
火车车厢,侧面印着 22
FIG.0809 / 旅途。七千多帧里,这一节车厢只停留一小段,但 22 一直在。
07

改片只说时间码

Iteration

这片子前后改了三十多条,几乎每条都是“时间码 + 一句话”: “1:48 加一个镜头”“2:39 弹幕从右边进来”“3:50 把回忆窗收掉”。 听起来很随意,其实省事。1:48 能直接找到秋天那场的第 4 秒,也能对上“青春虽然有本钱可以洒脱”那句词。

每次改的流程都差不多:定位、改代码、在改动点前后各抽一帧、看姿态和转场、重导整片、再从 MP4 里抽一次。 浏览器里的预览只能看个大概,编码之后才算数。

时间码那条修改最后变成了什么
1:08弹幕延后到「却发现许多烦恼要面对」生日场景的后半段,祝福声之后才响起现实的声音
1:19生日弹幕延长,加入考研、考公话题蛋糕是祝福,弹幕是“社会时钟”
1:48新增青春洒脱镜头;随后重做手臂比例秋日林荫道上张开双臂的奔跑
2:21增加梦境场景;2:26 复用生日蛋糕公路尽头的三张明信片,和一块回来的蛋糕
2:31火车推进;2:35 城市;2:39 弹幕从右飘入从站台到霓虹的连续转场
2:51记忆圈学校;3:06 地图提前圆形记忆窗里的操场与纸飞机
3:16幸福镜头增加元素她把手放在心口,身后的光慢慢亮起来
3:50回忆窗消失、女孩长大、妈妈淡出、星空转场与十二岁的自己并肩,然后独自向前

1:48 最能说明问题。镜头是先加上去的,先解决“有没有”;第二遍才回到骨架,解决“对不对”。 手臂比例不对的时候,画面第一眼就是别扭,但说不上来哪别扭。把肩、肘、腕三个点画出来,才发现上臂短了一截。 有了时间码,这种“别扭”就能落到具体的位置上。

2:39 的弹幕方向也是试出来的。从左边进来像回忆,从右边进来,才像城市往你这边压。同一句话,换个方向,感觉就变了。

3:50 那段改得最多:回忆窗一张张消失、女孩长大、妈妈淡出、星空转场,四件事挤在一个镜头里。 这种段落看一遍根本看不出来,只能按秒抽帧,一帧一帧确认谁还在、谁已经走了。

秋日林荫道奔跑
FIG.0907 / 秋天。1:48,张开双臂的奔跑。
夜间城市与弹幕
FIG.1009 / 旅途。2:39,弹幕从右边来。
女孩与十二岁的自己牵手
FIG.1112 / 二重唱。她与十二岁的自己并肩。
漂浮的记忆窗
FIG.123:50,回忆窗一张张变淡、消失。
“感觉不对”很难改;“1:48,上臂短了”就好改。 改片笔记
08

做完之后

Lessons

整支片子就是 4 个前端文件,加 1 个导出脚本。没有框架,没有构建步骤,也没有素材目录。 画面不在素材里,在代码里。

文件职责
mv/engine.js1,235 行:纸张、笔触、水彩、角色骨架
mv/scenes.js7,283 行:13 个场景,所有画面
mv/main.js407 行:播放器、时间轴、歌词同步
mv/index.html + style.css页面、开始页、控制条
shots/export-mp4.jsCDP 逐帧抓图 → ffmpeg 封装
calibrator.html把歌词铺在波形上,校准那 0.2 秒
  • 01 · 歌词先定,画面后补。 先有歌,再有分镜。13 个场景的切点是从 40 句歌词的起唱时间里抠出来的,不是按镜头长度排的。切点跟着呼吸走,画面才不会像 PPT。
  • 02 · 能改骨架,就别一张张重画。 一套 drawGirl 撑完 13 个场景。坐姿的腿、奔跑的手臂,修的是骨架,所有场景一起好。
  • 03 · 手绘感是画出来的,不是贴上去的。 双线描边、三层水彩、每秒 9 次的线条抖动、带纤维的纸纹。这些都得在画的时候发生,盖一层滤镜没用。
  • 04 · 改片说时间码,别说“中间那段”。 “1:48 手臂比例不对”比“人物看着有点怪”有用得多。时间码能定位到场景、歌词和帧,改起来才有落点。
  • 05 · 最后一定要看导出的 MP4。 浏览器里的预览只能看个大概。编码之后,标签有没有撞、结尾有没有少半秒,才会真的露出来。
终章草原
FIG.1313 / 终章。她坐在草地上,天色从黄昏走向夜色。

如果再来一次,我会先把所有动作和场景的时间轴搭好,再动笔画风景。这次是先画风景,再回头补动作,返工不少。 不过下次换一首歌,这套东西还能接着用。

片尾:二十二与祝愿
FIG.14片尾。二十二,和一句祝愿。
End of Transmission
愿你一直满怀希望。
《二十二》手绘动画 MV · 2026
4:21.97 · 13 个场景 · 7,859 帧 · 9,130 行代码 · 0 张外部画面
完整成片 · 4:22
如果播放器没有加载,去 B 站观看
09

怎么让 AI 帮你做一支这样的片子

Beginner Tutorial

这一节写给完全没做过视频的人。你只要准备好歌和歌词, 然后把下面 6 段话依次发给 AI。每做完一步,看一眼结果,满意了再发下一段。 不用懂技术,具体怎么做交给 AI。

STEP 01先让歌播起来

把歌和歌词放进项目的 mv/ 文件夹,命名成 song.mp3 和 song.lrc。然后对 AI 说:

复制给 AI
我要做一支大约 4 分钟的手绘动画 MV。 先帮我做两件事: 1. 检查电脑上做视频要用的东西,缺什么就帮我装好; 2. 做一个能播放 mv/song.mp3 的简单页面, 有播放、暂停、进度条和全屏。 先不要做画面。做好后告诉我怎么打开。

做到这里就够了:歌能播、进度条能拖、画面能全屏。先跑通,再谈好看。

STEP 02让它先给一张分段表

复制给 AI
读一下 mv/song.lrc,把整首歌分成 10–14 段。 用一张表告诉我: 每段从第几分几秒到第几分几秒、对应哪句歌词、画面是什么。 先只给表,不要开始画。

为什么先要表:在表上改故事只要一分钟,画出来再改要半天。不满意就直接说“第 4 段改成生日,第 7 段和第 8 段合并”。

STEP 03先试 5 秒画风

复制给 AI
先做一个 5 秒的试画:一棵树、一朵云、一个女孩。 要手绘绘本的感觉,水彩、暖色,纸张有一点点颗粒。 截图给我看,先不要做整片。

为什么只看 5 秒:画风对了,后面只是重复;画风不对,后面全是返工。

STEP 04一段一段做

复制给 AI
按那张表,先做前 3 段。 每段做完截 3 张图给我:开始、中间、结尾。 用同一个女主角,不要换画风。 等我确认,再做下一组。

记住:AI 说“做好了”不算,看到图才算。一次做太多,出了问题你也不知道是哪一段。

STEP 05先导 5 秒,再导全片

复制给 AI
帮我把整支片子导出成 MP4。 先导前 5 秒给我看,确认声音和画面对得上,再导完整版。 导出后告诉我文件在哪里、有多长。

为什么先导 5 秒:整片导出要等几分钟,先试一小段,能在花时间之前发现问题。

STEP 06用时间点改,最后检查

复制给 AI
接下来我按时间点提修改,比如: “1:48 加一个奔跑镜头。” “2:39 弹幕从右边进来。” 每次只改这一处,不要动其他画面。 改完截 3 张图给我:改之前 1 秒、改动处、改之后 1 秒。 我确认后,再重新导出。

导出之后,打开 MP4 从头看一遍,检查这几件事:

  • 声音和画面对得上吗?
  • 歌词出现的时间对吗?
  • 人物的腿、手、头有没有断开或比例奇怪?
  • 歌词有没有挡住脸?
  • 能正常播放、拖动、全屏吗?

最后记住三句话:先做小的,再做整片;每次都要看图;改片子说“第几分几秒”,不说“中间那段”。

就用这六步。剩下的——怎么画、怎么导出、怎么让歌词对上—— 都交给 AI。你要做的只是每一步看一眼,然后说“继续”或者“这里改一下”。

Before You Publish

练习时可以用任何歌;要发布,请换成你有权使用的音乐。

10

参考资料与原始素材

Sources

开工时收到的资料,加上这个项目里实际用到的原始文件。 想自己试一遍,可以从这里开始。

资料说明
Claude Opus 5.5 是怎么做出视频的 · 知乎 最初的工作流参考:用代码逐帧画面,时间决定一切
Opus 5.5 动画 MV 拆解 另一篇参考,讲动画 MV 的结构与制作
opus-video-prompts · GitHub 视频提示词与制作流程的整理
二十二.lrc 对着原曲逐句校对后的歌词时间轴
mv-storyboard.md 最早的分镜脚本:角色、色调、逐句画面
二十二.mp3 片中原曲,未随页面提供(版权归原权利人所有)

关于那份最初的歌词:最早的一版有不少听错的地方——他/她、冬历/农历、一只耳朵/一十二、六月/九月…… 后来对着原曲一句一句校成了 LRC。分镜也是从这份校对后的歌词长出来的。

原曲版权归原权利人所有;这里只作本地练习与记录,不用于商业发布。

MAKING OF / 二十二 CANVAS 2D · CDP · NODE · FFMPEG