鲸鱼娘桌宠

一只会跟着你干活的鲸鱼娘桌宠,同一个角色的两个平台版本:Claude Code(PetPet 桌宠) 和 dsh(DshDesktop)。
她不是那种只会原地晃两下的桌宠 —— 你发消息她捧着碗一口一口吃,这一轮干完她收碗,偶尔双手合十给你祝福,你按 Esc 打断她,她会停下手里的事甩你一个屑表情,点她会挥手/比心。
English: a desktop pet that reacts to your AI coding session. Same character, two builds: Claude Code (PetPet) and dsh. She eats from a bowl while you work, puts it away when the turn ends, and gives you a smug look if you hit Esc to interrupt. On Windows you can grab the green build, unzip it and double-click 启动.cmd.

实拍:左边是 Claude Code 正在跑任务,右边就是她——干活期间一直端着碗吃,气泡是她自己弹的。

上图是她的十个动作,全部实拍自宠物素材。

挥手 → 起子代理时脚边游出一只小海豚,绕着她转一圈再沉下去。上面这段是 6 秒循环(436×640 原尺寸,15 秒完整版还含比心、吃饭、收碗和屑表情)。
她什么时候做什么
| 你做什么 | Claude Code 版 | dsh 版 |
|---|---|---|
| 你发消息 / 模型开始干活 | 捧着碗吃(循环);10% 的轮次换成端着中碗、拿筷子正经吃一顿 | 捧着碗吃(循环,全程一条轨道,见坑五) |
| 这一轮结束 | 收碗 → 55% 托腮发呆 / 10% 双手合十祝福 / 35% 待机 | native 同左(靠 transitions.json);legacy 直接回待机(粘性 phase 的限制,见坑一) |
| 按 Esc 打断她 | 立刻停下手里的动作,甩你一个屑表情 | 工具失败/本轮出错时同样甩屑表情 |
| 点她 | 挥手(8% 换成屑表情) | 57% 挥手 / 20% 托腮 / 15% 比心 / 8% 屑表情 |
| 起子代理 | 收碗 → 脚边游出一只小海豚转圈 → 跑完沉下去 | 同左(靠打补丁,见「看鲸鱼」一节) |
| 什么都不做 | 老实待机——长发和尾巴轻轻晃,偶尔眨眼 | 同左 |

