feat(wave21): Live2D renderer ported from AIRI — Haru model bundled
Some checks failed
Release / Build aarch64-apple-darwin (push) Has been cancelled
Release / Build x86_64-apple-darwin (push) Has been cancelled
Release / Build aarch64-unknown-linux-gnu (push) Has been cancelled
Release / Build x86_64-unknown-linux-gnu (push) Has been cancelled
Release / Build mcp-server darwin-arm64 (push) Has been cancelled
Release / Build mcp-server linux-arm64 (push) Has been cancelled
Release / Build mcp-server darwin-x64 (push) Has been cancelled
Release / Build mcp-server linux-x64 (push) Has been cancelled
Release / Build mcp-server windows-x64 (push) Has been cancelled
Release / Publish GitHub Release (push) Has been cancelled
Release / Publish npm packages (optional) (push) Has been cancelled

cortex-ui gains a Live2D anime-character renderer alongside existing
32px pixel sprites. User chooses via Setup; switch stored in localStorage
(pet.toml `meta.renderer` field will be wired to daemon in Wave 22).

## Ports from AIRI (MIT, attribution in each file header)

- `src/lib/live2d/emotions.ts` (72 LOC) — 9-value Emotion enum + motion
  name + VRM expression maps. Copy-verbatim + CortexMood→Emotion adapter.
- `src/lib/live2d/motion-manager.ts` (194 LOC) — motion state machine
  (Vue composable → Svelte 5 factory). Named-group preference, fallback
  on throw, replay, reset.
- `src/lib/live2d/expression-controller.ts` (180 LOC) — expression blend
  math + transition reset.
- `src/lib/live2d/beat-sync.ts` (63 LOC) — audio-reactive STUB; full
  port deferred to Wave 22.
- `src/lib/live2d/types.ts` (62 LOC) — shared interfaces.

## New component

- `src/components/Live2DPet.svelte` (169 LOC) — wraps pixi-live2d-display.
  Props `{ modelPath, mood, width, height }`. Fallback message in jsdom
  (no WebGL) — tests mount without throwing.

## PetEditor + Setup integration

- `PetEditor.svelte` reads `pet.toml` `meta.renderer` → localStorage
  fallback → defaults `sprite32`. Toggle button swaps renderer mid-session.
- `Setup.svelte` adds Renderer radio (sprite32 / live2d), persists to
  localStorage.

## Model bundled

Haru (`haru_greeter_t03`), Live2D Cubism official sample, free sample
data license with attribution. 3.4 MB on disk (384 KB moc3 + 2.7 MB
textures + 8 expressions + 5 motions). Served at
`./live2d-models/haru/haru_greeter_t03.model3.json`.

## Deps

- Added `pixi.js ^7.4.0` + `pixi-live2d-display ^0.4.0` to
  _ts_packages/packages/cortex-ui/package.json
- AIRI's `pixi-live2d-display.patch` copied to patches/ with README
  (manual apply, not needed for default model path)
- **Known risk**: pixi 7 vs library peer-dep 6 mismatch — silent at
  install time, untested at runtime. Fallback path: downgrade to pixi ^6
  OR swap to `pixi-live2d-display-lipsyncpatch`. Will validate during
  live browser test; fix in Wave 22 if breaks.

## Tests

30 pass / 0 fail (was 10):
- live2d-emotions.test.ts (6 new)
- live2d-motion-manager.test.ts (6 new)
- live2d-expression-controller.test.ts (5 new)
- live2d-pet-mount.test.ts (3 new)
- pre-existing api + config tests unchanged (10)

## Bundle size

- Main JS: 56 KB (22 KB gzip) — SPA shell unchanged
- pixi.js: 428 KB (129 KB gzip) — code-split, lazy-loaded on `live2d` pick
- pixi-live2d-display + Cubism4: 308 KB (82 KB gzip) — code-split too
- Haru model assets: 3.4 MB public/, served on-demand
- dist total: 7.5 MB with sourcemaps

## Constructor Pattern

All source files ≤194 LOC (motion-manager largest). All leaf fns ≤18
LOC. Factory-composer pattern avoids Vue's Composition API wrapping
(no mixins, no DI).

## Svelte 5 config touch

- `vite.config.ts` adds `resolve.conditions: ['browser']` +
  `server.deps.inline` for vitest — Svelte 5 runes resolve to client
  entry under jsdom correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-24 04:05:47 +08:00
