用 9,130 行代码,
画完一个女孩的二十二岁
这支 MV 没有一张原画。40 句歌词、13 个场景、7,859 帧, 都是在一块 1920×1080 的画布上现画的。
最开始我想偷个懒:用 ImageGen 出十张关键帧,CSS 推拉几下,再撒点粒子,应该就差不多了。
真开工才发现不行。十张图不够 13 个场景用,也不够让一个女孩从春天走到冬天。 后来索性换了个笨办法:开一块 1920×1080 的画布,每一帧都现画。
最后成片 4 分 21 秒 97,13 个场景,7,859 帧。代码 9,130 行,大半是场景。 没有一张外部图片,歌是原曲。
开工时拿到的几篇参考——一篇讲 Claude Opus 5.5 怎么用代码做动画 MV 的文章、一份整理 视频提示词 的仓库,还有一篇 动画 MV 拆解——基本决定了后来的做法: 画面用代码画,歌决定切点,歌词先校对再上场。
先听见画面: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.0 | 22 路一直不来,她决定走一走 |
| 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 | 星空、草原、日出,和一句祝愿 |
手绘感是画出来的
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——三月六号,歌里她刚满二十二岁。
一个女孩,一套骨架
Character Rig
她穿过春天、教室、生日、秋天,也走过夜路和草地,但身体只有一套。
drawGirl(x, y, s, pose) 接一堆参数:走路的幅度、头的角度、眼睛、笑、头发被风吹多少、坐还是站。
同一副骨架,换个姿势就是另一个阶段。改一个动作,所有场景里的她都会跟着变,省了很多事。
麻烦的是骨架得站得住。最早那版坐姿,腿还是按站姿画的,大腿和小腿从身体上分了出去,像被剪开贴在椅子上。 后来给坐姿单独写了一条分支:大腿向前、小腿向下,鞋子重新找位置。 1:48 的奔跑也是,镜头先加上去,手臂比例不对,再回头把肩、肘、腕三个点重摆一遍。
还有一些小东西反复出现:蛋糕上拼成 22 的两根数字蜡烛、22 路公交、车厢侧面的 22、歌词里的“二十二个月”。 数字算是这片子的暗号,也是她的年纪。
每一帧都从 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);
}终章还有个小判断:日出前歌词是浅色,天亮后同一句会自动变深。没有单独的字幕层,颜色是从场景状态里读的。
播放器也得自己写
Player
网页播放器我没用 video 标签。它和成片共用一套绘制代码:你在网页里看到的每一帧,和导出的每一帧,都是同一个 renderAt(t)。
4 分 22 秒的 MP3 有 10.5 MB。直接给 <audio> 一个地址,拖进度条时浏览器会去请求字节区间,网一慢,声音和画面就分家。
我的做法是先把整首歌 fetch 成 Blob,再换成 object URL。拖的时候音频已经在本地,不用等网。
开始页也不是摆设。它挡着加载,等音频元数据好了才让按播放。进度条能拖,空格暂停,左右键各跳 5 秒,F 全屏,R 重来。 这些东西不会出现在片尾字幕里,但少一个,看片的人就会烦。
7,859 帧
Pipeline
导出的时候,浏览器就是渲染器。脚本用 CDP 打开 ?render=1 的页面,把 requestAnimationFrame 停掉,逐帧调 renderAt(t),
把画布抓成 JPEG,直接塞进 ffmpeg 的 stdin。中间不落帧文件。
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 |
改片只说时间码
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 那段改得最多:回忆窗一张张消失、女孩长大、妈妈淡出、星空转场,四件事挤在一个镜头里。 这种段落看一遍根本看不出来,只能按秒抽帧,一帧一帧确认谁还在、谁已经走了。
“感觉不对”很难改;“1:48,上臂短了”就好改。 改片笔记
做完之后
Lessons整支片子就是 4 个前端文件,加 1 个导出脚本。没有框架,没有构建步骤,也没有素材目录。 画面不在素材里,在代码里。
| 文件 | 职责 |
|---|---|
| mv/engine.js | 1,235 行:纸张、笔触、水彩、角色骨架 |
| mv/scenes.js | 7,283 行:13 个场景,所有画面 |
| mv/main.js | 407 行:播放器、时间轴、歌词同步 |
| mv/index.html + style.css | 页面、开始页、控制条 |
| shots/export-mp4.js | CDP 逐帧抓图 → ffmpeg 封装 |
| calibrator.html | 把歌词铺在波形上,校准那 0.2 秒 |
- 01 · 歌词先定,画面后补。 先有歌,再有分镜。13 个场景的切点是从 40 句歌词的起唱时间里抠出来的,不是按镜头长度排的。切点跟着呼吸走,画面才不会像 PPT。
- 02 · 能改骨架,就别一张张重画。 一套 drawGirl 撑完 13 个场景。坐姿的腿、奔跑的手臂,修的是骨架,所有场景一起好。
- 03 · 手绘感是画出来的,不是贴上去的。 双线描边、三层水彩、每秒 9 次的线条抖动、带纤维的纸纹。这些都得在画的时候发生,盖一层滤镜没用。
- 04 · 改片说时间码,别说“中间那段”。 “1:48 手臂比例不对”比“人物看着有点怪”有用得多。时间码能定位到场景、歌词和帧,改起来才有落点。
- 05 · 最后一定要看导出的 MP4。 浏览器里的预览只能看个大概。编码之后,标签有没有撞、结尾有没有少半秒,才会真的露出来。
如果再来一次,我会先把所有动作和场景的时间轴搭好,再动笔画风景。这次是先画风景,再回头补动作,返工不少。 不过下次换一首歌,这套东西还能接着用。
4:21.97 · 13 个场景 · 7,859 帧 · 9,130 行代码 · 0 张外部画面
怎么让 AI 帮你做一支这样的片子
Beginner Tutorial这一节写给完全没做过视频的人。你只要准备好歌和歌词, 然后把下面 6 段话依次发给 AI。每做完一步,看一眼结果,满意了再发下一段。 不用懂技术,具体怎么做交给 AI。
STEP 01先让歌播起来
把歌和歌词放进项目的 mv/ 文件夹,命名成
song.mp3 和 song.lrc。然后对 AI 说:
我要做一支大约 4 分钟的手绘动画 MV。
先帮我做两件事:
1. 检查电脑上做视频要用的东西,缺什么就帮我装好;
2. 做一个能播放 mv/song.mp3 的简单页面,
有播放、暂停、进度条和全屏。
先不要做画面。做好后告诉我怎么打开。做到这里就够了:歌能播、进度条能拖、画面能全屏。先跑通,再谈好看。
STEP 02让它先给一张分段表
读一下 mv/song.lrc,把整首歌分成 10–14 段。
用一张表告诉我:
每段从第几分几秒到第几分几秒、对应哪句歌词、画面是什么。
先只给表,不要开始画。为什么先要表:在表上改故事只要一分钟,画出来再改要半天。不满意就直接说“第 4 段改成生日,第 7 段和第 8 段合并”。
STEP 03先试 5 秒画风
先做一个 5 秒的试画:一棵树、一朵云、一个女孩。
要手绘绘本的感觉,水彩、暖色,纸张有一点点颗粒。
截图给我看,先不要做整片。为什么只看 5 秒:画风对了,后面只是重复;画风不对,后面全是返工。
STEP 04一段一段做
按那张表,先做前 3 段。
每段做完截 3 张图给我:开始、中间、结尾。
用同一个女主角,不要换画风。
等我确认,再做下一组。记住:AI 说“做好了”不算,看到图才算。一次做太多,出了问题你也不知道是哪一段。
STEP 05先导 5 秒,再导全片
帮我把整支片子导出成 MP4。
先导前 5 秒给我看,确认声音和画面对得上,再导完整版。
导出后告诉我文件在哪里、有多长。为什么先导 5 秒:整片导出要等几分钟,先试一小段,能在花时间之前发现问题。
STEP 06用时间点改,最后检查
接下来我按时间点提修改,比如:
“1:48 加一个奔跑镜头。”
“2:39 弹幕从右边进来。”
每次只改这一处,不要动其他画面。
改完截 3 张图给我:改之前 1 秒、改动处、改之后 1 秒。
我确认后,再重新导出。导出之后,打开 MP4 从头看一遍,检查这几件事:
- 声音和画面对得上吗?
- 歌词出现的时间对吗?
- 人物的腿、手、头有没有断开或比例奇怪?
- 歌词有没有挡住脸?
- 能正常播放、拖动、全屏吗?
最后记住三句话:先做小的,再做整片;每次都要看图;改片子说“第几分几秒”,不说“中间那段”。
就用这六步。剩下的——怎么画、怎么导出、怎么让歌词对上—— 都交给 AI。你要做的只是每一步看一眼,然后说“继续”或者“这里改一下”。
练习时可以用任何歌;要发布,请换成你有权使用的音乐。
参考资料与原始素材
Sources开工时收到的资料,加上这个项目里实际用到的原始文件。 想自己试一遍,可以从这里开始。
| 资料 | 说明 |
|---|---|
| Claude Opus 5.5 是怎么做出视频的 · 知乎 | 最初的工作流参考:用代码逐帧画面,时间决定一切 |
| Opus 5.5 动画 MV 拆解 | 另一篇参考,讲动画 MV 的结构与制作 |
| opus-video-prompts · GitHub | 视频提示词与制作流程的整理 |
| 二十二.lrc | 对着原曲逐句校对后的歌词时间轴 |
| mv-storyboard.md | 最早的分镜脚本:角色、色调、逐句画面 |
| 二十二.mp3 | 片中原曲,未随页面提供(版权归原权利人所有) |
关于那份最初的歌词:最早的一版有不少听错的地方——他/她、冬历/农历、一只耳朵/一十二、六月/九月…… 后来对着原曲一句一句校成了 LRC。分镜也是从这份校对后的歌词长出来的。
原曲版权归原权利人所有;这里只作本地练习与记录,不用于商业发布。