上面这段就是宠物里真实的待机循环(24 帧 @12fps):呼吸、飘发、眨眼都是程序化烘焙的,不是抽帧——AI 画不出这种幅度的小动作。其余动作由 AI 视频转序列帧、再逐帧抠像配准,工具链在 whalegirl-pet-kit。
装
最省事:下 绿色版 —— Windows x64,解压后双击 启动.cmd 就有她。不用装 Node、不用 clone 源码、不用编译。代价是包大(约 270MB,整个 Electron 运行时都在里面)。
⚠️ 双击时可能弹「Windows 已保护你的电脑」——点「更多信息」→「仍要运行」即可,不是文件坏了。浏览器下载的 zip 会被打上「来自 Internet」标记,解压时传给里面的文件,Windows 对没签名的程序都会拦这一次。交给 AI 装的话它可以帮你把这行去掉(见下面的「给 AI 的一句话」)。
绿色版就是下面「Claude Code 版」预先构建好的成品;想自己构建,或者要用 dsh 版,走这两条:
| 版本 | 给谁用 | 怎么装 |
|---|---|---|
| Claude Code 版 | PetPet 桌宠,跟 Claude Code 联动的那只 | node claude/install.mjs → 按它打印的收尾。详见 claude/README.md |
| dsh 版 | dsh(桌面外壳 DshDesktop)里那只 | 双击根目录的 install.cmd(或 node install.mjs)→ 重启 dsh。渲染器没装的话脚本会自己检测并装上 |
| WorkBuddy 版 | WorkBuddy 里那只 | 下 release 里的 whalegirl-workbuddy-win-x64.zip(自带插件,不用再装别的)→ 先双击 启动.cmd,再双击 装WorkBuddy钩子.cmd。详见 workbuddy/README.md |
dsh 版也有打好的 zip:
dsh-v1.9.2(73MB)——解压后跟 clone 下来一样跑install.cmd,不用 clone、不用装依赖。注意releases/latest指的是绿色版那条线(里面有两个包:Claude Code 版和 WorkBuddy 版,按名字挑),dsh 版得从 tag 进。
说明:dsh 版的渲染器是另一个插件(
@linxin666/dsh-pet),本包只管把素材放好、把她画出来的是它。安装脚本会先看它有没有、没有就调dsh plugin add装上(顺带把 bundle 登记好、并给它的相位白名单打上看鲸鱼要用的补丁)——不想让它动你的插件环境就加--no-renderer。自动装失败也不会坏:素材已经就位,脚本会在收尾给出可直接复制的命令,装完重启即可、不用重跑本脚本。装渲染器之前界面上什么都不会出现,这是分工决定的,不是坏了。
两个安装脚本都是幂等的:会自己找/下依赖,改配置前先备份,重复跑不会出问题;都能加 --dry-run 先看它会改什么。
给 AI 的一句话(把仓库链接和下面这段一起发给你用的 AI):
「帮我装鲸鱼娘桌宠(<仓库链接>)。
要 Claude Code 那只:走绿色版 —— 从
releases/latest下whalegirl-petpet-win-x64.zip,解压到一个固定目录(别在压缩包里直接点)。解压完先跑这行,去掉「来自 Internet」标记,不然双击会弹 Windows 拦截框:powershell -NoProfile -Command "Get-ChildItem '<解压目录>' -Recurse -File | Unblock-File"然后跑<解压目录>\claude\install.mjs --hooks-only --pet-exe "<解压目录>\PetPet.exe"(先加--dry-run看一眼)——挂上钩子她才会跟着我干活。脚本在解压出来的包里带着,不用联网。要 dsh 那只:跑根目录的
install.mjs。都一样:先
--dry-run看一眼,按脚本最后打印的收尾,做完告诉我一声重启。」
仓库里都有什么
pet/ dsh 版素材 + manifest(frames2d 格式)
lib/ · install.mjs · install.cmd dsh 版插件:把 pet/ 释放给 dsh-pet
claude/ Claude Code 版:宠物包(petpack)+ 一键安装 + viewer 补丁 + 钩子脚本
workbuddy/ WorkBuddy 版:hook 插件(宠物本体仍是绿色版那只,这个只负责让她跟着你干活)
docs/preview/ README 用的预览图
两个版本用的是同一批动作素材,只是打包格式不同:Claude 版是精灵表 + pet.json v3(PetPet 框架),dsh 版是逐帧 PNG + manifest v2(@linxin666/dsh-pet 插件)。
⚠️ 素材全部由 AI 生成 —— 立绘经 see-through 分层,动作由豆包图生视频抽帧。拿去做别的事情之前,先确认所用平台的服务条款:不同平台对生成内容的商用和再分发规定不一样。代码部分是 MIT(见 LICENSE)。
WorkBuddy 版
WorkBuddy(腾讯那个桌面 AI 工作台)里也能用同一只桌宠。做法和 dsh 完全不同:
她不画在 WorkBuddy 窗口里 —— 她就是你桌面上的那只;WorkBuddy 这边只有一个 hook 插件,负责告诉她"我在干活"。
最省事的是下 release 里的 whalegirl-workbuddy-win-x64.zip —— 宠物本体和插件都在里面:
- 解压,双击
启动.cmd—— 桌面上就有她了 - 双击
装WorkBuddy钩子.cmd - 完全退出 WorkBuddy 再打开
已经有绿色版、不想重下的话,也能单独装插件:node workbuddy/install.mjs。
装完的效果:你发消息她端碗吃饭、一轮回答结束收碗、派子代理时脚边游出一只小海豚。
WorkBuddy 的 hook 机制和 Claude Code 是同一套 —— 事件名(
UserPromptSubmit/Stop/SessionStart/SubagentStart…)、payload 字段、配置结构全都对得上,所以这一版复用claude/下那三个脚本,没有第二份实现。唯一要留意的是两边共用~/.petpet/state.json:同时开着、一个在干活一个待机时她会闪一下,只用其中一边就没事。
装、卸、排查都写在 workbuddy/README.md 里。
dsh 版细节
装完在宠物选择里叫鲸鱼娘(id whalegirl-hd)。
装 / 卸
双击 install.cmd(或 node install.mjs),它做三件事:
- 把包复制到
<DSH_HOME>/profiles/<profile>/node_modules/@wei125775-lab/whalegirl-deskpet/ - 在 profile 的
package.json里加dependencies和dsh.profile.bundles条目 - 备份改之前的 manifest 到
package.json.bak-whalegirl
然后重启 dsh(自建的 DshDesktop 或官方桌面版)。插件本身不注册任何东西,只在启动时把包内的 pet/ 释放到 <DSH_HOME>/pets/whalegirl-hd/ —— dsh-pet 的 frames2d 宠物只能从那个目录扫出来(它内联 manifest 的通道只认 v1 sprite2d 精灵表,喂 frames2d 进去会静默降级,而 sprite2d 只有固定 9 个 Codex 动画名,装不下这些自定义动作)。
释放是幂等的:目标目录的内容和包内一致就跳过。比的是内容不是版本号——改了 pet/ 里的任何东西(素材、pet.json、voice.json)都不用手动删目录,下次启动发现对不上就会重建;部署目录被拷坏(缺帧、拷到一半)也是同样处理,会自己修好。想强制覆盖就删掉 <DSH_HOME>/pets/whalegirl-hd/ 再重启。宠物没出现就再重启一次——插件释放和 dsh-pet 扫目录都发生在启动期间,安装脚本已经把 bundle 排在 @linxin666/dsh-pet 前面,但万一顺序还是反了,第二次必然正确。
卸:删掉上面那三处(node_modules 里的包、profile package.json 的条目、<DSH_HOME>/pets/whalegirl-hd/)。
给对方装
整个文件夹拷过去(zip / 网盘 / clone 都行),对方双击 install.cmd 或跑 node install.mjs,重启 dsh。对方那边需要:
- dsh ≥ 0.1.5-rc.1(插件走
dsh.bundle.patch机制) @linxin666/dsh-pet—— 可选,两条路只走一条。装了就走 legacy(挂在它上面渲染,有气泡和台词包);没装就走自研引擎(lib/engine/,素材直接从包目录读、相位自己投影),她照样会显示。入口按实际探测结果定,不猜- node(dsh 本身就依赖它)
安装脚本自己会找地方:--dsh-home=<目录> > DSH_HOME 环境变量 > ~/.dsh。profile 优先挑装了 dsh-pet 的那个,找不到退回 web;有多个装了 dsh-pet 的 profile 时它不会猜,会报出来让你用 --profile=<名字> 指定(挑错的代价是改了另一个 profile 的 bundles)。
渲染器版本必须跟着目标 profile 的 dsh 走,所以 --renderer-spec= 是刚需:
| 目标 | dsh | 渲染器 spec |
|---|---|---|
自建 DshDesktop / dsh web |
0.1.5-rc.x | @linxin666/dsh-pet@0.3.23 |
| 官方桌面版 | 0.1.7-rc.x | @linxin666/dsh-pet@0.4.2 |
dsh-pet 0.4.2 的 peerDeps 是 dsh >=0.1.7-rc.1,装上 0.1.5 的 profile 会直接坏掉。所以脚本不猜:web profile 有钉好的默认值(0.3.23),其它 profile 查不到默认值时它会停下来,让你显式传 --renderer-spec=(或加 --no-renderer 走自研引擎)—— 宁可停下,也不装一个可能把环境装坏的版本。
官方桌面版(Electron 那版)
桌面版有自己的数据目录(DSH_HOME)和自己的 profile,叫 desktop。它走自研引擎:这个 profile 里不装 @linxin666/dsh-pet,lib/engine/ 直接从包目录读素材、自己投影相位、自己画。所以安装只要一句:
node install.mjs --profile=desktop --dsh-home=D:\dsh-desktop-home --no-renderer
--no-renderer 就是这个意思 —— 这个 profile 不需要 dsh-pet,脚本也不会再去提示你装它(装了两只同时画在屏幕上)。
启动:
set "DSH_HOME=D:\dsh-desktop-home"
start "" "D:\DeepSeekHarness\DeepSeek Harness.exe"
注意安装器给桌面版建的快捷方式指向 exe 本体、不带 DSH_HOME,双击它会用默认的 ~/.dsh —— 指向启动脚本才走隔离目录。
两条路径:入口自己判定,不用配
| legacy | native | |
|---|---|---|
| 什么时候走 | profile 里有 dsh-pet | profile 里没有 dsh-pet |
| 素材 | 「释放」到 $DSH_HOME/pets/ |
直接从包目录读 |
| 相位 | 改 dsh-pet 的白名单 | 自己定义,不碰第三方 |
| 看鲸鱼 | 包一层它的 applyActivity |
自己的管线,天然不被打断 |
| 需要 dsh-pet | 是 | 否 |
启动日志会打一行 引擎:legacy 或 引擎:native,不静默切换。想强制可以给插件行加 config: { engine: 'native' },但装了 dsh-pet 又强走 native 会被拒绝并回退(两个引擎会同时画两只宠物)。
排查「宠物没出现」
引擎自带一个诊断接口,先看它再猜:
curl http://127.0.0.1:19387/api/whalegirl/diagnostics
# {"hits":{"beacon":1,"pet":1,"state":182,"frame":24,"touch":0,"config":0},...}
beacon: 0→ 浏览器半端压根没进页面(dsh.client没被扫到,或exports['./client']解析不出来)beacon有、pet: 0→ 进来了但挂载失败- 三个都在涨、
frame也涨 → 引擎是好的,问题在显示层(被别的窗口盖住、或位置在视口外)
这个接口是上一次排查"她怎么不出现"留下的:当时从外面看只有"右下角空的"一种症状,而它背后可能是完全不同的三种毛病。
干完活收碗(putaway)与动作链(transitions.json)
一轮跑完(phase 变 done)她会把手里的碗收掉再回待机。这条只在 native 引擎(官方桌面版)上有,声明写在 pet/transitions.json 的保留键 $phases 里,engine 载入时并进相位表:
{
"$phases": { "done": "putaway" },
"putaway": { "touchface": 55, "bless": 10, "idle": 35 },
"bless": { "idle": 100 }
}
$phases 是"相位 → 轨名"的补丁,用来放在 pet.json 里写不得的映射。为什么 done → putaway 写不得:done 是粘性相位,dsh-pet 客户端在点击动作释放时会按当前相位重新解析一次,映射到一次性动作就变成每点她一次重播一遍收碗(坑一那条规则)。pet.json 是两条路共用的,所以它保持 done: "idle"。
收碗之后接什么,是 transitions.json 的另外那些键,按权重掷。键是"播完之后要挑下一段"的那条轨,值是 {目标轨: 权重}(权重,不是概率,不要求和为 1)。没写这条轨、或掷到的目标不存在,就走它自己的 fallback(缺省待机)。链上走过的轨不会重复走(A→B→A 走到重复就落 fallback),所以不会打转。
为什么这些都要放同级文件:dsh-pet 对 track 的字段是白名单 + fail-closed(只认 frames / frameMs / loop / fallback),多一个字段就是 diag.error、整只宠物被丢掉。而 pet.json 还要给 legacy(dsh-pet)那条路用,所以扩展只能放它不认的文件里 —— 跟 voice.json 同一个套路。键名带 $ 前缀的是保留指令(轨道名走 /^[a-z0-9][a-z0-9-]*$/,撞不上)。
两条实现上的注意(改这块之前先看):
- 同一次里,同一条一次性轨只认一次。 收碗 875ms 播完,客户端按
fallback自己落回待机,而服务端在这个相位里还会继续说"该播 putaway" —— 400ms 一次的轮询会把收碗一遍遍重播。 判据是/state里的seq(状态机每收一个新相位就 +1),不是相位名:done/failed允许重复提交(重新计时),第二轮结束时相位名还是done,只看名字会把第二次收碗静默吞掉 —— 触发条件是客户端错过了中间那些相位,而窗口最小化时它本来就不轮询。 - 动作链只在相位驱动时掷,点击动作不掷:点击有
override和释放定时器,插进来两边会打架。
她的台词(pet/voice.json)
✅ 两条路都接了台词(2026-09-27)。native 引擎(官方桌面版)在
lib/engine/voice.js里复刻了同一套 voice-pack 语义 —— 11 个状态场景、17 个工具类别、并行工具计数、碎碎念,气泡视觉照 dsh-pet 的规格做,行为也一致(干活期间常驻、干完/失败满 2.4 秒才收)。所以下面这套文案两条路共用同一个voice.json,改一处两边都变。
dsh-pet 把会话事件投影成 phase,同时也会投影出一句台词冒在气泡里。台词默认来自官方内置文案("准备开始""正在思考""爬取中"…)—— 那不是鲸鱼娘在说话。所以她在 dsh 里的话由 pet/voice.json 提供。
voice 不能写进 pet.json —— manifest v2 的顶层白名单里没有这个字段。它是一个跟 pet.json 平级的独立文件,放在宠物目录里就会被读走(scanPetDir 里 loadVoicePackFile(join(entryDir, "voice.json")))。宠物被释放到 ~/.dsh/pets/<id>/ 时会一起带过去。native 引擎不经过释放目录,直接从包内的 pet/ 读(loadVoice(definition.petDir))—— 改完台词文件记得同步 profile 里那份副本。
能配四块:
| 字段 | 覆盖什么 | 允许的键 |
|---|---|---|
status |
各 phase 的状态台词 | prepare waiting thinking review toolResult done failed toolFailed maxTokens interrupted blocked |
tools |
按工具类别出词 | read write edit shell grep find ls webSearch webFetch mcp memory subagent todo browser git ask generic |
toolRemaining |
还有几个工具在跑 | 可用占位符 {n} |
whispers |
干活期间的碎碎念(分类 9 秒、结果 5 秒节流) | categories:thinking writing reading editing running searching git delegating browsing generic;results:pass fail done |
另外 panel(面板标签)、remarks、ranks 也能覆盖,本宠物没用。
占位符按块白名单校验:tools 只认 {tool} 和 {hint},toolRemaining 只认 {n},status 一个都不认(写了会被丢掉并记一条 warning)。单行 ≤160 字,单池 ≤64 条。写错不会崩 —— 整包解析失败只是被忽略,降级回官方文案,所以 voice.json 是低风险改动。
想确认改动生效、又不想起 dsh:loadPetRegistry 的 warnings 应当为空,且 reg.byId('<id>').voice.overrides 里能看到你的池子。链路是 voicePools() = mergeVoicePacks(globalVoice, entry.voice).overrides → emptyProjectionRuntime(pools) → new StatusVoice(pools) / new WhisperEngine(pools)。
看鲸鱼(子代理来了,脚边游出一只小海豚)
起子代理的时候,她会收碗、脚边游出一只小海豚转圈,等子代理跑完,海豚沉下去,她回去吃饭。
这一块是三层非官方机制搭出来的,dsh-pet 一升级就可能失效,所以单独写清楚。
它靠什么跑起来
- 给 dsh-pet 的相位白名单打补丁。
frames2d.phases是 fail-closed 校验的 —— 写进去不认识的名字(unknown activity phase)会让整只宠物被静默丢弃,界面上直接没有她。所以要往 dsh-pet 的PET_ACTIVITY_PHASES里补whale-in / whale-loop / whale-out,补丁落在两个文件:lib/index.js(服务端那份,实际校验走它)和lib/types/manifest-v2.js。 脚本是patch-dshpet.mjs(install.mjs会自动调它),幂等,--dry-run可预览。 - 相位是"偷渡"过去的。
pet服务上的applyActivity()内部直接machine.onActivityStatus(input),不校验相位;相位原样流到客户端,而客户端的trackForPhase只是查config.phases[phase]这张表。所以只要表里挂得上键就能播。 - 演出期间吞掉子代理的事件。 dsh-pet 对子代理会话没有任何过滤 —— 子代理自己的
tool/call之类会applyActivity到自己身上并抢走displaySession,不处理的话鲸鱼每被子代理的一次工具调用打断就从头重播一遍。所以插件把applyActivity包了一层,演出期间只吞来源是子代理会话的调用,主会话照常放行。
dsh-pet 升级之后怎么办
补丁会被冲掉。宠物不会消失 —— 插件里的 syncWhalePhases() 会在 dsh-pet 读 manifest 之前,先探一下它认不认那几个相位(读主文件里有没有 "whale-in" 字面量):不认就把相位键从部署的 manifest 里摘掉,降级成"没有看鲸鱼"的版本,并在日志里喊一声。
它是双向的 —— 这一点是有意设计的:releasePet() 只在包内版本比已装的新时才覆盖,所以"没装 dsh-pet 时先装了本包(相位被摘)→ 之后才装 dsh-pet 并打上补丁"这条路上,被摘掉的相位没人会加回来。syncWhalePhases() 检测到补丁在了就把相位补回去,所以先装哪个都行,不用重装。
恢复:node patch-dshpet.mjs,然后重启 DshDesktop。
不要了怎么关
把 pet/pet.json 里 frames2d.phases 的 whale-in / whale-loop / whale-out 三个键删掉就行,补丁留着无害(白名单只是放行,多几个名字不影响别的宠物)。插件会因为你没声明那几个相位而不启动演出。
phase 映射
dsh-pet 把会话事件投影成 7 个 phase,宠物按 phase 播动作:
| phase | 什么时候 | 播什么 |
|---|---|---|
idle |
没有会话活动 / 一轮被中止 | 待机(循环) |
waiting |
你发出消息、每步开始 | 吃饭 |
thinking |
模型在推理 | 吃饭 |
tool |
工具调用中 | 吃饭 |
review |
模型在输出回复 | 吃饭 |
done |
一轮完成 | 待机 |
failed |
工具失败 / 本轮出错 | 屑表情(播一遍回待机) |
改这些就是改 pet/pet.json 里的 frames2d.phases 和 gameplay.touch.zones,两个都是 dsh-pet 的 manifest v2 字段,改了要重启 dsh(宠物是启动时扫的)。
四个踩过的坑,改 manifest 前先看
一、done 和 failed 是粘性 phase,映射到它们的 track 必须是循环动作或者 idle。
phase 只在收到新事件时才变 —— dsh-pet 里只有 sprite2d 走的 animation 字段带 2.4 秒回落窗口,frames2d 直接吃 phases[phase],没有回落。而 track 默认 loop: true。所以映射循环动作会一直播到你下次发消息(一开始配的 done → 比心 就是这样,比心循环个没完);映射一次性动作则每次点击后都会补播一次(点击是"占住 override + 定时释放",释放时重新按当前 phase 解析,那会儿 phase 多半还粘在 done 上)。
所以 done 只能映射 idle。想在 dsh 里表达"干完活"这类一次性动作,只能挂到 gameplay.touch 上。
native 引擎(官方桌面版)现在能让
done播收碗,靠的是pet/transitions.json里那个保留键$phases(native 载入时并进相位表)—— 不是把pet.json的done改掉。因为pet.json是两条路共用的,一旦在那里写成done: putaway,legacy 那边每次点她都会重播一遍收碗(这条规则上面刚写过)。同一个文件要伺候两个宿主时,能写进共用文件的只有两边都成立的映射。
failed → 屑表情 是这条规则的有意例外:屑表情是一次性 track(loop: false + fallback: idle),那 30 帧是"抬手/眨眼 → 保持得意表情 → 收回"的完整表情动作,循环播会变成反复眨眼。代价是出错后那个窗口里点她一下会补播一次屑表情——补的正是当前 phase 该有的动作,看着还算连贯,就接受了。(之前这里挂的是托腮,换成屑表情是因为表情类素材在宠物尺寸下读得出来,比肢体动作更贴"被打断了"的语境;托腮现在只做点击反应。)
二、一次性动作的 stateMs 必须等于素材总时长(帧数 × 帧长)。
点击分支的 stateMs 是"占住 override 多久",不写时默认 3000ms(gameplay-hud.tsx 里 result.stateMs ?? 3000),不是素材时长。它和素材对不上就会切在动作中段:比素材长 → 循环动作多播小半遍再被切走(托腮原来配 3200ms、素材只有 2490ms,于是"做完之后手又抬了一次",就是被这个问题坑的);比素材短 → 一次性动作被砍掉尾巴。
所以四个点击分支的 stateMs 都按各自素材的帧数 × 83ms 对齐、再多留一帧(剪完死帧后:挥手 23 帧 → 1992、托腮 27 → 2324、比心 27 → 2324、屑表情 27 → 2324)。剪了素材的帧就必须回来改这里,否则就退回"多播一截"。多留的那一帧是防定时器抖动:stateMs 比素材短会把一次性动作砍掉尾巴。
三、frames2d 没有 pingpong。
petpet 那边靠 pingpong 往返播放解决"素材首尾接不上",dsh 这边没有。所以吃饭那 7 帧在 manifest 里是手工展开成 12 格往返序列的(f01..f07, f06..f02)。同一个文件名可以重复引用,客户端按 URL 缓存,不会重复解码。
这个数组就是"剪死帧"的存放处,两条路都认(native 引擎 2026-09-26 之前只列目录、把它整份丢掉了,见坑七)。所以
frames里写了什么、屏幕上就播什么 —— 改这里的帧数必须同步改点击分支的stateMs(坑二),两处差一帧就会两头切错位置。
四、轨道名不许有下划线,单轨最多 64 帧。
- 轨道名走
/^[a-z0-9][a-z0-9-]*$/,下划线不合法(叫eat_mid会让整条轨道被拒);frames里的必须是相对该轨道自己目录的纯文件名(写成eat/f01.png这种带路径的会被当缺帧逐个丢掉) - 单轨
frames最多 64 条。超限是整只宠物被静默丢弃、界面上直接没有她,所以"端碗吃 N 轮之后换成中口吃"这种长循环写不出来。想改只能动 dsh-pet 的FRAMES2D_MAX_FRAMES(src/manifest-v2.ts与编译产物lib/各一处,改完会被下次升级覆盖)
五、同一段工作里别给不同 phase 映射不同轨道。
每次 phase 变化,只要映射到的轨道名和当前的不一样,就从第 0 帧重播(renderers/frames2d.ts:订阅里 if (target !== track) play(target),而 play() 第一件事就是 frameIndex = 0)。
而一轮里 tool 和 thinking 是交替出现的 —— tool/call → tool,tool/result → thinking,reasoning-delta → thinking。所以这两者映射到不同轨道时,每调用一次工具就会重播两次。原来 thinking → 中口吃 就是这么坏的:一轮十几个工具调用,等于二十几次"把碗放下又端起来",看起来极乱。
修法是把 waiting / thinking / tool / review 全部映射到同一条 eat,一轮里轨道只在开头和结尾各切一次。(中口吃素材还在 pet/frames/eat-mid/,轨道也保留着,只是现在没人引用。)
六、phase 映射没有概率,而 idleDirector 在这版里是死的。
带随机的只有四处:点击分支 branches[].probability、待机导演 idleDirector.acts[].weight、打工 work.successProbability、商店 shop.lottery。想要"某个 phase 按概率换动作",manifest 表达不了。
idleDirector 本来是最像"闲着时小概率做个动作"的机制,但它的守卫是 phaseRef.current !== 'idle',而 phase 是粘性的(见坑一)——正常跑完一轮后永久停在 done,只有"一轮被中止"或"会话被 dispose"才会回到 idle。官方自带的四只宠物也没有一只用它(grep idleDirector assets/*/pet.json 全空)。所以别指望它。
七、素材首尾自带"动作之外"的帧,要剪。
build_video.py 是 np.linspace(0, total-1, n) 均匀抽帧,抽出来的窗口是按"整条视频"切的,不是按动作边界切的。所以每条轨道的开头/结尾都挂着动作之外的帧:开头是她还没起手的站桩(点了之后先愣一下),结尾是动作做完后的站桩、有时候还混进下一段动作的开头几帧。
实测(eat 那条最严重):
eat:源视频第 63~84 帧(约 0.9 秒)她本身就是定住的,抽中的 5 帧全是这段;而窗口取到第 91 帧,把下一口的起手也切了进来。循环里 38% 是死气。wave:30 帧里开头 3 帧 + 末尾 4 帧是站桩,占 30%。touchface/smug/putaway/heart:各有 2~3 帧。
剪法:直接改 frames 数组(文件不用删,留着方便回退)。eat 因为是手工展开的往返序列,剪完要重新补上回程。putaway 有 frameMs 数组,剪 frames 必须同步剪 frameMs,长度不等会让整只宠物被静默丢弃。
native 引擎(官方桌面版)也认这个数组 —— 2026-09-26 之前它只列目录、把剪好的序列整份丢掉(
eat的往返没了、38% 死气回来了;wave从还没起手的 f01 开始播,而点击的stateMs是按剪后帧数配的,两头都切错)。现在帧序列这一块两条路语义一致:声明了就用声明,省略才列目录。("相位怎么映射"那部分两边仍有意不同,见坑一和transitions.json那节。)
判断"哪几帧是多余的"用逐帧相邻差:取整条轨道的相邻差中位数,低于它 35% 的连续首/尾段就是死帧。注意区分"死帧"和"有意的保持"——smug(保持得意表情)、touchface(托住)、heart(红心成型后停一拍)中间那些小差异是动作本身,别剪。
八、所有动作都在以约 2 倍速播放。
build_video.py 从 121 帧 @24fps(5.04 秒)的视频里均匀抽 30 帧 = 每 4.03 帧抽一张(等效 6fps),而播放是 12fps → 整个动作 2.02 倍速。每一条 30 帧的动作都如此。
这是设计时就这样的(12fps 是精灵动画的常规速率),一直没人抱怨;但如果哪天觉得"动作太急",先把这条对上。想改就加轨道级的 frameMs 数组(KNOWN_FRAMES2D_TRACK 允许 frames/frameMs/loop/fallback),把 83 调成约 166。eat 因为往返播放,前向速度本来就和源视频接近,改速要连 eat-mid 一起改,否则两种吃法会一快一慢。
素材与授权
pet/ 和 claude/whalegirl.petpack 里的素材全部由 AI 生成:立绘经 see-through 分层,动作由豆包图生视频再抽帧。要用在自己的项目里,请先确认对应平台的服务条款——不同平台对生成内容的商用和再分发规定不一样。
脚本部分 MIT。
No comments yet. Be the first to write one.