Claude Cursor Skill

paint-mv

Turns a song (audio file + lyrics) into a hand-painted watercolour music video (MP4) in the style of PDoomVideo, following its pipeline end to end: measure the beat grid and time the lyrics, scaffold the p5.brush engine, design the story, characters and sets from the lyrics, pain

LLM Mart · 0 points · 1 views 1 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lintsinghua-paint-mv-skills-skills_paint-mv-ca8f5f7.zip · 40 KB
Part of lintsinghua/paint-mv-skills — 4 skills

Install

skills CLI npx skills add https://github.com/lintsinghua/paint-mv-skills/tree/main/skills/paint-mv
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lintsinghua-paint-mv-skills@llmmart
Git git clone https://github.com/lintsinghua/paint-mv-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lintsinghua/paint-mv-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

音乐 + 歌词 → 水彩动画 MV(paint-mv,总控)

复刻 PDoomVideo(Claude 画的《I'm Upping My P(doom)》MV)的制作流程:每一帧都是歌曲时间 t 的纯函数, 由 p5 + p5.brush 在 headless Chrome 里画成水彩与墨线,再用 ffmpeg 和歌曲合成 MP4。

**画风固定,内容跟着歌词走。**画风来自原作源码并保持不变:水彩加墨线的绘本观感、纸纹与"沸腾"的线条、字体、 卡拉 OK、笔刷转场、相机和卡拍工具、作画规则。构思、角色、场景、道具每首歌从歌词重新设计。原作的 Clawd、研究员、 剧场舞台和计量器是为那首歌(一首唱给 AI 的歌)设计的,留在引擎里作参考实现,歌词指向或用户要求时才用。

四个 skill 分工(与原作一致:导演写分镜和指南,并行子代理各画一章):

skill 角色 什么时候读
paint-mv(本文件) 导演/总控:建项目、对时、角色落地、派活、统稿、出片 从头做一支 MV
paint-mv-storyboard 分镜:从歌词定构思、角色、场景、镜头、转场 第 3 步
paint-mv-animate 画师:作画规范、角色设计(characters.md)、API、技法、原作九章示例 第 4、5 步(每个子代理都要读)
paint-mv-render 检查、渲染、编码、验收、排错 第 2、6、7 步

下文 <SKILLS> 指这几个 skill 所在的目录(本文件的上一级),<PROJECT> 指 MV 项目目录。

输入与前置条件

  • 音频文件(mp3/wav/flac/m4a…)。
  • 歌词:带时间轴的 LRC / SRT / VTT / TSV / JSON 最好;只有纯文本就先自动对齐(见第 1 步)。
  • Node.js ≥ 18、ffmpeg、Google Chrome、git 和 patch;纯文本歌词对齐还需要 uv(按需安装 faster-whisper)。
  • 原作源码不随本仓库分发。第一次建项目时 new_project.mjs 会调用 scripts/fetch_upstream.mjs,从 JohnHeibel/PDoomVideo 按固定提交拉下引擎、九章示例和原作分镜, 再打上 template.patch;也可以先手动跑 node <SKILLS>/paint-mv/scripts/fetch_upstream.mjs,之后才能读示例。

流程

复制这份清单并逐项推进:

- [ ] 1 建项目:new_project.mjs(引擎、音频、节拍网格、歌词、npm ci)
- [ ] 2 对时确认:速度/首拍有歧义或歌词是自动对齐的,出一段对拍短片给用户看
- [ ] 3 分镜:按 paint-mv-storyboard 从歌词写 STORYBOARD.md(构思、角色、场景、镜头),落到 song.js / 章节存根 / studio.html
- [ ] 4 角色与共享场景:按 characters.md 实现 src/characters.js、src/sets.js,出模型表给用户过目
- [ ] 5 作画:每章一个子代理并行(paint-mv-animate),各自用联系表检查
- [ ] 6 统稿:检查所有章节交界、转场、卡拉 OK 遮挡、回归的客串、性能
- [ ] 7 出片:全片逐帧渲染 → 编码 MP4 → 抽帧验收(paint-mv-render)

1 · 建项目

node <SKILLS>/paint-mv/scripts/new_project.mjs <PROJECT> --audio=song.mp3 --lyrics=song.lrc --title="歌名"

它会:复制引擎模板(template/)→ 把歌拷成 assets/song.* → 分析节拍(BPM、首拍、漂移、小节响度,存 assets/analysis.json 并打印报告)→ 把歌词转成 src/lyrics.js(打印警告)→ 写 src/song.js → npm ci。可选 --bpm=N、--offset=S 覆盖分析结果, --meter-label=TEXT(只有分镜要用计量器时),--no-install 跳过安装。

只有纯文本歌词时先打轴(文本每行一句、按演唱顺序写全,删掉歌名这类不唱的行),再把 SRT 交给 --lyrics:

uv run --python 3.12 --with faster-whisper --with zhconv python <SKILLS>/paint-mv/scripts/align_lyrics.py \
    song.mp3 lyrics.txt --out lyrics.srt --lang zh --words words.json

读分析报告和歌词警告:速度或首拍标了 AMBIGUOUS、grid drift 警告、歌词有重叠/过短/过宽,都要处理或记下。 报告的读法、格式细节、对齐精度与局限见 reference/timing.md。

2 · 对时确认

先出一张联系表(new_project.mjs 最后会打印命令),看卡拉 OK 是否在对的句子上。以下情况再出一段带声音的短片请用户看: 分析报告标了 AMBIGUOUS、歌词是自动对齐的、或者有被标记的句子。

cd <PROJECT> && node render.mjs --clip=<副歌开始>:<+15> --out=out/check/timing.mp4

占位画面里的小 Clawd 只是节拍器(按拍弹跳),卡拉 OK 按时间扫字。怎么根据反馈调 offset/bpm/歌词见 paint-mv-render「节拍与歌词的人工确认」。时间定下来之后再开始作画:之后改 bpm/offset/歌词,所有打点都要重对。

3 · 分镜

读 <SKILLS>/paint-mv-storyboard/SKILL.md,从歌词写 <PROJECT>/STORYBOARD.md:构思、角色表(每个角色有歌词依据和风格简报)、 各章场景、镜头表。然后按它的第 7 步落到项目:song.js 的 wipes(和计量器,如果用)、每章一个存根文件、studio.html 的章节 <script>。存根联系表渲染正常再往下走。把分镜给用户过目(尤其是构思、角色和结尾反转),用户有意见先改分镜。

4 · 角色与共享场景

所有章节都要用的东西在并行作画之前写好,就像原作的 clawd.js、cast.js、props.js 在派子代理之前就已存在:

  • 主要角色(两个以上章节出现)写进 src/characters.js,每个角色一个函数,遵守 <SKILLS>/paint-mv-animate/characters.md 的接口约定和画法配方,照分镜里的风格简报画。
  • 反复出现的场景和道具(副歌主场景、反复出现的象征)写进 src/sets.js。
  • 在 characters.js 里注册模型表 LOOPS.cast,渲染检查: node render.mjs --loop=cast --sheet=0.3,1.3,2.3,3.3,4.3,5.3 --cols=3 --out=out/check/cast.jpg。 大中小尺寸都认得出、各种表情和舞步都不穿帮、放在各章调色上对比够,再把模型表给用户过目;用户定了主角的样子再往下走。

可以自己写,也可以派一个子代理专门做(任务书同第 5 步,但它只改 characters.js 和 sets.js)。客串不在这里做: 由首次登场的章节实现并导出到 CAST。

5 · 作画(并行子代理)

原作做法:导演写好指南和分镜,每章派一个子代理并行作画,每个子代理只改自己的章节文件。 用 Task 工具在同一条消息里为每章各发一个子代理(互不依赖,可以同时渲染检查)。任务书模板:

你是 MV 项目 <PROJECT> 的章节画师,负责第 N 章 src/ch/cNN_name.js(start–end s,场景:…,调色:…)。
先完整阅读并遵守:
1. <SKILLS>/paint-mv-animate/SKILL.md(作画规范;需要时查同目录的 characters.md、api.md、techniques.md、examples/)
2. <PROJECT>/STORYBOARD.md:开头的「歌曲信息」、角色表、第 N 节全部镜头、第 N−1 节最后一镜和第 N+1 节第一镜
3. <PROJECT>/src/song.js、src/lyrics.js,以及本片角色 src/characters.js 和共享场景 src/sets.js(只读),先看角色模型表
要求:
- 只修改 src/ch/cNN_name.js,保持 chapter('name', start, end, …) 的名字和起止时间不变
- 角色用 characters.js 里的函数,画风与模型表一致;开场接住上一章的「出」:…;结尾交给下一章:…
- 本章要新建并导出的客串:CAST.xxx(签名 (x, y, s, t, o),要谢幕的支持 o.bow)/ 无
- 用联系表检查每个镜头直到满意;最慢帧不超过 4 s(目标 2.5 s);联系表和截图都输出到 out/check/cNN_*(各章并行渲染,别用默认路径)
- 完成后按规范最后一节汇报;共享文件的问题只报告不修改

后面章节要用前面章节导出的客串(比如结尾大合影):客串的 CAST 名字和签名在分镜角色表里定死;使用方按名字调用, 并像原作 c09_finale.js 的 who() 一样写后备画法,这样各章并行时也能先画起来。

子代理汇报后,看它给的联系表(自己用 Read 打开),不满意就带着具体意见让它继续改。共享文件(包括 characters.js、 sets.js)的修改由你统一做,改完通知所有子代理。

会话被打断(编辑器重启、网络中断)时,子代理也会停下,章节文件保留在打断时的状态。不要重新派活:用 Task 的 resume 接上原来的子代理(ID 就是它的 agent ID),告诉它停在哪个镜头、还剩哪些镜头,它的上下文都还在。

相邻章节的交接各自读对方的文件来对齐(颜色、相机、场景参数),任何一方改了开场或结尾,另一方都要再对一次,所以统稿要等 所有章节都交付之后再做。

6 · 统稿

  • 每个章节交界各看一组帧(交界前 0.1 s、交界、交界后 0.05 s、0.3 s),确认"出"和下一章开场对得上、笔刷擦除时刻正确。
  • 每章抽几帧看:角色是否都和模型表一致;卡拉 OK 条下有没有压住脸和关键动作;回归的客串是否来自 CAST(不是后备画法)。
  • 汇总各章最慢帧;超过 4 s 的退回对应章节优化。
  • 全片再看一次节奏:每个镜头都有事发生、相机都在动、字少、画面讲的是这句歌词。

7 · 出片

所有章节都交付之后再开渲:渲染时还在改的章节会把改到一半的状态渲进成片。赶时间要提前出片,就先让还在画的子代理停下, 等它交付后只重渲它那一段(见 paint-mv-render「全片渲染」)。

按 <SKILLS>/paint-mv-render/SKILL.md:node render.mjs --frames=0:<duration> --workers=4(可续渲,直到 0 frames to render) → node render.mjs --encode --out=out/mv.mp4 → ffprobe 与抽帧验收。把成片路径、时长和几张抽帧给用户。

风格与内容

风格(每首歌都一样,照原作):手绘水彩加墨线的绘本观感(角色平涂加墨线,背景柔和水彩,纸纹、暗角、每秒 12 次"沸腾"的线条); 可爱、卡通、色彩欢快但柔和;每个镜头都有事发生;相机一直在动;打点落在拍上,压扁拉伸、预备与过冲;情绪切换不硬切; 有动机的转场;少字,只有少数大拟声字;卡拉 OK 条与笔刷擦除。由引擎和 paint-mv-animate 的规则保证。

内容(每首歌重新设计,照歌词):构思与反转、角色、场景、道具、反复出现的象征都从歌词来(paint-mv-storyboard)。 原作对每首歌都适用的方向是"给每句歌词有趣的画面和转场";分镜阶段的要求是"用 p5.brush 笔触、每个场景都有看头、 每个场景都转场进下一个";给子代理的要求是"可爱、卡通、色彩欢快、动画活泼、每个镜头都有事发生、大胆有野心"。 原作还有一条"用 Clawd 的角色设计",那是针对那首 AI 歌的,不适用于别的歌。用户给了自己的方向就以用户为准。

规模参考:原作 156.6 s、46 句歌词、9 章、约 60 个镜头、章节代码约 6,600 行。更短的歌按比例减少,但规则不打折。

项目结构

<PROJECT>/
├── STORYBOARD.md            分镜(第 3 步写)
├── studio.html              绘制页面:依次加载 p5、p5.brush、src/*.js 和各章节
├── render.mjs               headless Chrome 渲染 + ffmpeg 编码
├── assets/  song.*  analysis.json  lyrics.*
├── src/
│   ├── song.js              歌曲配置:title, audio, duration, bpm, offset, wipes, meter, meterLabel, glyphs
│   ├── lyrics.js            LY = [[start, end, text], …](lyrics_to_ly.mjs 生成)
│   ├── core.js timeline.js  共享引擎:绘制、时间、相机、字、卡拉 OK、转场(原作源码)
│   ├── clawd.js cast.js props.js   原作的角色与剧场道具(参考实现;mood/move/emote 等通用工具也在 clawd.js)
│   ├── characters.js        本片主要角色 + 模型表 LOOPS.cast(第 4 步写)
│   ├── sets.js              本片共享场景与道具(第 4 步写)
│   └── ch/cNN_name.js       各章节(子代理画)
└── out/  check/  frames/  mv.mp4

引擎与原作源码的差异

模板(template/)由原作文件加 template.patch 生成(fetch_upstream.mjs),只改了必须随歌曲变化或随平台变化的地方:

  • 新增 src/song.js:原来写死在代码里的 BPM 88、首拍 0.21、时长 156.6、WIPES、METER、计量器标签、音频路径都移到这里; 计量器默认关闭(meter: []、meterLabel: '')。
  • 新增空的 src/characters.js、src/sets.js(本片主要角色和共享场景的位置),studio.html 在章节之前加载它们。
  • core.js / timeline.js / props.js:改读 SONG;字体栈加中日韩回退字体 ZCOOL KuaiLe 并预加载所需字形; 卡拉 OK 扫字速度对中日韩字符加权;调试拖动条上限跟随时长。
  • render.mjs:时长和音频读 song.js;Chrome 路径和 WebGL(ANGLE)后端按平台选,可用 --chrome/--angle 覆盖 (原作写死 Windows 的 d3d11,在 macOS 上创建不了 WebGL 上下文)。
  • studio.html:先加载 song.js,章节列表清空,字体链接加 ZCOOL KuaiLe。
  • clawd.js、cast.js 与原作逐字相同。

验证:用原作的配置和九个章节,模板渲染出的帧与原作逐字节一致。

Files (paint-mv-skills)
  • reference
    • timing.md 7.1 KB
      # 节拍与歌词对时(细节)
      
      引擎只认两样时间:一条**恒定速度的拍网格**(`song.js` 的 `bpm` + `offset`,拍点 = `offset + n × 60/bpm`)和
      **每句歌词的起止时间**(`lyrics.js` 的 `LY`)。所有打点、舞步、卡拉 OK 都由它们决定,所以动画开工前必须把它们弄对。
      
      ## analyze_audio.mjs
      
      ```bash
      node <SKILLS>/paint-mv/scripts/analyze_audio.mjs song.mp3            # 可读报告
      node <SKILLS>/paint-mv/scripts/analyze_audio.mjs song.mp3 --json     # JSON(new_project.mjs 存为 assets/analysis.json)
      node <SKILLS>/paint-mv/scripts/analyze_audio.mjs song.mp3 --bpm=88   # 指定速度,只拟合首拍
      ```
      
      做法:ffmpeg 解码成 22050 Hz 单声道 → 40 个对数频带的谱通量作起音包络 → 自相关(以 120 BPM 为中心、一个八度宽的先验)
      选速度 → 在 ±4% 内拟合恒定网格,BPM 精确到 0.01、首拍精确到约 1 ms → 整数或 .5 BPM 拟合得几乎一样好时取整
      (`song.js` 存的是取整后的值,首拍按它重新拟合)。
      
      报告怎么读:
      - `tempo candidates`:候选速度和相对强度。标了 **AMBIGUOUS** 表示有一个 2:1 或 3:2 关系的对手几乎一样强,
        光凭音频分不出拍子在哪一层(例如满是均匀三连音的歌,88 和 132 BPM 都成立);脚本取最接近 95 BPM 舞步脉搏的那个。
        两种网格的拍点都踩在真实起音上,选错只是舞步快慢不同,不会失步。拿不准就请用户看一段对拍短片(见 paint-mv-render)。
      - `beat 0 could also be at … ← AMBIGUOUS phase`:在半拍或三分之一拍之外还有几乎一样强的起音(反拍很重、或满是三连音),
        哪个位置才是拍头只是猜测。选错会让所有打点整体早或晚半拍/三分之一拍。同样请用户看对拍短片,用 `--offset=S` 选定。
        原作歌曲就是这种情况:首选 0.247 s,备选 0.474 / 0.02 s,原作手定的 0.21 s 最接近首选。
      - `grid drift`:每 32 拍局部最佳相位相对全曲网格的偏移(ms)。稳定的歌都在几 ms 内;超过约 45 ms 会警告:
        这首歌速度不恒定(现场录音、渐快渐慢),恒定网格在那些段落会漂,那里的打点要按听感手动定时间,别用 `B(n)`。
      - `bars start on beats ≡ k (mod 4)`:小节起点的猜测(按底鼓落点),置信度低时只作参考。
      - `loudness per bar`:每小节响度条形图,找前奏、主歌、副歌、间奏、尾奏的边界。
      
      精度(实测):合成点击音轨的首拍误差 1 ms;原作歌曲测出 88 BPM(与原作一致,并正确报出 88/132 歧义),
      首拍 0.247 s(原作手定 0.21 s,差 37 ms,不到 24 fps 的一帧)。需要时在 `song.js` 里微调 `offset`,一帧约 0.042 s。
      
      ## lyrics_to_ly.mjs
      
      ```bash
      node <SKILLS>/paint-mv/scripts/lyrics_to_ly.mjs lyrics.lrc --out=src/lyrics.js --duration=<song.js 的 duration> [--shift=0.2]
      ```
      
      | 格式 | 说明 |
      |---|---|
      | LRC | `[mm:ss.xx]歌词`;支持一行多个时间标签、`[offset:±ms]`、逐字 `<mm:ss.xx>` 标签(会去掉);**空时间行 `[mm:ss.xx]` 表示上一句在此结束**;同一时间戳的第二行(常见的翻译行)会丢弃并警告 |
      | SRT / VTT | 每条字幕的起止时间直接用;多行文字合成一行 |
      | TSV | `起<TAB>止<TAB>歌词` 或 `起<TAB>歌词`(秒或 mm:ss.xx) |
      | JSON | `[[起, 止, "歌词"]]` 或 `[{start, end, text}]` |
      | 纯文本 | 拒绝,先用 align_lyrics.py 打轴 |
      
      没有结束时间(普通 LRC)时:结束 = min(下一句开始 − 0.1 s, 开始 + max(2 s, 2 × 扫字时间)),
      这样卡拉 OK 不会一直挂过间奏。实测在原作歌词上,只有开始时间的 LRC 结束时间平均偏差 0.32 s(间奏前的句子最多拖 2.5 s);
      加上空时间行标出间奏开始后偏差 ≤ 0.1 s。
      
      会给出警告的情况:句子重叠(自动截短)、短于 0.6 s(卡拉 OK 条几乎打不开)、太宽放不进卡拉 OK 条
      (50px 字号下约 70 个拉丁字符或 33 个汉字,需要拆成两句)、超出歌曲时长、第一句早于 1 s。
      
      ## align_lyrics.py(只有纯文本歌词时)
      
      ```bash
      uv run --python 3.12 --with faster-whisper --with zhconv python <SKILLS>/paint-mv/scripts/align_lyrics.py \
          song.mp3 lyrics.txt --out assets/lyrics.srt --lang zh --model small --words assets/words.json
      ```
      
      - 歌词文本:**每行一句,按演唱顺序写全**,重复的副歌每唱一次就写一次。`[Chorus]`、`(Verse 2)`、`【副歌】`、`Chorus:` 这类段落标记会被跳过。
        歌名、作词作曲这类**不唱的行要删掉**,否则会被当成第一句去找时间。
      - 做法:faster-whisper 带词级时间戳转写(从不跳过"无人声"窗口:音乐常骗过静音检测,漏掉一个 30 s 窗口就丢掉后面所有句子),
        再把已知歌词和转写结果按字符序列对齐(difflib),唱错/听错的词由邻居带出时间;听不到的句子按字数分摊到前后句之间。
        修正了 Whisper 的几个常见毛病:停顿后第一个词把停顿"吞"进自己的时长(按词长封顶)、同一个词复读(只留第一个)、
        零时长的词(成串出现时是幻觉出来的"背诵",丢掉不用);一句里匹配上的字挤在比人能唱的还短的时间里
        (每个汉字不到约 0.08 s、每个字母不到约 0.03 s)就当这句没听到。
      - 默认不给识别器提示。`--prompt` 把歌词作为 `hotwords` 提示每个窗口:生僻词和专有名词听得更准,但识别器可能在器乐段
        "背"出歌词(实测一首中文说唱:305 个词里 138 个零时长,整段副歌被挤到同一时刻)。超过 20% 的词没有时长时脚本会警告,
        建议换另一种模式或更大的模型。
      - 输出每句的听到比例;`← weak`(听到不足一半)和 `← neighbour of a weak line`(它的邻居,时间常被挤歪)要人工复核。
      - `--words` 缓存转写结果:改了歌词文本再对齐只要几秒。缓存与 `--prompt`、`--model` 无关,换模式或模型时换个缓存文件名。
      - 精度(实测,原作歌曲、small 模型):句首误差中位数 0.34 s,46 句里 32 句在 0.5 s 内(带 `--prompt`:0.31 s、31 句),
        所有误差超过 1 s 的句子都被标出。
        想更准用 `--model medium` 或 `large-v3`(下载更大、更慢);最好的办法仍是拿到现成的 LRC(音乐平台的歌词文件)。
      - 首次运行会下载模型(small 约 0.5 GB)。Hugging Face 慢就设 `HF_ENDPOINT=https://hf-mirror.com`。
        繁体输出靠 zhconv 统一成简体再匹配(去掉 `--with zhconv` 也能跑,只是繁简不会互认)。
      
      结果是 SRT,接着用 lyrics_to_ly.mjs 转成 `src/lyrics.js`(`new_project.mjs --lyrics=assets/lyrics.srt` 会自动做)。
      
      ## 中日韩歌词
      
      模板在拉丁字体后面加了 ZCOOL KuaiLe 作中日韩字符的回退(卡拉 OK、`letter()` 都生效),启动时预加载歌词、歌名、
      计量器标签和 `SONG.glyphs` 用到的字形,保证并行渲染的每一帧都是同一种字体。章节里要写不在这些文字里的汉字时,
      把它们加进 `song.js` 的 `glyphs`。卡拉 OK 扫字速度按一个汉字约等于四个拉丁字母计。
      
  • scripts
    • align_lyrics.py 9.3 KB
      #!/usr/bin/env python3
      """align_lyrics.py: time plain lyrics (one sung line per text line) against the song and write an SRT that
      lyrics_to_ly.mjs turns into src/lyrics.js.
      
      faster-whisper transcribes the song with word timestamps; the known lyrics are then matched to the transcript character
      by character (difflib), so misheard words still get timed by their neighbours. Lines it could not hear are placed in the
      gap between their neighbours and flagged. Run it through uv so the dependencies install on demand:
      
        uv run --python 3.12 --with faster-whisper --with zhconv python align_lyrics.py song.mp3 lyrics.txt \\
            --out lyrics.srt [--lang zh] [--model small] [--words words.json] [--prompt]
      
      --words caches the transcript, so re-aligning after fixing the lyrics text takes seconds instead of minutes.
      Section markers such as [Chorus], (Verse 2), 【副歌】 or "Chorus:" are skipped. Models download from Hugging Face on first
      use (small ~0.5 GB, medium ~1.5 GB, large-v3 ~3 GB); set HF_ENDPOINT=https://hf-mirror.com if that is slow.
      """
      import argparse
      import difflib
      import json
      import os
      import re
      import statistics
      import sys
      
      CJK = re.compile(r'[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]')
      MARKER = re.compile(r'^\s*(\[[^\]]*\]|\([^)]*\)|【[^】]*】|(verse|chorus|bridge|intro|outro|hook|pre-chorus)\s*\d*\s*:?)\s*$', re.I)
      
      try:
          from zhconv import convert as _zh
          def simplify(s): return _zh(s, 'zh-cn')
      except ImportError:
          def simplify(s): return s
      
      
      def norm_chars(s):
          """Characters that take part in matching: CJK characters and lowercase letters/digits."""
          s = simplify(s).lower()
          return [c for c in s if CJK.match(c) or c.isalnum()]
      
      
      def read_lines(path):
          lines = []
          with open(path, encoding='utf-8') as f:
              for raw in f:
                  t = raw.strip()
                  if not t or MARKER.match(t):
                      continue
                  if norm_chars(t):
                      lines.append(t)
          return lines
      
      
      def transcribe(audio, model_name, lang, device, hint):
          from faster_whisper import WhisperModel
          model = WhisperModel(model_name, device=device, compute_type='int8' if device in ('cpu', 'auto') else 'float16',
                               cpu_threads=os.cpu_count() or 4)
          # Music fools the no-speech detector, and one skipped window loses every line after it: never skip. `hotwords`
          # (only with --prompt) re-primes every 30 s window with the lyrics; it helps with odd words but can make the model
          # recite the whole lyric sheet over an instrumental, so it is off by default.
          segments, info = model.transcribe(audio, language=lang, word_timestamps=True, vad_filter=False, beam_size=5,
                                            condition_on_previous_text=False, no_speech_threshold=None, hotwords=hint)
          words = [(w.start, w.end, w.word) for s in segments for w in (s.words or [])]
          return words, info
      
      
      def align(lines, words):
          # lyric side: every matchable character with its line; transcript side: characters timed by spreading each word
          L, owner = [], []
          for i, t in enumerate(lines):
              for c in norm_chars(t):
                  L.append(c)
                  owner.append(i)
          A, at, last = [], [], None
          for s, e, w in words:
              cs = norm_chars(w)
              # a word with no duration carries no timing (and whole runs of them are hallucinated recitation); "just, just,
              # just, just" is Whisper looping on a word: keep the first
              if not cs or e - s < .02 or cs == last:
                  continue
              last = cs
              # Whisper stretches the first word after a pause back over the pause: keep its end, cap how long it can last
              cjk = sum(1 for c in cs if CJK.match(c))
              cap = .7 * cjk + (.4 + .1 * (len(cs) - cjk) if len(cs) > cjk else 0)
              s = max(s, e - cap)
              for k, c in enumerate(cs):
                  A.append(c)
                  at.append((s + (e - s) * k / len(cs), s + (e - s) * (k + 1) / len(cs)))
          hit = {}
          for i, j, n in difflib.SequenceMatcher(None, L, A, autojunk=False).get_matching_blocks():
              for k in range(n):
                  hit[i + k] = at[j + k]
          durs = [b - a for a, b in hit.values() if b > a]
          cdur = statistics.median(durs) if durs else .25
          first = {}
          for idx, ln in enumerate(owner):
              first.setdefault(ln, idx)
          out = []
          for ln, text in enumerate(lines):
              i0 = first[ln]
              n = sum(1 for o in owner if o == ln)
              m = [(k - i0, hit[k]) for k in range(i0, i0 + n) if k in hit]
              if m:   # drop matches that wandered far from the line's median time
                  med = statistics.median(t[0] for _, t in m)
                  m = [(k, t) for k, t in m if abs(t[0] - med) < 6]
              cov = len(m) / n
              if cov >= .2:
                  (k0, t0), (k1, t1) = m[0], m[-1]
                  # nobody sings faster than ~0.08 s per CJK character or ~0.03 s per letter: a tighter match landed on a
                  # garbled stretch
                  if t1[1] - t0[0] < sum(.08 if CJK.match(c) else .03 for c in L[i0 + k0:i0 + k1 + 1]):
                      cov = 0
              if cov < .2:
                  out.append([None, None, text, cov])
                  continue
              out.append([max(0, t0[0] - k0 * cdur), t1[1] + (n - 1 - k1) * cdur, text, cov])
          # unheard lines: share the gap between the known neighbours in proportion to their length
          i = 0
          while i < len(out):
              if out[i][0] is not None:
                  i += 1
                  continue
              j = i
              while j < len(out) and out[j][0] is None:
                  j += 1
              a = out[i - 1][1] if i else 0.0
              b = out[j][0] if j < len(out) else a + sum(len(r[2]) for r in out[i:j]) * cdur * 1.5
              w = [len(norm_chars(r[2])) for r in out[i:j]]
              x = a
              for r, k in zip(out[i:j], w):
                  span = (b - a) * k / sum(w)
                  r[0], r[1] = x + .05, x + span - .05
                  x += span
              i = j
          # no overlaps, nothing shorter than 0.5 s
          for k in range(len(out)):
              if k and out[k][0] < out[k - 1][1] + .05:
                  out[k][0] = out[k - 1][1] + .05
              out[k][1] = max(out[k][1], out[k][0] + .5)
          for k in range(len(out) - 1):
              if out[k][1] > out[k + 1][0] - .05:
                  out[k][1] = max(out[k][0] + .3, out[k + 1][0] - .05)
          return out
      
      
      def srt_time(s):
          ms = int(round(s * 1000))
          return f'{ms // 3600000:02d}:{ms // 60000 % 60:02d}:{ms // 1000 % 60:02d},{ms % 1000:03d}'
      
      
      def main():
          ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
          ap.add_argument('audio')
          ap.add_argument('lyrics', help='UTF-8 text, one sung line per line')
          ap.add_argument('--out', default='lyrics.srt')
          ap.add_argument('--lang', default=None, help='language code (zh, en, ja, ...); detected when omitted')
          ap.add_argument('--model', default='small', help='tiny | base | small | medium | large-v3')
          ap.add_argument('--device', default='auto', help='auto | cpu | cuda')
          ap.add_argument('--prompt', action='store_true', help='prime the recogniser with the lyrics (hotwords): better on odd '
                                                                'words, but it can recite lyrics over instrumentals')
          ap.add_argument('--words', help='cache of the transcript (JSON): reused if it exists, written otherwise, so '
                                          'realigning edited lyrics skips the slow transcription')
          a = ap.parse_args()
      
          lines = read_lines(a.lyrics)
          if not lines:
              sys.exit(f'no lyric lines in {a.lyrics}')
          if a.words and os.path.exists(a.words):
              with open(a.words, encoding='utf-8') as f:
                  words = [tuple(w) for w in json.load(f)]
              print(f'reusing {len(words)} transcribed words from {a.words}', file=sys.stderr)
          else:
              hint = ' '.join(dict.fromkeys(lines)) if a.prompt else None   # choruses once; the model keeps what fits
              print(f'transcribing {a.audio} with faster-whisper {a.model} …', file=sys.stderr)
              words, info = transcribe(a.audio, a.model, a.lang, a.device, hint)
              print(f'  language {info.language} ({info.language_probability:.2f}), {len(words)} words heard', file=sys.stderr)
              if a.words:
                  with open(a.words, 'w', encoding='utf-8') as f:
                      json.dump(words, f, ensure_ascii=False)
          flat = sum(1 for s, e, _ in words if e - s < .02)
          if words and flat > .2 * len(words):
              print(f'  warning: {flat} of {len(words)} words have no duration (hallucinated or garbled stretches); '
                    f'those were ignored. If lines look wrong, retry {"without" if a.prompt else "with"} --prompt or a larger --model.',
                    file=sys.stderr)
          rows = align(lines, words)
          with open(a.out, 'w', encoding='utf-8') as f:
              for i, (s, e, t, _) in enumerate(rows, 1):
                  f.write(f'{i}\n{srt_time(s)} --> {srt_time(e)}\n{t}\n\n')
          # a weakly heard line also skews its neighbours (the recogniser smears their words over the gap): check those too
          weak = {i for i, r in enumerate(rows) if r[3] < .5}
          check = weak | {j for i in weak for j in (i - 1, i + 1) if 0 <= j < len(rows)}
          print(f'{len(rows)} lines → {a.out}; {len(weak)} weakly heard, {len(check)} to check by ear:', file=sys.stderr)
          for i, (s, e, t, c) in enumerate(rows):
              flag = '  ← weak' if i in weak else '  ← neighbour of a weak line' if i in check else ''
              print(f'  {s:7.2f} – {e:7.2f}  {c:4.0%}  {t}{flag}', file=sys.stderr)
      
      
      if __name__ == '__main__':
          main()
      
    • analyze_audio.mjs 11.8 KB · in bundle
    • fetch_upstream.mjs 2.8 KB · in bundle
    • lyrics_to_ly.mjs 6.9 KB · in bundle
    • new_project.mjs 4.9 KB · in bundle
  • template
    • assets
      • .gitkeep 0 B · in bundle
    • src
      • ch
        • .gitkeep 0 B · in bundle
      • characters.js 605 B
        // characters.js: this song's main cast, designed from its lyrics (STORYBOARD.md, cast table) and shared by every chapter.
        // Written before the chapters are painted. One function per character, same contract as clawd() / researcher():
        //   name(x, y, u, o): (x, y) is the ground point between the feet, u the size unit, o the pose / face / hook options,
        //   so move(), mood(), emote and hand hooks work on everyone. See paint-mv-animate/characters.md.
        // Register a model sheet as LOOPS.cast and check the cast with
        //   node render.mjs --loop=cast --sheet=0,1,2,3,4,5 --cols=3 --out=out/check/cast.jpg
        
      • lyrics.js 123 B
        // lyrics.js: [start, end, text]. Generated by lyrics_to_ly.mjs; regenerate it instead of editing by hand.
        const LY = [
        ];
        
      • sets.js 414 B
        // sets.js: the places and props this song returns to in more than one chapter (the chorus home set, a recurring motif),
        // designed from its lyrics and shared by every chapter. Same conventions as props.js: world coordinates of a 1920x1080
        // frame, backgrounds painted larger than the frame so camera moves and shakes never show an edge, a back layer drawn
        // before the characters and a front layer after them.
        
      • song.js 1 KB
        // song.js: the one place that knows which song this project animates. new_project.mjs writes it from the audio analysis;
        // the storyboard step fills in wipes and meter. Read by core.js (BPM, OFF, DUR), timeline.js (wipes, meter) and render.mjs.
        const SONG = {
          title: 'Untitled',
          audio: 'assets/song.mp3',   // mixed into the MP4 by render.mjs
          duration: 60,               // seconds; the last frame is ceil(duration * fps) - 1
          bpm: 120,                   // constant tempo of the beat grid
          offset: 0,                  // time of beat 0 in seconds; beats fall at offset + n * 60 / bpm
          wipes: [],                  // chapter breaks that get a brush wipe (s)
          meter: [],                  // optional: [start, end, from %, to %] windows where the meter prop climbs, beat by beat
          meterLabel: '',             // lettering on the meter prop and the corner meter (only if the song uses the meter)
          glyphs: '',                 // any other CJK text the chapters letter, preloaded so every frame gets the same font
        };
        
    • .gitignore 37 B · in bundle
    • package-lock.json 19.5 KB
      {
        "name": "paint-mv-project",
        "version": "1.0.0",
        "lockfileVersion": 3,
        "requires": true,
        "packages": {
          "": {
            "name": "paint-mv-project",
            "version": "1.0.0",
            "license": "ISC",
            "dependencies": {
              "p5": "^2.3.3",
              "p5.brush": "^2.2.3",
              "puppeteer-core": "^25.11.0"
            }
          },
          "node_modules/@babel/runtime": {
            "version": "7.29.7",
            "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
            "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
            "license": "MIT",
            "engines": {
              "node": ">=6.9.0"
            }
          },
          "node_modules/@davepagurek/bezier-path": {
            "version": "0.0.7",
            "resolved": "https://registry.npmjs.org/@davepagurek/bezier-path/-/bezier-path-0.0.7.tgz",
            "integrity": "sha512-CVlnCOrV1iy4Z12T756i9l4G6kF7r8uhlnb+xqDemAMmWQB+8Q0b+8VEqIiUfywgZDSiDr18Rm7pZlnA69rE8Q==",
            "license": "MIT"
          },
          "node_modules/@japont/unicode-range": {
            "version": "1.0.0",
            "resolved": "https://registry.npmjs.org/@japont/unicode-range/-/unicode-range-1.0.0.tgz",
            "integrity": "sha512-BckHvA2XdjRBVAWe2uceNuRf78lBeI28kyWEbfr/Q2pE17POkwuZ6WWY/UMv8FL9iBxhW4xfDoNLM9UVZaTeUQ==",
            "license": "MIT"
          },
          "node_modules/@puppeteer/browsers": {
            "version": "3.2.2",
            "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz",
            "integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==",
            "license": "Apache-2.0",
            "dependencies": {
              "modern-tar": "^0.8.4",
              "yargs": "^18.0.0"
            },
            "bin": {
              "browsers": "lib/main-cli.js"
            },
            "engines": {
              "node": ">=22.12.0"
            },
            "peerDependencies": {
              "proxy-agent": ">=8.0.1",
              "yauzl": "^2.10.0 || ^3.4.0"
            },
            "peerDependenciesMeta": {
              "proxy-agent": {
                "optional": true
              },
              "yauzl": {
                "optional": true
              }
            }
          },
          "node_modules/acorn": {
            "version": "8.18.0",
            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
            "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
            "license": "MIT",
            "bin": {
              "acorn": "bin/acorn"
            },
            "engines": {
              "node": ">=0.4.0"
            }
          },
          "node_modules/acorn-walk": {
            "version": "8.3.5",
            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
            "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
            "license": "MIT",
            "dependencies": {
              "acorn": "^8.11.0"
            },
            "engines": {
              "node": ">=0.4.0"
            }
          },
          "node_modules/ansi-regex": {
            "version": "6.3.0",
            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
            "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
            "license": "MIT",
            "engines": {
              "node": ">=12"
            },
            "funding": {
              "url": "https://github.com/chalk/ansi-regex?sponsor=1"
            }
          },
          "node_modules/ansi-styles": {
            "version": "6.2.3",
            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
            "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
            "license": "MIT",
            "engines": {
              "node": ">=12"
            },
            "funding": {
              "url": "https://github.com/chalk/ansi-styles?sponsor=1"
            }
          },
          "node_modules/chromium-bidi": {
            "version": "17.0.2",
            "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
            "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
            "license": "Apache-2.0",
            "dependencies": {
              "mitt": "^3.0.1",
              "zod": "^3.24.1"
            },
            "engines": {
              "node": ">=20.19.0 <22.0.0 || >=22.12.0"
            },
            "peerDependencies": {
              "devtools-protocol": "*"
            }
          },
          "node_modules/chromium-bidi/node_modules/zod": {
            "version": "3.25.76",
            "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
            "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
            "license": "MIT",
            "funding": {
              "url": "https://github.com/sponsors/colinhacks"
            }
          },
          "node_modules/cliui": {
            "version": "9.0.1",
            "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
            "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
            "license": "ISC",
            "dependencies": {
              "string-width": "^7.2.0",
              "strip-ansi": "^7.1.0",
              "wrap-ansi": "^9.0.0"
            },
            "engines": {
              "node": ">=20"
            }
          },
          "node_modules/cliui/node_modules/string-width": {
            "version": "7.2.0",
            "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
            "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
            "license": "MIT",
            "dependencies": {
              "emoji-regex": "^10.3.0",
              "get-east-asian-width": "^1.0.0",
              "strip-ansi": "^7.1.0"
            },
            "engines": {
              "node": ">=18"
            },
            "funding": {
              "url": "https://github.com/sponsors/sindresorhus"
            }
          },
          "node_modules/colorjs.io": {
            "version": "0.6.1",
            "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.6.1.tgz",
            "integrity": "sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==",
            "license": "MIT",
            "funding": {
              "type": "opencollective",
              "url": "https://opencollective.com/color"
            }
          },
          "node_modules/devtools-protocol": {
            "version": "0.0.1680125",
            "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1680125.tgz",
            "integrity": "sha512-rVARKAvOFJCFkvgX01VLvYqKIvt+cRCqvUxIhB2oAqVGPalxk3w3sb5TUGDagpow/EFblBpm/Zl/29QA2dd+jA==",
            "license": "BSD-3-Clause"
          },
          "node_modules/emoji-regex": {
            "version": "10.6.0",
            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
            "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
            "license": "MIT"
          },
          "node_modules/escalade": {
            "version": "3.2.0",
            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
            "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
            "license": "MIT",
            "engines": {
              "node": ">=6"
            }
          },
          "node_modules/escodegen": {
            "version": "2.1.0",
            "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
            "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
            "license": "BSD-2-Clause",
            "dependencies": {
              "esprima": "^4.0.1",
              "estraverse": "^5.2.0",
              "esutils": "^2.0.2"
            },
            "bin": {
              "escodegen": "bin/escodegen.js",
              "esgenerate": "bin/esgenerate.js"
            },
            "engines": {
              "node": ">=6.0"
            },
            "optionalDependencies": {
              "source-map": "~0.6.1"
            }
          },
          "node_modules/esprima": {
            "version": "4.0.1",
            "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
            "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
            "license": "BSD-2-Clause",
            "bin": {
              "esparse": "bin/esparse.js",
              "esvalidate": "bin/esvalidate.js"
            },
            "engines": {
              "node": ">=4"
            }
          },
          "node_modules/estraverse": {
            "version": "5.3.0",
            "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
            "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
            "license": "BSD-2-Clause",
            "engines": {
              "node": ">=4.0"
            }
          },
          "node_modules/esutils": {
            "version": "2.0.3",
            "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
            "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
            "license": "BSD-2-Clause",
            "engines": {
              "node": ">=0.10.0"
            }
          },
          "node_modules/get-caller-file": {
            "version": "2.0.5",
            "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
            "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
            "license": "ISC",
            "engines": {
              "node": "6.* || 8.* || >= 10.*"
            }
          },
          "node_modules/get-east-asian-width": {
            "version": "1.7.0",
            "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
            "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
            "license": "MIT",
            "engines": {
              "node": ">=18"
            },
            "funding": {
              "url": "https://github.com/sponsors/sindresorhus"
            }
          },
          "node_modules/gifenc": {
            "version": "1.0.3",
            "resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
            "integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
            "license": "MIT"
          },
          "node_modules/i18next": {
            "version": "19.9.2",
            "resolved": "https://registry.npmjs.org/i18next/-/i18next-19.9.2.tgz",
            "integrity": "sha512-0i6cuo6ER6usEOtKajUUDj92zlG+KArFia0857xxiEHAQcUwh/RtOQocui1LPJwunSYT574Pk64aNva1kwtxZg==",
            "license": "MIT",
            "dependencies": {
              "@babel/runtime": "^7.12.0"
            }
          },
          "node_modules/i18next-browser-languagedetector": {
            "version": "4.3.1",
            "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-4.3.1.tgz",
            "integrity": "sha512-KIToAzf8zwWvacgnRwJp63ase26o24AuNUlfNVJ5YZAFmdGhsJpmFClxXPuk9rv1FMI4lnc8zLSqgZPEZMrW4g==",
            "license": "MIT",
            "dependencies": {
              "@babel/runtime": "^7.5.5"
            }
          },
          "node_modules/libtess": {
            "version": "1.2.2",
            "resolved": "https://registry.npmjs.org/libtess/-/libtess-1.2.2.tgz",
            "integrity": "sha512-Nps8HPeVVcsmJxUvFLKVJcCgcz+1ajPTXDVAVPs6+giOQP4AHV31uZFFkh+CKow/bkB7GbZWKmwmit7myaqDSw==",
            "license": "SGI-B-2.0"
          },
          "node_modules/mitt": {
            "version": "3.0.1",
            "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
            "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
            "license": "MIT"
          },
          "node_modules/modern-tar": {
            "version": "0.8.5",
            "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz",
            "integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
            "license": "MIT",
            "engines": {
              "node": ">=18.0.0"
            }
          },
          "node_modules/omggif": {
            "version": "1.0.10",
            "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
            "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==",
            "license": "MIT"
          },
          "node_modules/p5": {
            "version": "2.3.3",
            "resolved": "https://registry.npmjs.org/p5/-/p5-2.3.3.tgz",
            "integrity": "sha512-+zVz5JFp3sCLpLscTMOLoA0UEHV0dsHxngKVfMmeW1J2/IXvfFqCjNYZeIOtG4Lhfnpm7cu6446P6Ho64ihXfA==",
            "license": "LGPL-2.1",
            "dependencies": {
              "@davepagurek/bezier-path": "^0.0.7",
              "@japont/unicode-range": "^1.0.0",
              "acorn": "^8.15.0",
              "acorn-walk": "^8.3.4",
              "colorjs.io": "^0.6.0",
              "escodegen": "^2.1.0",
              "gifenc": "^1.0.3",
              "i18next": "^19.0.2",
              "i18next-browser-languagedetector": "^4.0.1",
              "libtess": "^1.2.2",
              "omggif": "^1.0.10",
              "pako": "^2.1.0",
              "zod": "^4.2.1"
            }
          },
          "node_modules/p5.brush": {
            "version": "2.2.3",
            "resolved": "https://registry.npmjs.org/p5.brush/-/p5.brush-2.2.3.tgz",
            "integrity": "sha512-Jxl7Rl01StakZN7OnaY+HYLXvemeIiCnDzVz6qnvsABF8GunCBcQON7xUKZUjV1E/Ab7VIFSMOspmVzLO/ljTw==",
            "license": "MIT",
            "dependencies": {
              "simplex-noise": "^4.0.3"
            },
            "peerDependencies": {
              "p5": "^2.2"
            }
          },
          "node_modules/pako": {
            "version": "2.2.0",
            "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
            "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
            "funding": [
              {
                "type": "github",
                "url": "https://github.com/sponsors/puzrin"
              },
              {
                "type": "github",
                "url": "https://github.com/sponsors/nodeca"
              }
            ],
            "license": "(MIT AND Zlib)"
          },
          "node_modules/puppeteer-core": {
            "version": "25.11.0",
            "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.11.0.tgz",
            "integrity": "sha512-Ujym7JbxoAqanaxGcHgmTomfhC01HfDCSuGZ8Hs9boHrDjzbNVMnFRJo2KtbDT9LCRgeJoFnkn9JQZ41eEUrLw==",
            "license": "Apache-2.0",
            "dependencies": {
              "@puppeteer/browsers": "3.2.2",
              "chromium-bidi": "17.0.2",
              "devtools-protocol": "0.0.1680125",
              "typed-query-selector": "^2.12.2",
              "webdriver-bidi-protocol": "0.4.3",
              "ws": "^8.21.3"
            },
            "engines": {
              "node": ">=22.12.0"
            }
          },
          "node_modules/simplex-noise": {
            "version": "4.0.3",
            "resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz",
            "integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==",
            "license": "MIT"
          },
          "node_modules/source-map": {
            "version": "0.6.1",
            "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
            "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
            "license": "BSD-3-Clause",
            "optional": true,
            "engines": {
              "node": ">=0.10.0"
            }
          },
          "node_modules/string-width": {
            "version": "8.2.2",
            "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
            "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
            "license": "MIT",
            "dependencies": {
              "get-east-asian-width": "^1.5.0",
              "strip-ansi": "^7.1.2"
            },
            "engines": {
              "node": ">=20"
            },
            "funding": {
              "url": "https://github.com/sponsors/sindresorhus"
            }
          },
          "node_modules/strip-ansi": {
            "version": "7.2.0",
            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
            "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
            "license": "MIT",
            "dependencies": {
              "ansi-regex": "^6.2.2"
            },
            "engines": {
              "node": ">=12"
            },
            "funding": {
              "url": "https://github.com/chalk/strip-ansi?sponsor=1"
            }
          },
          "node_modules/typed-query-selector": {
            "version": "2.12.2",
            "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
            "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
            "license": "MIT"
          },
          "node_modules/webdriver-bidi-protocol": {
            "version": "0.4.3",
            "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz",
            "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==",
            "license": "Apache-2.0"
          },
          "node_modules/wrap-ansi": {
            "version": "9.0.2",
            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
            "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
            "license": "MIT",
            "dependencies": {
              "ansi-styles": "^6.2.1",
              "string-width": "^7.0.0",
              "strip-ansi": "^7.1.0"
            },
            "engines": {
              "node": ">=18"
            },
            "funding": {
              "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
            }
          },
          "node_modules/wrap-ansi/node_modules/string-width": {
            "version": "7.2.0",
            "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
            "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
            "license": "MIT",
            "dependencies": {
              "emoji-regex": "^10.3.0",
              "get-east-asian-width": "^1.0.0",
              "strip-ansi": "^7.1.0"
            },
            "engines": {
              "node": ">=18"
            },
            "funding": {
              "url": "https://github.com/sponsors/sindresorhus"
            }
          },
          "node_modules/ws": {
            "version": "8.21.3",
            "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
            "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
            "license": "MIT",
            "engines": {
              "node": ">=10.0.0"
            },
            "peerDependencies": {
              "bufferutil": "^4.0.1",
              "utf-8-validate": ">=5.0.2"
            },
            "peerDependenciesMeta": {
              "bufferutil": {
                "optional": true
              },
              "utf-8-validate": {
                "optional": true
              }
            }
          },
          "node_modules/y18n": {
            "version": "5.0.8",
            "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
            "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
            "license": "ISC",
            "engines": {
              "node": ">=10"
            }
          },
          "node_modules/yargs": {
            "version": "18.2.0",
            "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
            "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
            "license": "MIT",
            "dependencies": {
              "cliui": "^9.0.1",
              "escalade": "^3.1.1",
              "get-caller-file": "^2.0.5",
              "string-width": "^8.2.1",
              "y18n": "^5.0.5",
              "yargs-parser": "^22.0.0"
            },
            "engines": {
              "node": "^20.19.0 || ^22.12.0 || >=23"
            }
          },
          "node_modules/yargs-parser": {
            "version": "22.0.0",
            "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
            "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
            "license": "ISC",
            "engines": {
              "node": "^20.19.0 || ^22.12.0 || >=23"
            }
          },
          "node_modules/zod": {
            "version": "4.6.5",
            "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
            "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
            "license": "MIT",
            "funding": {
              "url": "https://github.com/sponsors/colinhacks"
            }
          }
        }
      }
      
    • package.json 334 B
      {
        "name": "paint-mv-project",
        "version": "1.0.0",
        "main": "index.js",
        "scripts": {
          "test": "echo \"Error: no test specified\" && exit 1"
        },
        "keywords": [],
        "author": "",
        "license": "ISC",
        "description": "",
        "dependencies": {
          "p5": "^2.3.3",
          "p5.brush": "^2.2.3",
          "puppeteer-core": "^25.11.0"
        }
      }
      
  • SKILL.md 14.1 KB
    ---
    name: paint-mv
    description: >-
      Turns a song (audio file + lyrics) into a hand-painted watercolour music video (MP4) in the style of PDoomVideo,
      following its pipeline end to end: measure the beat grid and time the lyrics, scaffold the p5.brush engine, design the
      story, characters and sets from the lyrics, paint one chapter per parallel subagent with contact-sheet checks, render
      every frame in headless Chrome and encode with the song via ffmpeg. The director skill that drives
      paint-mv-storyboard, paint-mv-animate and paint-mv-render. Use when the user wants to make an MV / music video /
      lyric video / 歌词视频 / 动画 MV from a song and its lyrics, or mentions PDoomVideo or p5.brush videos.
    ---
    
    # 音乐 + 歌词 → 水彩动画 MV(paint-mv,总控)
    
    复刻 PDoomVideo(Claude 画的《I'm Upping My P(doom)》MV)的制作流程:每一帧都是歌曲时间 t 的纯函数,
    由 p5 + p5.brush 在 headless Chrome 里画成水彩与墨线,再用 ffmpeg 和歌曲合成 MP4。
    
    **画风固定,内容跟着歌词走。**画风来自原作源码并保持不变:水彩加墨线的绘本观感、纸纹与"沸腾"的线条、字体、
    卡拉 OK、笔刷转场、相机和卡拍工具、作画规则。构思、角色、场景、道具每首歌从歌词重新设计。原作的 Clawd、研究员、
    剧场舞台和计量器是为那首歌(一首唱给 AI 的歌)设计的,留在引擎里作参考实现,歌词指向或用户要求时才用。
    
    四个 skill 分工(与原作一致:导演写分镜和指南,并行子代理各画一章):
    
    | skill | 角色 | 什么时候读 |
    |---|---|---|
    | `paint-mv`(本文件) | 导演/总控:建项目、对时、角色落地、派活、统稿、出片 | 从头做一支 MV |
    | `paint-mv-storyboard` | 分镜:从歌词定构思、角色、场景、镜头、转场 | 第 3 步 |
    | `paint-mv-animate` | 画师:作画规范、角色设计(characters.md)、API、技法、原作九章示例 | 第 4、5 步(每个子代理都要读) |
    | `paint-mv-render` | 检查、渲染、编码、验收、排错 | 第 2、6、7 步 |
    
    下文 `<SKILLS>` 指这几个 skill 所在的目录(本文件的上一级),`<PROJECT>` 指 MV 项目目录。
    
    ## 输入与前置条件
    
    - 音频文件(mp3/wav/flac/m4a…)。
    - 歌词:带时间轴的 LRC / SRT / VTT / TSV / JSON 最好;只有纯文本就先自动对齐(见第 1 步)。
    - Node.js ≥ 18、ffmpeg、Google Chrome、git 和 patch;纯文本歌词对齐还需要 `uv`(按需安装 faster-whisper)。
    - 原作源码不随本仓库分发。第一次建项目时 `new_project.mjs` 会调用 `scripts/fetch_upstream.mjs`,从
      [JohnHeibel/PDoomVideo](https://github.com/JohnHeibel/PDoomVideo) 按固定提交拉下引擎、九章示例和原作分镜,
      再打上 `template.patch`;也可以先手动跑 `node <SKILLS>/paint-mv/scripts/fetch_upstream.mjs`,之后才能读示例。
    
    ## 流程
    
    复制这份清单并逐项推进:
    
    ```
    - [ ] 1 建项目:new_project.mjs(引擎、音频、节拍网格、歌词、npm ci)
    - [ ] 2 对时确认:速度/首拍有歧义或歌词是自动对齐的,出一段对拍短片给用户看
    - [ ] 3 分镜:按 paint-mv-storyboard 从歌词写 STORYBOARD.md(构思、角色、场景、镜头),落到 song.js / 章节存根 / studio.html
    - [ ] 4 角色与共享场景:按 characters.md 实现 src/characters.js、src/sets.js,出模型表给用户过目
    - [ ] 5 作画:每章一个子代理并行(paint-mv-animate),各自用联系表检查
    - [ ] 6 统稿:检查所有章节交界、转场、卡拉 OK 遮挡、回归的客串、性能
    - [ ] 7 出片:全片逐帧渲染 → 编码 MP4 → 抽帧验收(paint-mv-render)
    ```
    
    ### 1 · 建项目
    
    ```bash
    node <SKILLS>/paint-mv/scripts/new_project.mjs <PROJECT> --audio=song.mp3 --lyrics=song.lrc --title="歌名"
    ```
    
    它会:复制引擎模板(`template/`)→ 把歌拷成 `assets/song.*` → 分析节拍(BPM、首拍、漂移、小节响度,存 `assets/analysis.json`
    并打印报告)→ 把歌词转成 `src/lyrics.js`(打印警告)→ 写 `src/song.js` → `npm ci`。可选 `--bpm=N`、`--offset=S` 覆盖分析结果,
    `--meter-label=TEXT`(只有分镜要用计量器时),`--no-install` 跳过安装。
    
    只有纯文本歌词时先打轴(文本每行一句、按演唱顺序写全,删掉歌名这类不唱的行),再把 SRT 交给 `--lyrics`:
    
    ```bash
    uv run --python 3.12 --with faster-whisper --with zhconv python <SKILLS>/paint-mv/scripts/align_lyrics.py \
        song.mp3 lyrics.txt --out lyrics.srt --lang zh --words words.json
    ```
    
    读分析报告和歌词警告:速度或首拍标了 `AMBIGUOUS`、`grid drift` 警告、歌词有重叠/过短/过宽,都要处理或记下。
    报告的读法、格式细节、对齐精度与局限见 [reference/timing.md](reference/timing.md)。
    
    ### 2 · 对时确认
    
    先出一张联系表(`new_project.mjs` 最后会打印命令),看卡拉 OK 是否在对的句子上。以下情况再出一段带声音的短片请用户看:
    分析报告标了 `AMBIGUOUS`、歌词是自动对齐的、或者有被标记的句子。
    
    ```bash
    cd <PROJECT> && node render.mjs --clip=<副歌开始>:<+15> --out=out/check/timing.mp4
    ```
    
    占位画面里的小 Clawd 只是节拍器(按拍弹跳),卡拉 OK 按时间扫字。怎么根据反馈调 `offset`/`bpm`/歌词见
    `paint-mv-render`「节拍与歌词的人工确认」。**时间定下来之后再开始作画**:之后改 `bpm`/`offset`/歌词,所有打点都要重对。
    
    ### 3 · 分镜
    
    读 `<SKILLS>/paint-mv-storyboard/SKILL.md`,从歌词写 `<PROJECT>/STORYBOARD.md`:构思、角色表(每个角色有歌词依据和风格简报)、
    各章场景、镜头表。然后按它的第 7 步落到项目:`song.js` 的 `wipes`(和计量器,如果用)、每章一个存根文件、`studio.html` 的章节
    `<script>`。存根联系表渲染正常再往下走。把分镜给用户过目(尤其是构思、角色和结尾反转),用户有意见先改分镜。
    
    ### 4 · 角色与共享场景
    
    所有章节都要用的东西在并行作画之前写好,就像原作的 `clawd.js`、`cast.js`、`props.js` 在派子代理之前就已存在:
    - **主要角色**(两个以上章节出现)写进 `src/characters.js`,每个角色一个函数,遵守 `<SKILLS>/paint-mv-animate/characters.md`
      的接口约定和画法配方,照分镜里的风格简报画。
    - **反复出现的场景和道具**(副歌主场景、反复出现的象征)写进 `src/sets.js`。
    - 在 `characters.js` 里注册模型表 `LOOPS.cast`,渲染检查:
      `node render.mjs --loop=cast --sheet=0.3,1.3,2.3,3.3,4.3,5.3 --cols=3 --out=out/check/cast.jpg`。
      大中小尺寸都认得出、各种表情和舞步都不穿帮、放在各章调色上对比够,再把模型表给用户过目;用户定了主角的样子再往下走。
    
    可以自己写,也可以派一个子代理专门做(任务书同第 5 步,但它只改 `characters.js` 和 `sets.js`)。客串不在这里做:
    由首次登场的章节实现并导出到 `CAST`。
    
    ### 5 · 作画(并行子代理)
    
    原作做法:导演写好指南和分镜,每章派一个子代理并行作画,每个子代理只改自己的章节文件。
    用 Task 工具在**同一条消息里**为每章各发一个子代理(互不依赖,可以同时渲染检查)。任务书模板:
    
    ```
    你是 MV 项目 <PROJECT> 的章节画师,负责第 N 章 src/ch/cNN_name.js(start–end s,场景:…,调色:…)。
    先完整阅读并遵守:
    1. <SKILLS>/paint-mv-animate/SKILL.md(作画规范;需要时查同目录的 characters.md、api.md、techniques.md、examples/)
    2. <PROJECT>/STORYBOARD.md:开头的「歌曲信息」、角色表、第 N 节全部镜头、第 N−1 节最后一镜和第 N+1 节第一镜
    3. <PROJECT>/src/song.js、src/lyrics.js,以及本片角色 src/characters.js 和共享场景 src/sets.js(只读),先看角色模型表
    要求:
    - 只修改 src/ch/cNN_name.js,保持 chapter('name', start, end, …) 的名字和起止时间不变
    - 角色用 characters.js 里的函数,画风与模型表一致;开场接住上一章的「出」:…;结尾交给下一章:…
    - 本章要新建并导出的客串:CAST.xxx(签名 (x, y, s, t, o),要谢幕的支持 o.bow)/ 无
    - 用联系表检查每个镜头直到满意;最慢帧不超过 4 s(目标 2.5 s);联系表和截图都输出到 out/check/cNN_*(各章并行渲染,别用默认路径)
    - 完成后按规范最后一节汇报;共享文件的问题只报告不修改
    ```
    
    后面章节要用前面章节导出的客串(比如结尾大合影):客串的 `CAST` 名字和签名在分镜角色表里定死;使用方按名字调用,
    并像原作 `c09_finale.js` 的 `who()` 一样写后备画法,这样各章并行时也能先画起来。
    
    子代理汇报后,看它给的联系表(自己用 Read 打开),不满意就带着具体意见让它继续改。共享文件(包括 `characters.js`、
    `sets.js`)的修改由你统一做,改完通知所有子代理。
    
    会话被打断(编辑器重启、网络中断)时,子代理也会停下,章节文件保留在打断时的状态。不要重新派活:用 Task 的 `resume`
    接上原来的子代理(ID 就是它的 agent ID),告诉它停在哪个镜头、还剩哪些镜头,它的上下文都还在。
    
    相邻章节的交接各自读对方的文件来对齐(颜色、相机、场景参数),任何一方改了开场或结尾,另一方都要再对一次,所以统稿要等
    所有章节都交付之后再做。
    
    ### 6 · 统稿
    
    - 每个章节交界各看一组帧(交界前 0.1 s、交界、交界后 0.05 s、0.3 s),确认"出"和下一章开场对得上、笔刷擦除时刻正确。
    - 每章抽几帧看:角色是否都和模型表一致;卡拉 OK 条下有没有压住脸和关键动作;回归的客串是否来自 `CAST`(不是后备画法)。
    - 汇总各章最慢帧;超过 4 s 的退回对应章节优化。
    - 全片再看一次节奏:每个镜头都有事发生、相机都在动、字少、画面讲的是这句歌词。
    
    ### 7 · 出片
    
    所有章节都交付之后再开渲:渲染时还在改的章节会把改到一半的状态渲进成片。赶时间要提前出片,就先让还在画的子代理停下,
    等它交付后只重渲它那一段(见 `paint-mv-render`「全片渲染」)。
    
    按 `<SKILLS>/paint-mv-render/SKILL.md`:`node render.mjs --frames=0:<duration> --workers=4`(可续渲,直到 `0 frames to render`)
    → `node render.mjs --encode --out=out/mv.mp4` → ffprobe 与抽帧验收。把成片路径、时长和几张抽帧给用户。
    
    ## 风格与内容
    
    **风格(每首歌都一样,照原作)**:手绘水彩加墨线的绘本观感(角色平涂加墨线,背景柔和水彩,纸纹、暗角、每秒 12 次"沸腾"的线条);
    可爱、卡通、色彩欢快但柔和;每个镜头都有事发生;相机一直在动;打点落在拍上,压扁拉伸、预备与过冲;情绪切换不硬切;
    有动机的转场;少字,只有少数大拟声字;卡拉 OK 条与笔刷擦除。由引擎和 `paint-mv-animate` 的规则保证。
    
    **内容(每首歌重新设计,照歌词)**:构思与反转、角色、场景、道具、反复出现的象征都从歌词来(`paint-mv-storyboard`)。
    原作对每首歌都适用的方向是"给每句歌词有趣的画面和转场";分镜阶段的要求是"用 p5.brush 笔触、每个场景都有看头、
    每个场景都转场进下一个";给子代理的要求是"可爱、卡通、色彩欢快、动画活泼、每个镜头都有事发生、大胆有野心"。
    原作还有一条"用 Clawd 的角色设计",那是针对那首 AI 歌的,不适用于别的歌。用户给了自己的方向就以用户为准。
    
    规模参考:原作 156.6 s、46 句歌词、9 章、约 60 个镜头、章节代码约 6,600 行。更短的歌按比例减少,但规则不打折。
    
    ## 项目结构
    
    ```
    <PROJECT>/
    ├── STORYBOARD.md            分镜(第 3 步写)
    ├── studio.html              绘制页面:依次加载 p5、p5.brush、src/*.js 和各章节
    ├── render.mjs               headless Chrome 渲染 + ffmpeg 编码
    ├── assets/  song.*  analysis.json  lyrics.*
    ├── src/
    │   ├── song.js              歌曲配置:title, audio, duration, bpm, offset, wipes, meter, meterLabel, glyphs
    │   ├── lyrics.js            LY = [[start, end, text], …](lyrics_to_ly.mjs 生成)
    │   ├── core.js timeline.js  共享引擎:绘制、时间、相机、字、卡拉 OK、转场(原作源码)
    │   ├── clawd.js cast.js props.js   原作的角色与剧场道具(参考实现;mood/move/emote 等通用工具也在 clawd.js)
    │   ├── characters.js        本片主要角色 + 模型表 LOOPS.cast(第 4 步写)
    │   ├── sets.js              本片共享场景与道具(第 4 步写)
    │   └── ch/cNN_name.js       各章节(子代理画)
    └── out/  check/  frames/  mv.mp4
    ```
    
    ## 引擎与原作源码的差异
    
    模板(`template/`)由原作文件加 `template.patch` 生成(`fetch_upstream.mjs`),只改了必须随歌曲变化或随平台变化的地方:
    
    - 新增 `src/song.js`:原来写死在代码里的 BPM 88、首拍 0.21、时长 156.6、`WIPES`、`METER`、计量器标签、音频路径都移到这里;
      计量器默认关闭(`meter: []`、`meterLabel: ''`)。
    - 新增空的 `src/characters.js`、`src/sets.js`(本片主要角色和共享场景的位置),`studio.html` 在章节之前加载它们。
    - `core.js` / `timeline.js` / `props.js`:改读 `SONG`;字体栈加中日韩回退字体 ZCOOL KuaiLe 并预加载所需字形;
      卡拉 OK 扫字速度对中日韩字符加权;调试拖动条上限跟随时长。
    - `render.mjs`:时长和音频读 `song.js`;Chrome 路径和 WebGL(ANGLE)后端按平台选,可用 `--chrome`/`--angle` 覆盖
      (原作写死 Windows 的 `d3d11`,在 macOS 上创建不了 WebGL 上下文)。
    - `studio.html`:先加载 `song.js`,章节列表清空,字体链接加 ZCOOL KuaiLe。
    - `clawd.js`、`cast.js` 与原作逐字相同。
    
    验证:用原作的配置和九个章节,模板渲染出的帧与原作逐字节一致。
    
  • template.patch 13.3 KB · in bundle

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related