parent a42847063f
commit dc196dc325
36 changed files with 11996 additions and 12 deletions

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,10 @@
"test": "vitest run",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"pixi-live2d-display": "^0.4.0",
"pixi.js": "^7.4.0"
},
"devDependencies": {
"svelte": "^5.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",

View file

@ -0,0 +1,38 @@
# patches/
## pixi-live2d-display.patch
Copied verbatim from AIRI (https://github.com/moeru-ai/airi), MIT License.
### What it does
Both `FileLoader` and `ZipLoader` in `pixi-live2d-display@0.4.0` pick the first
settings file ending in `model.json` / `model3.json`. Newer Live2D authoring
tools emit `items_pinned_to_model.json` as a side-car, which accidentally
matches the suffix and derails the loader. The patch filters it out.
### Is it applied?
Not automatically. This project uses vanilla `npm install`, so we cannot
pre-apply patches the way `pnpm` + `patchedDependencies` or
`patch-package` would. Two options if you need it:
1. **Manual**, once per clone, from `_ts_packages/`:
```sh
patch -p1 -d node_modules/pixi-live2d-display < packages/cortex-ui/patches/pixi-live2d-display.patch
```
2. **Automated**, wire up `patch-package`:
```sh
npm i -D patch-package
# add "postinstall": "patch-package" to root package.json
```
(This would touch `_ts_packages/package.json` which is out of scope for
the current wave; leaving as a follow-up.)
### Do we need it here?
Not for the bundled Haru model — `Live2DPet.svelte` loads the explicit
`model3.json` URL straight from `/public/live2d-models/…`, bypassing the
filename-scanning code paths the patch touches. The patch becomes relevant
only when (a) user-uploaded ZIPs are supported or (b) a model folder
contains an `items_pinned_to_model.json` sidecar.

View file

@ -0,0 +1,144 @@
diff --git a/core/README.md b/core/README.md
deleted file mode 100644
index ad383747237ee1a22ce39d01fbc7e77ac94b8e47..0000000000000000000000000000000000000000
diff --git a/core/live2d.d.ts b/core/live2d.d.ts
deleted file mode 100644
index 0283512ed1c9ea01d7dd1b67b76d660237b453e8..0000000000000000000000000000000000000000
diff --git a/cubism/.vscode/extensions.json b/cubism/.vscode/extensions.json
deleted file mode 100644
index fda5ad57b9567b939382ba15fb1d3b9f1fecf77e..0000000000000000000000000000000000000000
diff --git a/cubism/.vscode/tasks.json b/cubism/.vscode/tasks.json
deleted file mode 100644
index 7cd3fffed85da69d5af154f63480bce8766a038f..0000000000000000000000000000000000000000
diff --git a/dist/cubism2.es.js b/dist/cubism2.es.js
index 0d40d5d40533881d154e3c2cdda9b360c57eaaa6..8dbdb1b36e2da42da264dc3533523e61745102f7 100644
--- a/dist/cubism2.es.js
+++ b/dist/cubism2.es.js
@@ -1373,7 +1373,7 @@ const _FileLoader = class {
}
static createSettings(files) {
return __async(this, null, function* () {
- const settingsFile = files.find((file) => file.name.endsWith("model.json") || file.name.endsWith("model3.json"));
+ const settingsFile = files.find((file) => !file.name.endsWith("items_pinned_to_model.json") && (file.name.endsWith("model.json") || file.name.endsWith("model3.json")));
if (!settingsFile) {
throw new TypeError("Settings file not found");
}
@@ -1458,7 +1458,7 @@ const _ZipLoader = class {
static createSettings(reader) {
return __async(this, null, function* () {
const filePaths = yield _ZipLoader.getFilePaths(reader);
- const settingsFilePath = filePaths.find((path) => path.endsWith("model.json") || path.endsWith("model3.json"));
+ const settingsFilePath = filePaths.find((path) => !path.endsWith("items_pinned_to_model.json") && (path.endsWith("model.json") || path.endsWith("model3.json")));
if (!settingsFilePath) {
throw new Error("Settings file not found");
}
diff --git a/dist/cubism2.js b/dist/cubism2.js
index c3db490f8dbfdd63ad40648fe0e0325604f22e95..4b452db0b109ba7deaddf397ef351ae7bcfe145c 100644
--- a/dist/cubism2.js
+++ b/dist/cubism2.js
@@ -1373,7 +1373,7 @@ var __async = (__this, __arguments, generator) => {
}
static createSettings(files) {
return __async(this, null, function* () {
- const settingsFile = files.find((file) => file.name.endsWith("model.json") || file.name.endsWith("model3.json"));
+ const settingsFile = files.find((file) => !file.name.endsWith("items_pinned_to_model.json") && (file.name.endsWith("model.json") || file.name.endsWith("model3.json")));
if (!settingsFile) {
throw new TypeError("Settings file not found");
}
@@ -1458,7 +1458,7 @@ var __async = (__this, __arguments, generator) => {
static createSettings(reader) {
return __async(this, null, function* () {
const filePaths = yield _ZipLoader.getFilePaths(reader);
- const settingsFilePath = filePaths.find((path) => path.endsWith("model.json") || path.endsWith("model3.json"));
+ const settingsFilePath = filePaths.find((path) => !path.endsWith("items_pinned_to_model.json") && (path.endsWith("model.json") || path.endsWith("model3.json")));
if (!settingsFilePath) {
throw new Error("Settings file not found");
}
diff --git a/dist/cubism4.es.js b/dist/cubism4.es.js
index f21619f5794c00542bb2a9df340e50d4453a0367..38bd069a5d1ec37380c376c2ff2d7fed9c0042b2 100644
--- a/dist/cubism4.es.js
+++ b/dist/cubism4.es.js
@@ -5012,7 +5012,7 @@ const _FileLoader = class {
}
static createSettings(files) {
return __async(this, null, function* () {
- const settingsFile = files.find((file) => file.name.endsWith("model.json") || file.name.endsWith("model3.json"));
+ const settingsFile = files.find((file) => !file.name.endsWith("items_pinned_to_model.json") && (file.name.endsWith("model.json") || file.name.endsWith("model3.json")));
if (!settingsFile) {
throw new TypeError("Settings file not found");
}
@@ -5097,7 +5097,7 @@ const _ZipLoader = class {
static createSettings(reader) {
return __async(this, null, function* () {
const filePaths = yield _ZipLoader.getFilePaths(reader);
- const settingsFilePath = filePaths.find((path) => path.endsWith("model.json") || path.endsWith("model3.json"));
+ const settingsFilePath = filePaths.find((path) => !path.endsWith("items_pinned_to_model.json") && (path.endsWith("model.json") || path.endsWith("model3.json")));
if (!settingsFilePath) {
throw new Error("Settings file not found");
}
diff --git a/dist/cubism4.js b/dist/cubism4.js
index 03c1fb2b32b3b908a8b0e604aa3ee50aa9130169..99fc587e3ca7e6f433099e13f33e0e480a10c25a 100644
--- a/dist/cubism4.js
+++ b/dist/cubism4.js
@@ -5012,7 +5012,7 @@ var __async = (__this, __arguments, generator) => {
}
static createSettings(files) {
return __async(this, null, function* () {
- const settingsFile = files.find((file) => file.name.endsWith("model.json") || file.name.endsWith("model3.json"));
+ const settingsFile = files.find((file) => !file.name.endsWith("items_pinned_to_model.json") && (file.name.endsWith("model.json") || file.name.endsWith("model3.json")));
if (!settingsFile) {
throw new TypeError("Settings file not found");
}
@@ -5097,7 +5097,7 @@ var __async = (__this, __arguments, generator) => {
static createSettings(reader) {
return __async(this, null, function* () {
const filePaths = yield _ZipLoader.getFilePaths(reader);
- const settingsFilePath = filePaths.find((path) => path.endsWith("model.json") || path.endsWith("model3.json"));
+ const settingsFilePath = filePaths.find((path) => !path.endsWith("items_pinned_to_model.json") && (path.endsWith("model.json") || path.endsWith("model3.json")));
if (!settingsFilePath) {
throw new Error("Settings file not found");
}
diff --git a/dist/index.es.js b/dist/index.es.js
index f969d304f346c4e420b6798dbd4dec81b902a5e4..7e6b4d3ef53f1ea0648030899de9debd291fc551 100644
--- a/dist/index.es.js
+++ b/dist/index.es.js
@@ -1373,7 +1373,7 @@ const _FileLoader = class {
}
static createSettings(files) {
return __async(this, null, function* () {
- const settingsFile = files.find((file) => file.name.endsWith("model.json") || file.name.endsWith("model3.json"));
+ const settingsFile = files.find((file) => !file.name.endsWith("items_pinned_to_model.json") && (file.name.endsWith("model.json") || file.name.endsWith("model3.json")));
if (!settingsFile) {
throw new TypeError("Settings file not found");
}
@@ -1458,7 +1458,7 @@ const _ZipLoader = class {
static createSettings(reader) {
return __async(this, null, function* () {
const filePaths = yield _ZipLoader.getFilePaths(reader);
- const settingsFilePath = filePaths.find((path) => path.endsWith("model.json") || path.endsWith("model3.json"));
+ const settingsFilePath = filePaths.find((path) => !path.endsWith("items_pinned_to_model.json") && (path.endsWith("model.json") || path.endsWith("model3.json")));
if (!settingsFilePath) {
throw new Error("Settings file not found");
}
diff --git a/dist/index.js b/dist/index.js
index bc3d800c45889979190175cc88cdd8251feb9b5e..d1a660708ff81e1379f173709a32c6f4aa16696a 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1373,7 +1373,7 @@ var __async = (__this, __arguments, generator) => {
}
static createSettings(files) {
return __async(this, null, function* () {
- const settingsFile = files.find((file) => file.name.endsWith("model.json") || file.name.endsWith("model3.json"));
+ const settingsFile = files.find((file) => !file.name.endsWith("items_pinned_to_model.json") && (file.name.endsWith("model.json") || file.name.endsWith("model3.json")));
if (!settingsFile) {
throw new TypeError("Settings file not found");
}
@@ -1458,7 +1458,7 @@ var __async = (__this, __arguments, generator) => {
static createSettings(reader) {
return __async(this, null, function* () {
const filePaths = yield _ZipLoader.getFilePaths(reader);
- const settingsFilePath = filePaths.find((path) => path.endsWith("model.json") || path.endsWith("model3.json"));
+ const settingsFilePath = filePaths.find((path) => !path.endsWith("items_pinned_to_model.json") && (path.endsWith("model.json") || path.endsWith("model3.json")));
if (!settingsFilePath) {
throw new Error("Settings file not found");
}

View file

@ -0,0 +1,10 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamMouthOpenY",
"Value": 0.27,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,35 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamBrowLY",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamMouthOpenY",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeForm",
"Value": 0.54,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,55 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamBrowLY",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -2,
"Blend": "Add"
},
{
"Id": "ParamMouthOpenY",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeForm",
"Value": -1,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,60 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": 0.8,
"Blend": "Multiply"
},
{
"Id": "ParamEyeROpen",
"Value": 0.8,
"Blend": "Multiply"
},
{
"Id": "ParamBrowLY",
"Value": -0.56,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": -0.56,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0.35,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0.35,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": -0.74,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": -0.74,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -1.76,
"Blend": "Add"
},
{
"Id": "ParamEyeForm",
"Value": 1,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,35 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": 0,
"Blend": "Multiply"
},
{
"Id": "ParamEyeLSmile",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": 0,
"Blend": "Multiply"
},
{
"Id": "ParamEyeRSmile",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0.32,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0.32,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,35 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": 2,
"Blend": "Multiply"
},
{
"Id": "ParamEyeROpen",
"Value": 2,
"Blend": "Multiply"
},
{
"Id": "ParamBrowLY",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamEyeBallForm",
"Value": -0.65,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,65 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": 0.89,
"Blend": "Multiply"
},
{
"Id": "ParamEyeROpen",
"Value": 0.89,
"Blend": "Multiply"
},
{
"Id": "ParamBrowLY",
"Value": -0.56,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": -0.56,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0.35,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0.35,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": -0.74,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": -0.74,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -0.46,
"Blend": "Add"
},
{
"Id": "ParamTere",
"Value": 1,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,30 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": 0.8,
"Blend": "Multiply"
},
{
"Id": "ParamEyeROpen",
"Value": 0.8,
"Blend": "Multiply"
},
{
"Id": "ParamBrowLForm",
"Value": -0.33,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": -0.33,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -1.76,
"Blend": "Add"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View file

@ -0,0 +1,49 @@
{
"Version": 3,
"FileReferences": {
"Moc": "haru_greeter_t03.moc3",
"Textures": [
"haru_greeter_t03.2048/texture_00.png",
"haru_greeter_t03.2048/texture_01.png"
],
"Physics": "haru_greeter_t03.physics3.json",
"Pose": "haru_greeter_t03.pose3.json",
"Expressions": [
{ "Name": "f00", "File": "expressions/F01.exp3.json" },
{ "Name": "f01", "File": "expressions/F02.exp3.json" },
{ "Name": "f02", "File": "expressions/F03.exp3.json" },
{ "Name": "f03", "File": "expressions/F04.exp3.json" },
{ "Name": "f04", "File": "expressions/F05.exp3.json" },
{ "Name": "f05", "File": "expressions/F06.exp3.json" },
{ "Name": "f06", "File": "expressions/F07.exp3.json" },
{ "Name": "f07", "File": "expressions/F08.exp3.json" }
],
"Motions": {
"Idle": [
{ "File": "motion/haru_g_idle.motion3.json" },
{ "File": "motion/haru_g_m07.motion3.json" },
{ "File": "motion/haru_g_m15.motion3.json" }
],
"Tap": [
{ "File": "motion/haru_g_m14.motion3.json" },
{ "File": "motion/haru_g_m05.motion3.json" }
]
}
},
"Groups": [
{
"Target": "Parameter",
"Name": "EyeBlink",
"Ids": ["ParamEyeLOpen", "ParamEyeROpen"]
},
{
"Target": "Parameter",
"Name": "LipSync",
"Ids": ["ParamMouthOpenY"]
}
],
"HitAreas": [
{ "Id": "HitArea", "Name": "Head" },
{ "Id": "HitArea2", "Name": "Body" }
]
}

View file

@ -0,0 +1,373 @@
{
"Version": 3,
"Meta": {
"PhysicsSettingCount": 4,
"TotalInputCount": 14,
"TotalOutputCount": 4,
"VertexCount": 8,
"EffectiveForces": {
"Gravity": {
"X": 0,
"Y": -1
},
"Wind": {
"X": 0,
"Y": 0
}
},
"PhysicsDictionary": [
{
"Id": "PhysicsSetting1",
"Name": "前髪"
},
{
"Id": "PhysicsSetting2",
"Name": "横髪"
},
{
"Id": "PhysicsSetting3",
"Name": "後ろ髪"
},
{
"Id": "PhysicsSetting4",
"Name": "スカーフ"
}
]
},
"PhysicsSettings": [
{
"Id": "PhysicsSetting1",
"Input": [
{
"Source": {
"Target": "Parameter",
"Id": "ParamAngleX"
},
"Weight": 60,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamAngleZ"
},
"Weight": 60,
"Type": "Angle",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleX"
},
"Weight": 40,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleZ"
},
"Weight": 40,
"Type": "Angle",
"Reflect": false
}
],
"Output": [
{
"Destination": {
"Target": "Parameter",
"Id": "ParamHairFront"
},
"VertexIndex": 1,
"Scale": 1.821,
"Weight": 100,
"Type": "Angle",
"Reflect": false
}
],
"Vertices": [
{
"Position": {
"X": 0,
"Y": 0
},
"Mobility": 1,
"Delay": 1,
"Acceleration": 1,
"Radius": 0
},
{
"Position": {
"X": 0,
"Y": 8
},
"Mobility": 0.95,
"Delay": 0.8,
"Acceleration": 1.5,
"Radius": 8
}
],
"Normalization": {
"Position": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
},
"Angle": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
}
}
},
{
"Id": "PhysicsSetting2",
"Input": [
{
"Source": {
"Target": "Parameter",
"Id": "ParamAngleX"
},
"Weight": 60,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamAngleZ"
},
"Weight": 60,
"Type": "Angle",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleX"
},
"Weight": 40,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleZ"
},
"Weight": 40,
"Type": "Angle",
"Reflect": false
}
],
"Output": [
{
"Destination": {
"Target": "Parameter",
"Id": "ParamHairSide"
},
"VertexIndex": 1,
"Scale": 1.593,
"Weight": 100,
"Type": "Angle",
"Reflect": false
}
],
"Vertices": [
{
"Position": {
"X": 0,
"Y": 0
},
"Mobility": 1,
"Delay": 1,
"Acceleration": 1,
"Radius": 0
},
{
"Position": {
"X": 0,
"Y": 8
},
"Mobility": 0.95,
"Delay": 0.8,
"Acceleration": 1,
"Radius": 8
}
],
"Normalization": {
"Position": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
},
"Angle": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
}
}
},
{
"Id": "PhysicsSetting3",
"Input": [
{
"Source": {
"Target": "Parameter",
"Id": "ParamAngleX"
},
"Weight": 60,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamAngleZ"
},
"Weight": 60,
"Type": "Angle",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleX"
},
"Weight": 40,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleZ"
},
"Weight": 40,
"Type": "Angle",
"Reflect": false
}
],
"Output": [
{
"Destination": {
"Target": "Parameter",
"Id": "ParamHairBack"
},
"VertexIndex": 1,
"Scale": 1.943,
"Weight": 100,
"Type": "Angle",
"Reflect": false
}
],
"Vertices": [
{
"Position": {
"X": 0,
"Y": 0
},
"Mobility": 1,
"Delay": 1,
"Acceleration": 1,
"Radius": 0
},
{
"Position": {
"X": 0,
"Y": 8
},
"Mobility": 0.95,
"Delay": 0.8,
"Acceleration": 1.5,
"Radius": 8
}
],
"Normalization": {
"Position": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
},
"Angle": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
}
}
},
{
"Id": "PhysicsSetting4",
"Input": [
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleX"
},
"Weight": 100,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleZ"
},
"Weight": 100,
"Type": "Angle",
"Reflect": false
}
],
"Output": [
{
"Destination": {
"Target": "Parameter",
"Id": "ParamScarf"
},
"VertexIndex": 1,
"Scale": 0.873,
"Weight": 100,
"Type": "Angle",
"Reflect": false
}
],
"Vertices": [
{
"Position": {
"X": 0,
"Y": 0
},
"Mobility": 1,
"Delay": 1,
"Acceleration": 1,
"Radius": 0
},
{
"Position": {
"X": 0,
"Y": 10
},
"Mobility": 0.9,
"Delay": 0.6,
"Acceleration": 1.5,
"Radius": 10
}
],
"Normalization": {
"Position": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
},
"Angle": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
}
}
}
]
}

View file

@ -0,0 +1,25 @@
{
"Type": "Live2D Pose",
"Groups": [
[
{
"Id": "Part01ArmRA001",
"Link": []
},
{
"Id": "Part01ArmRB001",
"Link": []
}
],
[
{
"Id": "Part01ArmLA001",
"Link": []
},
{
"Id": "Part01ArmLB001",
"Link": []
}
]
]
}

View file

@ -0,0 +1,169 @@
<script lang="ts">
// Ported from AIRI (https://github.com/moeru-ai/airi), MIT License.
// Original: packages/stage-ui-live2d/src/components/scenes/Live2D.vue
// Adapted from Vue 3 <script setup> + Pinia stores to Svelte 5 runes.
// Only the model-loading + emotion-switching + breathing/blink loop is
// reproduced; Canvas.vue + Model.vue's richer feature-set (hot reload,
// theme tint, focus tracking, render-scale probes) is deferred.
import { onMount, onDestroy } from 'svelte';
import { Emotion } from '../lib/live2d/emotions';
import {
createEmotionMotionManager,
type EmotionMotionManager,
} from '../lib/live2d/motion-manager';
import type { Live2DPetProps } from '../lib/live2d/types';
const {
modelPath,
mood = Emotion.Neutral,
width = 256,
height = 256,
}: Live2DPetProps = $props();
let canvas = $state<HTMLCanvasElement | null>(null);
let load_error = $state<string | null>(null);
let ready = $state(false);
// Non-reactive runtime refs (PIXI objects cannot be wrapped in $state).
let app: { destroy?: (r?: boolean, opts?: unknown) => void } | null = null;
let live2d_model: { destroy?: (opts?: unknown) => void } | null = null;
let motion_mgr: EmotionMotionManager | null = null;
onMount(() => {
// Guard: tests run under jsdom which has no WebGL. Skip boot and leave
// the canvas in place so `@testing-library` can assert a clean mount.
if (!has_webgl()) {
load_error = 'WebGL not available; renderer disabled (tests/jsdom).';
return;
}
void boot();
});
onDestroy(() => {
try {
if (live2d_model?.destroy) {
live2d_model.destroy({ children: true, texture: true, baseTexture: true });
}
} catch { /* noop */ }
try {
app?.destroy?.(true, { children: true, texture: true, baseTexture: true });
} catch { /* noop */ }
live2d_model = null;
app = null;
motion_mgr = null;
});
// Reactive bridge: mood prop → motion manager.
$effect(() => {
if (!ready || !motion_mgr) return;
void motion_mgr.setEmotion(mood);
});
async function boot(): Promise<void> {
try {
const { Application } = await import('pixi.js');
// pixi-live2d-display imports the Cubism 4 runtime + registers the
// motion-manager plugin onto PIXI when loaded.
const plugin = await import('pixi-live2d-display/cubism4');
const Live2DModel = plugin.Live2DModel;
if (!canvas) throw new Error('canvas element missing');
app = new Application({
view: canvas,
width,
height,
backgroundAlpha: 0,
antialias: true,
autoStart: true,
});
const model = await Live2DModel.from(modelPath, { autoInteract: false });
fit_model(model, width, height);
// @ts-expect-error — PIXI container.addChild signature.
app!.stage.addChild(model);
live2d_model = model as unknown as typeof live2d_model;
motion_mgr = createEmotionMotionManager(
(model as { internalModel: { motionManager: unknown } }).internalModel
.motionManager as Parameters<typeof createEmotionMotionManager>[0],
);
ready = true;
// Initial emotion.
await motion_mgr.setEmotion(mood);
} catch (err) {
load_error =
err instanceof Error
? err.message
: `failed to load Live2D model: ${String(err)}`;
// Keep ready=false so the $effect above doesn't fire on a null mgr.
}
}
function has_webgl(): boolean {
if (typeof document === 'undefined') return false;
// jsdom ships `HTMLCanvasElement.getContext` as a "not implemented"
// stub that logs to stderr. Short-circuit when we can detect jsdom.
const nav = typeof navigator !== 'undefined' ? navigator.userAgent : '';
if (nav.includes('jsdom')) return false;
try {
const c = document.createElement('canvas');
return !!(
c.getContext('webgl2') ||
c.getContext('webgl') ||
c.getContext('experimental-webgl')
);
} catch {
return false;
}
}
function fit_model(
m: { scale: { set: (v: number) => void }; x: number; y: number; width: number; height: number },
cw: number,
ch: number,
): void {
const scale = Math.min(cw / m.width, ch / m.height) * 0.95;
m.scale.set(scale);
m.x = (cw - m.width) / 2;
m.y = (ch - m.height) / 2;
}
</script>
<div class="live2d-pet" style:width={`${width}px`} style:height={`${height}px`}>
<canvas
bind:this={canvas}
class="live2d-canvas"
width={width}
height={height}
aria-label="Live2D pet ({mood})"
></canvas>
{#if load_error}
<div class="live2d-error muted" role="status">{load_error}</div>
{/if}
</div>
<style>
.live2d-pet {
position: relative;
display: inline-block;
border: 1px solid var(--border, #e5e5e5);
border-radius: 8px;
background: var(--card, #f7f7f8);
overflow: hidden;
}
.live2d-canvas {
display: block;
width: 100%;
height: 100%;
}
.live2d-error {
position: absolute;
inset: auto 0 8px 0;
text-align: center;
font-size: 11px;
color: var(--muted, #666);
padding: 2px 6px;
background: rgba(255, 255, 255, 0.7);
}
</style>

View file

@ -0,0 +1,63 @@
// Ported from AIRI (https://github.com/moeru-ai/airi), MIT License.
// Original: packages/stage-ui-live2d/src/composables/live2d/beat-sync.ts
// Adapted: STUB for Wave 21. The full 359-LOC AIRI implementation drives
// head yaw/roll from audio BPM with semi-implicit Euler springs and four
// stylistic patterns ('punchy-v' | 'balanced-v' | 'swing-lr' | 'sway-sine').
// cortex-ui does not yet have TTS/audio wired in, so every method here is
// a no-op that matches the AIRI shape so call-sites compile unchanged.
// Full port scheduled for Wave 22 (TTS integration).
export type BeatSyncStyleName =
| 'punchy-v'
| 'balanced-v'
| 'swing-lr'
| 'sway-sine';
export interface BeatBaseAngles {
x: number;
y: number;
z: number;
}
export interface BeatSyncController {
readonly targetX: number;
readonly targetY: number;
readonly targetZ: number;
readonly velocityX: number;
readonly velocityY: number;
readonly velocityZ: number;
updateTargets(now: number): void;
scheduleBeat(timestamp?: number | null): void;
setStyle(style: BeatSyncStyleName): void;
getStyle(): BeatSyncStyleName;
setAutoStyleShift(enabled: boolean): void;
debugState(): {
primed: boolean;
patternStarted: boolean;
bpm: number | null;
style: BeatSyncStyleName;
};
}
export function createBeatSyncController(): BeatSyncController {
let style: BeatSyncStyleName = 'balanced-v';
return {
targetX: 0,
targetY: 0,
targetZ: 0,
velocityX: 0,
velocityY: 0,
velocityZ: 0,
updateTargets: () => { /* stub: Wave 22 will drive head sway. */ },
scheduleBeat: () => { /* stub: Wave 22 will consume TTS beats. */ },
setStyle: (s) => { style = s; },
getStyle: () => style,
setAutoStyleShift: () => { /* stub */ },
debugState: () => ({
primed: false,
patternStarted: false,
bpm: null,
style,
}),
};
}

View file

@ -0,0 +1,72 @@
// Ported from AIRI (https://github.com/moeru-ai/airi), MIT License.
// Original: packages/stage-ui-live2d/src/constants/emotions.ts
// Copy-as-is; no Vue/Svelte API surface here, just an enum + lookup tables.
export enum Emotion {
Happy = 'happy',
Sad = 'sad',
Angry = 'angry',
Think = 'think',
Surprise = 'surprised',
Awkward = 'awkward',
Question = 'question',
Curious = 'curious',
Neutral = 'neutral',
}
export const EMOTION_VALUES = Object.values(Emotion);
export const EmotionHappyMotionName = 'Happy';
export const EmotionSadMotionName = 'Sad';
export const EmotionAngryMotionName = 'Angry';
export const EmotionAwkwardMotionName = 'Awkward';
export const EmotionThinkMotionName = 'Think';
export const EmotionSurpriseMotionName = 'Surprise';
export const EmotionQuestionMotionName = 'Question';
export const EmotionNeutralMotionName = 'Idle';
export const EmotionCuriousMotionName = 'Curious';
export const EMOTION_EmotionMotionName_value: Record<Emotion, string> = {
[Emotion.Happy]: EmotionHappyMotionName,
[Emotion.Sad]: EmotionSadMotionName,
[Emotion.Angry]: EmotionAngryMotionName,
[Emotion.Think]: EmotionThinkMotionName,
[Emotion.Surprise]: EmotionSurpriseMotionName,
[Emotion.Awkward]: EmotionAwkwardMotionName,
[Emotion.Question]: EmotionQuestionMotionName,
[Emotion.Neutral]: EmotionNeutralMotionName,
[Emotion.Curious]: EmotionCuriousMotionName,
};
export const EMOTION_VRMExpressionName_value: Record<Emotion, string | undefined> = {
[Emotion.Happy]: 'happy',
[Emotion.Sad]: 'sad',
[Emotion.Angry]: 'angry',
[Emotion.Think]: undefined,
[Emotion.Surprise]: 'surprised',
[Emotion.Awkward]: undefined,
[Emotion.Question]: undefined,
[Emotion.Neutral]: undefined,
[Emotion.Curious]: 'surprised',
};
/**
* Cortex mood (4 buckets used by PetEditor) Live2D Emotion.
* This is the bridge from the existing pet-editor sprite moods to the
* finer-grained AIRI emotion enum.
*/
export type CortexMood = 'idle' | 'happy' | 'think' | 'sleep';
export function moodToEmotion(mood: CortexMood): Emotion {
switch (mood) {
case 'happy':
return Emotion.Happy;
case 'think':
return Emotion.Think;
case 'sleep':
return Emotion.Neutral; // no "Sleep" emotion; closest resting state.
case 'idle':
default:
return Emotion.Neutral;
}
}

View file

@ -0,0 +1,180 @@
// Ported from AIRI (https://github.com/moeru-ai/airi), MIT License.
// Original: packages/stage-ui-live2d/src/composables/live2d/expression-controller.ts
// Adapted: AIRI's version maintains its own Pinia-backed parameter blend store
// to support authoring UI (ExpressionPanel) on top of exp3 files. In
// cortex-ui we delegate exp3 parsing to pixi-live2d-display's own
// ExpressionManager — the Svelte component merely drives emotion→expression
// selection. This controller tracks the lookup layer (Emotion → exp name)
// plus a small active-parameter registry for optional per-frame overrides.
import type {
CubismCoreModelLike,
ExpressionManagerLike,
Live2DModelLike,
} from './types';
export type ExpressionBlendMode = 'Add' | 'Multiply' | 'Overwrite';
export interface ExpressionEntry {
name: string;
parameterId: string;
blend: ExpressionBlendMode;
/** Runtime-mutable: current blended target. */
currentValue: number;
/** The Cubism parameter's initial value at load time. */
modelDefault: number;
/** The "intended activation value" from the exp3 file. */
targetValue: number;
}
export interface ExpressionController {
setActive(name: string): boolean;
clear(): void;
listAvailable(): string[];
/** Apply any registered manual overrides every frame. */
applyExpressions(coreModel: CubismCoreModelLike): void;
registerOverride(entry: ExpressionEntry): void;
removeOverride(parameterId: string): void;
debugActive(): string | null;
}
interface ControllerState {
active: string | null;
overrides: Map<string, ExpressionEntry>;
// Previous-frame active param IDs, so we can explicitly reset stale writes
// on the inactive→active transition (port of AIRI's `activeLastFrame`).
lastFrame: Set<string>;
}
function initialState(): ControllerState {
return {
active: null,
overrides: new Map(),
lastFrame: new Set(),
};
}
/**
* Create an expression controller bound to a Live2D model.
*
* For Cubism 4 models loaded via pixi-live2d-display, the `expressionManager`
* is populated from `model3.json.FileReferences.Expressions`. This wrapper
* adds:
* - `setActive(name)`: idempotent, logs on unknown name.
* - `applyExpressions(coreModel)`: apply manual parameter overrides on top
* of whatever the native expression manager has already written.
*/
export function createExpressionController(
model: Live2DModelLike,
): ExpressionController {
const state = initialState();
function getManager(): ExpressionManagerLike | null {
return model.internalModel?.motionManager?.expressionManager ?? null;
}
function listAvailable(): string[] {
const em = getManager();
if (!em?.definitions) return [];
return em.definitions.map((d, i) => d.Name ?? d.name ?? String(i));
}
function setActive(name: string): boolean {
const em = getManager();
if (!em) return false;
const available = listAvailable();
if (!available.includes(name)) {
// Try numeric index as fallback (pixi-live2d-display accepts both).
const asNum = Number(name);
if (!Number.isInteger(asNum)) return false;
}
try {
const r = em.setExpression(name);
if (r === false) return false;
state.active = name;
return true;
} catch {
return false;
}
}
function clear(): void {
const em = getManager();
if (em?.resetExpression) {
try { em.resetExpression(); } catch { /* noop */ }
}
state.active = null;
state.overrides.clear();
state.lastFrame.clear();
}
function registerOverride(entry: ExpressionEntry): void {
state.overrides.set(entry.parameterId, entry);
}
function removeOverride(parameterId: string): void {
state.overrides.delete(parameterId);
}
// ---- Per-frame --------------------------------------------------------
function applyExpressions(coreModel: CubismCoreModelLike): void {
const activeThisFrame = new Set<string>();
for (const entry of state.overrides.values()) {
if (isNoop(entry)) continue;
const value = computeBlend(entry, coreModel);
coreModel.setParameterValueById(entry.parameterId, value);
activeThisFrame.add(entry.parameterId);
}
for (const paramId of state.lastFrame) {
if (!activeThisFrame.has(paramId)) {
const entry = state.overrides.get(paramId);
if (entry) coreModel.setParameterValueById(paramId, entry.modelDefault);
}
}
state.lastFrame.clear();
activeThisFrame.forEach((p) => state.lastFrame.add(p));
}
return {
setActive,
clear,
listAvailable,
applyExpressions,
registerOverride,
removeOverride,
debugActive: () => state.active,
};
}
// ---- Internal pure helpers (blend math, ported verbatim) ------------------
function isNoop(entry: ExpressionEntry): boolean {
switch (entry.blend) {
case 'Add':
return entry.currentValue === 0;
case 'Multiply':
return entry.currentValue === 1;
default:
return entry.currentValue === entry.modelDefault;
}
}
function computeBlend(
entry: ExpressionEntry,
coreModel: CubismCoreModelLike,
): number {
switch (entry.blend) {
case 'Add':
return entry.modelDefault + entry.currentValue;
case 'Multiply': {
const frameValue = coreModel.getParameterValueById(entry.parameterId);
return frameValue * entry.currentValue;
}
default:
return entry.currentValue;
}
}

View file

@ -0,0 +1,194 @@
// Ported from AIRI (https://github.com/moeru-ai/airi), MIT License.
// Original: packages/stage-ui-live2d/src/composables/live2d/motion-manager.ts
// Adapted from Vue Composition API (Ref + composables + plugin registry) to
// a plain TS state machine + closure. The AIRI version runs a plugin chain
// each frame hooked into pixi-live2d-display's `MotionManager.update`. Our
// cortex-ui use case is coarser: switch emotions on user action, and let
// pixi-live2d-display's built-in idle + eyeBlink loops handle breathing.
// Only idle/Tap motion groups + F01-F08 expressions are mapped.
import {
Emotion,
EMOTION_EmotionMotionName_value,
} from './emotions';
import type { MotionManagerLike } from './types';
/**
* Which motion group + expression index the AIRI emotion maps to on the
* bundled Haru model. Models with richer per-emotion motion groups
* (`Happy` / `Sad` / ...) are handled by `MotionManager.startMotion`
* falling through to the declared group if present.
*/
export interface EmotionBinding {
/** Motion group name used by `manager.startMotion(group, index)`. */
group: string;
/** Optional index inside the group; defaults to 0 (first clip). */
index?: number;
/** Optional expression name inside the model's Expressions array. */
expression?: string;
}
/** Haru-specific fallback bindings used when the model lacks emotion-named groups. */
export const HARU_EMOTION_BINDINGS: Record<Emotion, EmotionBinding> = {
[Emotion.Neutral]: { group: 'Idle', index: 0, expression: 'f00' },
[Emotion.Happy]: { group: 'Tap', index: 0, expression: 'f01' },
[Emotion.Sad]: { group: 'Idle', index: 1, expression: 'f02' },
[Emotion.Angry]: { group: 'Tap', index: 1, expression: 'f03' },
[Emotion.Surprise]: { group: 'Tap', index: 0, expression: 'f04' },
[Emotion.Awkward]: { group: 'Idle', index: 2, expression: 'f05' },
[Emotion.Think]: { group: 'Idle', index: 0, expression: 'f06' },
[Emotion.Question]: { group: 'Idle', index: 0, expression: 'f07' },
[Emotion.Curious]: { group: 'Tap', index: 1, expression: 'f07' },
};
export interface MotionManagerState {
currentEmotion: Emotion;
lastGroup: string | null;
lastIndex: number;
}
function initialState(): MotionManagerState {
return {
currentEmotion: Emotion.Neutral,
lastGroup: null,
lastIndex: 0,
};
}
export interface EmotionMotionManagerOptions {
/** Per-emotion bindings. Defaults to `HARU_EMOTION_BINDINGS`. */
bindings?: Record<Emotion, EmotionBinding>;
/**
* Preferred motion group for a named emotion (e.g. `'Happy'`, `'Sad'`).
* If the pixi manager reports a group by this name, it's used in place
* of the generic binding. Defaults to the AIRI convention from
* `EMOTION_EmotionMotionName_value`.
*/
preferNamedGroups?: boolean;
/** Priority passed to `startMotion`. Default 2 (NORMAL in pixi). */
priority?: number;
}
/**
* Return value of {@link createEmotionMotionManager}.
*/
export interface EmotionMotionManager {
setEmotion(emotion: Emotion): Promise<boolean>;
getCurrentEmotion(): Emotion;
/** Re-play the last picked motion (e.g. after model reload). */
replay(): Promise<boolean>;
/** Reset state to Neutral. Does NOT call the manager. */
reset(): void;
/** Expose internal state for tests. */
debugState(): Readonly<MotionManagerState>;
}
/**
* Create an emotion-driven motion manager that wraps a
* pixi-live2d-display `MotionManager`.
*
* The returned object is the minimum surface the Svelte component needs:
* `setEmotion(e)` during user interaction, `getCurrentEmotion()` for
* display, `replay()` after reload.
*/
export function createEmotionMotionManager(
manager: MotionManagerLike,
opts: EmotionMotionManagerOptions = {},
): EmotionMotionManager {
const bindings = opts.bindings ?? HARU_EMOTION_BINDINGS;
const preferNamed = opts.preferNamedGroups ?? true;
const priority = opts.priority ?? 2;
const state: MotionManagerState = initialState();
async function setEmotion(emotion: Emotion): Promise<boolean> {
const binding = pickBinding(manager, emotion, bindings, preferNamed);
state.currentEmotion = emotion;
state.lastGroup = binding.group;
state.lastIndex = binding.index ?? 0;
const ok = await playMotion(manager, binding, priority);
if (ok && binding.expression) {
await applyExpression(manager, binding.expression);
}
return ok;
}
function replay(): Promise<boolean> {
return setEmotion(state.currentEmotion);
}
function reset(): void {
state.currentEmotion = Emotion.Neutral;
state.lastGroup = null;
state.lastIndex = 0;
}
return {
setEmotion,
getCurrentEmotion: () => state.currentEmotion,
replay,
reset,
debugState: () => state,
};
}
// ---------------------------------------------------------------------------
// Helpers (kept small to honour the 30-line-per-function rule).
// ---------------------------------------------------------------------------
function pickBinding(
manager: MotionManagerLike,
emotion: Emotion,
bindings: Record<Emotion, EmotionBinding>,
preferNamed: boolean,
): EmotionBinding {
const fallback = bindings[emotion];
if (!preferNamed) return fallback;
const namedGroup = EMOTION_EmotionMotionName_value[emotion];
if (namedGroup && managerHasGroup(manager, namedGroup)) {
return { group: namedGroup, index: 0, expression: fallback?.expression };
}
return fallback;
}
function managerHasGroup(manager: MotionManagerLike, group: string): boolean {
const groups = manager.groups;
if (!groups) return false;
return groups.idle === group || groups.tap === group;
}
async function playMotion(
manager: MotionManagerLike,
binding: EmotionBinding,
priority: number,
): Promise<boolean> {
try {
const result = await Promise.resolve(
manager.startMotion(binding.group, binding.index ?? 0, priority),
);
return result !== false;
} catch (err) {
// Model may lack the requested group/index; fall back to Idle.
try {
await Promise.resolve(manager.startMotion('Idle', 0, priority));
return true;
} catch {
return false;
}
}
}
async function applyExpression(
manager: MotionManagerLike,
name: string,
): Promise<boolean> {
const em = manager.expressionManager;
if (!em) return false;
try {
const r = await Promise.resolve(em.setExpression(name));
return r !== false;
} catch {
return false;
}
}

View file

@ -0,0 +1,62 @@
// Ported from AIRI (https://github.com/moeru-ai/airi), MIT License.
// Original: packages/stage-ui-live2d/src/composables/live2d/motion-manager.ts (types)
// Adapted: Vue Ref types removed; pure structural types only.
import type { Emotion } from './emotions';
/**
* Minimal shape we need from the Cubism 4 core model at runtime.
* We deliberately keep this abstract so the component can run against a
* stub (tests) or the real pixi-live2d-display Cubism4InternalModel.coreModel.
*/
export interface CubismCoreModelLike {
getParameterValueById(id: string): number;
setParameterValueById(id: string, value: number): void;
}
/**
* Shape of the motion manager exposed by pixi-live2d-display.
* Structural typing only; we never construct one of these ourselves.
*/
export interface MotionManagerLike {
startMotion(group: string, index?: number, priority?: number): Promise<boolean> | boolean;
stopAllMotions(): void;
groups?: { idle?: string; tap?: string };
state?: { currentGroup?: string | null };
}
/**
* Shape of the expression manager exposed by pixi-live2d-display.
*/
export interface ExpressionManagerLike {
setExpression(name: string | number): Promise<boolean> | boolean;
resetExpression?(): void;
definitions?: Array<{ Name?: string; name?: string }>;
}
/**
* Shape of the Live2DModel instance we wrap.
* We use a `Live2DModelLike` interface so the Svelte component can be
* tested with a tiny stub instead of requiring a real WebGL canvas.
*/
export interface Live2DModelLike {
internalModel: {
motionManager: MotionManagerLike & {
expressionManager?: ExpressionManagerLike;
};
coreModel: CubismCoreModelLike;
};
on?(event: string, handler: () => void): void;
destroy?(options?: { children?: boolean; texture?: boolean; baseTexture?: boolean }): void;
}
export interface Live2DPetProps {
/** Absolute or relative URL to the `*.model3.json` settings file. */
modelPath: string;
/** Current emotion; re-evaluated reactively. */
mood?: Emotion;
/** Width of the PIXI canvas in CSS pixels. Defaults to 256. */
width?: number;
/** Height of the PIXI canvas in CSS pixels. Defaults to 256. */
height?: number;
}

View file

@ -3,6 +3,8 @@
import type { CortexConfig } from '../lib/config';
import type { PetManifest } from '../lib/types';
import { pet as fetch_pet } from '../lib/api';
import Live2DPet from '../components/Live2DPet.svelte';
import { moodToEmotion, type CortexMood } from '../lib/live2d/emotions';
interface Props {
config: CortexConfig;
@ -11,10 +13,15 @@
const { config, user_id }: Props = $props();
const KEY_RENDERER = 'kei-cortex-renderer';
const DEFAULT_LIVE2D_MODEL =
'./live2d-models/haru/haru_greeter_t03.model3.json';
let manifest = $state<PetManifest | null>(null);
let error = $state<string | null>(null);
let loading = $state(true);
let mood = $state<'idle' | 'happy' | 'think' | 'sleep'>('idle');
let mood = $state<CortexMood>('idle');
let renderer = $state<'sprite32' | 'live2d'>('sprite32');
const AVAILABLE = {
cat: ['idle', 'happy', 'think', 'sleep'] as const,
@ -23,7 +30,6 @@
blob: ['idle'] as const,
};
// Pick species from pet name first letter, defaulting to cat (most states available)
function species_for(pet_name: string): 'cat' | 'dog' | 'owl' | 'blob' {
const first = pet_name.trim().toLowerCase().charAt(0);
if (first === 'd') return 'dog';
@ -32,13 +38,32 @@
return 'cat';
}
function sprite_src(pet_name: string, m: typeof mood): string {
function sprite_src(pet_name: string, m: CortexMood): string {
const sp = species_for(pet_name);
const states = AVAILABLE[sp] as readonly string[];
const state = states.includes(m) ? m : 'idle';
return `./sprites/32px/${sp}-${state}.png`;
}
function load_renderer_pref(m: PetManifest | null): 'sprite32' | 'live2d' {
// Preferred source of truth (future): `pet.toml` `renderer` field.
const tomlField = (m?.meta as { renderer?: unknown } | undefined)?.renderer;
if (tomlField === 'live2d' || tomlField === 'sprite32') return tomlField;
// Fallback: localStorage set by Setup wizard.
if (typeof localStorage !== 'undefined') {
const stored = localStorage.getItem(KEY_RENDERER);
if (stored === 'live2d' || stored === 'sprite32') return stored;
}
return 'sprite32';
}
function toggle_renderer(): void {
renderer = renderer === 'live2d' ? 'sprite32' : 'live2d';
if (typeof localStorage !== 'undefined') {
localStorage.setItem(KEY_RENDERER, renderer);
}
}
onMount(async () => {
if (!user_id) {
error = 'missing user_id in route';
@ -48,6 +73,7 @@
try {
const res = await fetch_pet(config, user_id);
manifest = res.pet;
renderer = load_renderer_pref(manifest);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
@ -60,23 +86,39 @@
{#if manifest}
<div class="pet-sprite-box">
<img
class="pet-sprite"
src={sprite_src(manifest.identity.pet_name, mood)}
alt="{manifest.identity.pet_name} ({mood})"
width="128"
height="128"
/>
{#if renderer === 'live2d'}
<Live2DPet
modelPath={DEFAULT_LIVE2D_MODEL}
mood={moodToEmotion(mood)}
width={256}
height={256}
/>
{:else}
<img
class="pet-sprite"
src={sprite_src(manifest.identity.pet_name, mood)}
alt="{manifest.identity.pet_name} ({mood})"
width="128"
height="128"
/>
{/if}
<div class="pet-sprite-name">{manifest.identity.pet_name}</div>
<div class="pet-sprite-moods">
{#each AVAILABLE[species_for(manifest.identity.pet_name)] as m}
<button
class="mood-btn"
class:active={mood === m}
onclick={() => (mood = m as typeof mood)}
onclick={() => (mood = m as CortexMood)}
>{m}</button>
{/each}
</div>
<button
class="secondary renderer-toggle"
onclick={toggle_renderer}
title="Toggle between pixel sprite and Live2D renderer"
>
Switch to {renderer === 'live2d' ? 'pixel' : 'Live2D'}
</button>
</div>
{/if}
@ -109,3 +151,11 @@
<button class="secondary" disabled>Tune (coming soon)</button>
</div>
{/if}
<style>
.renderer-toggle {
margin-top: 6px;
font-size: 12px;
padding: 4px 10px;
}
</style>

View file

@ -7,11 +7,21 @@
const { on_saved }: Props = $props();
const KEY_RENDERER = 'kei-cortex-renderer';
const existing = load_config();
let daemon_url = $state(existing?.daemon_url ?? 'http://localhost:9797');
let token = $state(existing?.token ?? '');
let renderer = $state<'sprite32' | 'live2d'>(load_renderer());
let error = $state<string | null>(null);
function load_renderer(): 'sprite32' | 'live2d' {
if (typeof localStorage === 'undefined') return 'sprite32';
const stored = localStorage.getItem(KEY_RENDERER);
if (stored === 'live2d' || stored === 'sprite32') return stored;
return 'sprite32';
}
function submit(event: Event): void {
event.preventDefault();
if (!token.trim()) {
@ -19,6 +29,9 @@
return;
}
save_config({ daemon_url: daemon_url.trim(), token: token.trim() });
if (typeof localStorage !== 'undefined') {
localStorage.setItem(KEY_RENDERER, renderer);
}
error = null;
on_saved();
window.location.hash = '#/';
@ -44,6 +57,30 @@
<label for="token">Bearer token</label>
<input id="token" type="password" bind:value={token} required />
<fieldset class="renderer-group">
<legend>Pet renderer</legend>
<label class="radio-row">
<input
type="radio"
name="renderer"
value="sprite32"
checked={renderer === 'sprite32'}
onchange={() => (renderer = 'sprite32')}
/>
<span>32px pixel sprite (default, fast)</span>
</label>
<label class="radio-row">
<input
type="radio"
name="renderer"
value="live2d"
checked={renderer === 'live2d'}
onchange={() => (renderer = 'live2d')}
/>
<span>Live2D (animated, breathing + blinking)</span>
</label>
</fieldset>
{#if error}
<div class="error">{error}</div>
{/if}
@ -52,3 +89,29 @@
<button type="submit">Save</button>
</div>
</form>
<style>
.renderer-group {
margin: 14px 0 8px;
padding: 10px 12px;
border: 1px solid var(--border, #e5e5e5);
border-radius: 6px;
}
.renderer-group legend {
padding: 0 6px;
font-size: 13px;
color: var(--muted, #666);
}
.radio-row {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
font-size: 14px;
cursor: pointer;
}
.radio-row input[type='radio'] {
width: auto;
margin: 0;
}
</style>

View file

@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import {
Emotion,
EMOTION_VALUES,
EMOTION_EmotionMotionName_value,
EMOTION_VRMExpressionName_value,
EmotionNeutralMotionName,
moodToEmotion,
} from '../src/lib/live2d/emotions';
describe('emotions enum', () => {
it('contains all 9 AIRI-canonical emotions', () => {
expect(EMOTION_VALUES).toHaveLength(9);
expect(EMOTION_VALUES).toEqual(
expect.arrayContaining([
Emotion.Happy,
Emotion.Sad,
Emotion.Angry,
Emotion.Think,
Emotion.Surprise,
Emotion.Awkward,
Emotion.Question,
Emotion.Curious,
Emotion.Neutral,
]),
);
});
it('maps every emotion to a non-empty motion-name string', () => {
for (const e of EMOTION_VALUES) {
const name = EMOTION_EmotionMotionName_value[e];
expect(typeof name).toBe('string');
expect(name.length).toBeGreaterThan(0);
}
});
it('Neutral maps to Idle (AIRI-canonical group name)', () => {
expect(EMOTION_EmotionMotionName_value[Emotion.Neutral]).toBe('Idle');
expect(EmotionNeutralMotionName).toBe('Idle');
});
it('VRM table leaves non-VRM-mappable emotions undefined', () => {
expect(EMOTION_VRMExpressionName_value[Emotion.Think]).toBeUndefined();
expect(EMOTION_VRMExpressionName_value[Emotion.Awkward]).toBeUndefined();
expect(EMOTION_VRMExpressionName_value[Emotion.Question]).toBeUndefined();
expect(EMOTION_VRMExpressionName_value[Emotion.Neutral]).toBeUndefined();
expect(EMOTION_VRMExpressionName_value[Emotion.Happy]).toBe('happy');
});
it('Curious falls back to surprised in VRM map (AIRI convention)', () => {
expect(EMOTION_VRMExpressionName_value[Emotion.Curious]).toBe('surprised');
});
});
describe('moodToEmotion bridge', () => {
it('maps cortex mood → AIRI emotion deterministically', () => {
expect(moodToEmotion('idle')).toBe(Emotion.Neutral);
expect(moodToEmotion('happy')).toBe(Emotion.Happy);
expect(moodToEmotion('think')).toBe(Emotion.Think);
expect(moodToEmotion('sleep')).toBe(Emotion.Neutral);
});
});

View file

@ -0,0 +1,98 @@
import { describe, it, expect, vi } from 'vitest';
import {
createExpressionController,
type ExpressionEntry,
} from '../src/lib/live2d/expression-controller';
import type { Live2DModelLike } from '../src/lib/live2d/types';
function makeStubModel() {
const paramValues = new Map<string, number>();
const getParameterValueById = vi.fn((id: string) => paramValues.get(id) ?? 0);
const setParameterValueById = vi.fn((id: string, v: number) => {
paramValues.set(id, v);
});
const setExpression = vi.fn(() => true);
const resetExpression = vi.fn();
const model: Live2DModelLike = {
internalModel: {
motionManager: {
startMotion: vi.fn(() => true),
stopAllMotions: vi.fn(),
expressionManager: {
setExpression,
resetExpression,
definitions: [{ Name: 'f00' }, { Name: 'f01' }],
},
},
coreModel: { getParameterValueById, setParameterValueById },
},
};
return { model, setExpression, resetExpression, paramValues, setParameterValueById };
}
describe('expression-controller', () => {
it('lists expression definitions from the manager', () => {
const { model } = makeStubModel();
const c = createExpressionController(model);
expect(c.listAvailable()).toEqual(['f00', 'f01']);
});
it('setActive records the active name and calls setExpression', () => {
const { model, setExpression } = makeStubModel();
const c = createExpressionController(model);
expect(c.setActive('f01')).toBe(true);
expect(c.debugActive()).toBe('f01');
expect(setExpression).toHaveBeenCalledWith('f01');
});
it('clear() invokes resetExpression and drops all overrides', () => {
const { model, resetExpression } = makeStubModel();
const c = createExpressionController(model);
c.setActive('f00');
c.registerOverride(sampleEntry('ParamAngleX', 'Overwrite', 10, 0));
c.clear();
expect(resetExpression).toHaveBeenCalled();
expect(c.debugActive()).toBeNull();
});
it('applyExpressions writes Add-blended value on top of modelDefault', () => {
const { model, setParameterValueById } = makeStubModel();
const c = createExpressionController(model);
c.registerOverride(sampleEntry('ParamMouthForm', 'Add', 0.3, 0));
c.applyExpressions(model.internalModel.coreModel);
// Expected write: default (0) + currentValue (0.3) = 0.3
expect(setParameterValueById).toHaveBeenCalledWith('ParamMouthForm', 0.3);
});
it('applyExpressions resets param on active→inactive transition', () => {
const { model, setParameterValueById } = makeStubModel();
const c = createExpressionController(model);
const entry = sampleEntry('ParamEyeLOpen', 'Multiply', 0.3, 1);
c.registerOverride(entry);
c.applyExpressions(model.internalModel.coreModel); // active
// Now turn the entry into a noop (Multiply * 1 = noop)
entry.currentValue = 1;
c.applyExpressions(model.internalModel.coreModel); // should reset to 1 (default)
const calls = setParameterValueById.mock.calls.filter(
([id]) => id === 'ParamEyeLOpen',
);
// Last call on ParamEyeLOpen must be the reset-to-modelDefault (1).
expect(calls.at(-1)?.[1]).toBe(1);
});
});
function sampleEntry(
id: string,
blend: ExpressionEntry['blend'],
current: number,
def: number,
): ExpressionEntry {
return {
name: id,
parameterId: id,
blend,
currentValue: current,
modelDefault: def,
targetValue: current,
};
}

View file

@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Emotion } from '../src/lib/live2d/emotions';
import {
createEmotionMotionManager,
HARU_EMOTION_BINDINGS,
} from '../src/lib/live2d/motion-manager';
import type { MotionManagerLike, ExpressionManagerLike } from '../src/lib/live2d/types';
function makeStub() {
const calls: Array<{ group: string; index: number; priority: number }> = [];
const expressionCalls: string[] = [];
const expression: ExpressionManagerLike = {
setExpression: vi.fn((name: string) => {
expressionCalls.push(name);
return true;
}),
resetExpression: vi.fn(),
definitions: [
{ Name: 'f00' },
{ Name: 'f01' },
{ Name: 'f02' },
{ Name: 'f03' },
{ Name: 'f04' },
{ Name: 'f05' },
{ Name: 'f06' },
{ Name: 'f07' },
],
};
const mgr: MotionManagerLike & { expressionManager: ExpressionManagerLike } = {
startMotion: vi.fn((group: string, index = 0, priority = 0) => {
calls.push({ group, index, priority });
return true;
}),
stopAllMotions: vi.fn(),
groups: { idle: 'Idle', tap: 'Tap' },
state: { currentGroup: null },
expressionManager: expression,
};
return { mgr, calls, expressionCalls };
}
describe('motion-manager state machine', () => {
beforeEach(() => vi.restoreAllMocks());
it('starts in Neutral with no prior calls', () => {
const { mgr } = makeStub();
const m = createEmotionMotionManager(mgr);
expect(m.getCurrentEmotion()).toBe(Emotion.Neutral);
expect(m.debugState().lastGroup).toBeNull();
});
it('transitions idle → happy → idle, calling startMotion each step', async () => {
const { mgr, calls, expressionCalls } = makeStub();
const m = createEmotionMotionManager(mgr);
await m.setEmotion(Emotion.Happy);
expect(m.getCurrentEmotion()).toBe(Emotion.Happy);
const happyBinding = HARU_EMOTION_BINDINGS[Emotion.Happy];
// Haru has no 'Happy' group so binding falls back to 'Tap'.
expect(calls.at(-1)?.group).toBe(happyBinding.group);
expect(expressionCalls.at(-1)).toBe(happyBinding.expression);
await m.setEmotion(Emotion.Neutral);
expect(m.getCurrentEmotion()).toBe(Emotion.Neutral);
expect(calls.at(-1)?.group).toBe('Idle');
expect(calls).toHaveLength(2);
});
it('replay() re-invokes startMotion with the last emotion', async () => {
const { mgr, calls } = makeStub();
const m = createEmotionMotionManager(mgr);
await m.setEmotion(Emotion.Surprise);
const before = calls.length;
await m.replay();
expect(calls.length).toBe(before + 1);
expect(calls.at(-1)?.group).toBe(calls.at(-2)?.group);
});
it('reset() clears state without touching the manager', () => {
const { mgr } = makeStub();
const m = createEmotionMotionManager(mgr);
m.reset();
expect(m.getCurrentEmotion()).toBe(Emotion.Neutral);
expect(mgr.startMotion).not.toHaveBeenCalled();
});
it('uses named group when model exposes matching group name', async () => {
const { mgr, calls } = makeStub();
// Pretend this model has a dedicated "Happy" group.
mgr.groups = { idle: 'Idle', tap: 'Happy' };
const m = createEmotionMotionManager(mgr);
await m.setEmotion(Emotion.Happy);
expect(calls.at(-1)?.group).toBe('Happy');
});
it('falls back to Idle if startMotion throws', async () => {
const calls: string[] = [];
const mgr: MotionManagerLike = {
startMotion: vi.fn((group: string) => {
calls.push(group);
if (group !== 'Idle') throw new Error('missing group');
return true;
}),
stopAllMotions: vi.fn(),
groups: { idle: 'Idle' },
};
const m = createEmotionMotionManager(mgr, { preferNamedGroups: false });
await m.setEmotion(Emotion.Angry);
expect(calls).toContain('Idle');
});
});

View file

@ -0,0 +1,93 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/svelte';
import Live2DPet from '../src/components/Live2DPet.svelte';
import { Emotion } from '../src/lib/live2d/emotions';
// Stub pixi-live2d-display/cubism4: Live2DModel.from() returns a fake model
// with the structural shape the motion manager needs.
vi.mock('pixi-live2d-display/cubism4', () => {
const fakeModel = {
x: 0,
y: 0,
width: 100,
height: 100,
scale: { set: vi.fn() },
internalModel: {
motionManager: {
startMotion: vi.fn(() => true),
stopAllMotions: vi.fn(),
groups: { idle: 'Idle', tap: 'Tap' },
state: { currentGroup: null },
expressionManager: {
setExpression: vi.fn(() => true),
resetExpression: vi.fn(),
definitions: [],
},
},
coreModel: {
getParameterValueById: vi.fn(() => 0),
setParameterValueById: vi.fn(),
},
},
destroy: vi.fn(),
};
return {
Live2DModel: {
from: vi.fn(async () => fakeModel),
},
};
});
// Stub pixi.js Application — no WebGL in jsdom.
vi.mock('pixi.js', () => ({
Application: vi.fn().mockImplementation(() => ({
stage: { addChild: vi.fn() },
destroy: vi.fn(),
})),
}));
describe('Live2DPet component', () => {
afterEach(() => cleanup());
it('mounts without throwing and renders a canvas element', () => {
const { container } = render(Live2DPet, {
props: {
modelPath: './live2d-models/haru/haru_greeter_t03.model3.json',
mood: Emotion.Neutral,
width: 128,
height: 128,
},
});
const canvas = container.querySelector('canvas.live2d-canvas');
expect(canvas).not.toBeNull();
// jsdom exposes no WebGL → component sets load_error and renders hint.
// The canvas still mounts either way; that's the contract we need.
const width = canvas?.getAttribute('width');
expect(width).toBe('128');
});
it('includes an accessible aria-label on the canvas', () => {
const { container } = render(Live2DPet, {
props: {
modelPath: './live2d-models/haru/haru_greeter_t03.model3.json',
mood: Emotion.Happy,
},
});
const canvas = container.querySelector('canvas') as HTMLCanvasElement | null;
expect(canvas?.getAttribute('aria-label')).toContain('happy');
});
it('renders a graceful error hint when WebGL is unavailable (jsdom path)', async () => {
const { container, findByRole } = render(Live2DPet, {
props: {
modelPath: './live2d-models/haru/haru_greeter_t03.model3.json',
mood: Emotion.Neutral,
width: 256,
height: 256,
},
});
const hint = await findByRole('status');
expect(hint.textContent).toMatch(/WebGL/i);
expect(container.querySelector('canvas')).not.toBeNull();
});
});

View file

@ -2,13 +2,19 @@ import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
plugins: [svelte({ hot: false })],
base: './',
build: {
outDir: 'dist',
emptyOutDir: true,
sourcemap: true,
},
// vitest picks the browser-side Svelte entry, matching how @testing-library/svelte
// mounts components in jsdom. Without this Svelte 5 resolves to the SSR build
// and `mount()` throws `lifecycle_function_unavailable`.
resolve: {
conditions: ['browser'],
},
test: {
environment: 'jsdom',
environmentOptions: {
@ -18,5 +24,10 @@ export default defineConfig({
},
globals: false,
include: ['tests/**/*.test.ts'],
server: {
deps: {
inline: ['@testing-library/svelte'],
},
},
},
});