From e73be328faa26f58ca6d41322851e4342d97010a Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Thu, 21 May 2026 19:17:08 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Live2D=20?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E5=AD=90=E7=B3=BB=E7=BB=9F=E4=B8=8E?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交包含以下内容: 1. 归档并同步 OpenSpec 变更至主规范目录,清理重复及旧归档 2. 实现行为状态机(Behavior FSM)与配置文件系统 3. 实现情感时间轴(Emotion Timeline)与过渡调度器 4. 实现运动层系统(Motion Layer System)与跨层混合 5. 实现程序化动画系统(Procedural Animation),包括呼吸、眨眼、眼动追踪 6. 实现语义参数层(Semantic Parameter Layer)与能力检测 7. 实现运行时滤镜管线(Filter Pipeline)与内置效果 8. 实现运行时控制器(Runtime Controller)与冲突解决 9. 添加 Live2D 开发工具面板(DevTools),支持 FSM、情感、运动层、滤镜、语义参数等调试 10. 初始化 AI Live2D 运行时钩子规范 Signed-off-by: LIlGG --- explore.md | 734 ++++++++ .../.openspec.yaml | 2 +- .../changes/ai-live2d-runtime-hooks/design.md | 68 + .../ai-live2d-runtime-hooks/proposal.md | 32 + .../specs/ai-chat/spec.md | 24 + .../specs/ai-command-bus/spec.md | 35 + .../specs/ai-stream-parser/spec.md | 42 + .../specs/emotion-timeline/spec.md | 39 + .../specs/text-lip-sync/spec.md | 29 + .../changes/ai-live2d-runtime-hooks/tasks.md | 50 + .../design.md | 29 - .../proposal.md | 23 - .../live2d-widget-behavior-parity/spec.md | 14 - .../tasks.md | 4 - .../design.md | 76 - .../proposal.md | 25 - .../multi-cubism-live2d-rendering/spec.md | 34 - .../tasks.md | 16 - .../design.md | 46 - .../proposal.md | 23 - .../live2d-widget-behavior-parity/spec.md | 21 - .../tasks.md | 13 - .../.openspec.yaml | 2 +- .../archive/2026-05-21-behavior-fsm/design.md | 140 ++ .../2026-05-21-behavior-fsm/proposal.md | 32 + .../specs/behavior-fsm/spec.md | 22 + .../specs/behavior-profile/spec.md | 29 + .../specs/state-transition-guards/spec.md | 20 + .../archive/2026-05-21-behavior-fsm/tasks.md | 49 + .../.openspec.yaml | 2 +- .../2026-05-21-emotion-timeline/design.md | 121 ++ .../2026-05-21-emotion-timeline/proposal.md | 33 + .../specs/emotion-registry/spec.md | 23 + .../specs/emotion-timeline/spec.md | 28 + .../specs/transition-scheduler/spec.md | 29 + .../2026-05-21-emotion-timeline/tasks.md | 49 + .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../halo-plugin-frontend-integration/spec.md | 0 .../specs/live2d-custom-tool-actions/spec.md | 0 .../live2d-public-runtime-config/spec.md | 0 .../multi-cubism-live2d-rendering/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 2 + .../2026-05-21-motion-layer-system/design.md | 73 + .../proposal.md | 33 + .../specs/cross-layer-blending/spec.md | 24 + .../specs/fade-transitions/spec.md | 30 + .../specs/motion-layer-system/spec.md | 27 + .../specs/motion-track/spec.md | 33 + .../2026-05-21-motion-layer-system/tasks.md | 49 + .../.openspec.yaml | 2 + .../design.md | 73 + .../proposal.md | 30 + .../specs/blink-module/spec.md | 30 + .../specs/breathing-module/spec.md | 23 + .../specs/eye-tracking-module/spec.md | 39 + .../specs/procedural-animation-system/spec.md | 41 + .../tasks.md | 44 + .../.openspec.yaml | 2 + .../2026-05-21-runtime-devtools/design.md | 94 + .../2026-05-21-runtime-devtools/proposal.md | 35 + .../specs/runtime-conflict-resolution/spec.md | 33 + .../specs/runtime-controller/spec.md | 31 + .../specs/runtime-devtools/spec.md | 67 + .../2026-05-21-runtime-devtools/tasks.md | 99 ++ .../.openspec.yaml | 2 + .../design.md | 68 + .../proposal.md | 28 + .../specs/model-part-filtering/spec.md | 25 + .../specs/runtime-filter-pipeline/spec.md | 44 + .../tasks.md | 41 + .../.openspec.yaml | 2 + .../design.md | 71 + .../proposal.md | 28 + .../parameter-capability-detection/spec.md | 33 + .../specs/semantic-parameter-layer/spec.md | 54 + .../tasks.md | 36 + openspec/specs/behavior-fsm/spec.md | 25 + openspec/specs/behavior-profile/spec.md | 33 + openspec/specs/blink-module/spec.md | 34 + openspec/specs/breathing-module/spec.md | 27 + openspec/specs/cross-layer-blending/spec.md | 28 + openspec/specs/emotion-registry/spec.md | 27 + openspec/specs/emotion-timeline/spec.md | 31 + openspec/specs/eye-tracking-module/spec.md | 43 + openspec/specs/fade-transitions/spec.md | 34 + .../halo-plugin-frontend-integration/spec.md | 44 + .../specs/live2d-custom-tool-actions/spec.md | 44 + .../live2d-public-runtime-config/spec.md | 31 + .../live2d-widget-behavior-parity/spec.md | 21 - openspec/specs/model-part-filtering/spec.md | 29 + openspec/specs/motion-layer-system/spec.md | 30 + openspec/specs/motion-track/spec.md | 37 + .../multi-cubism-live2d-rendering/spec.md | 6 +- .../parameter-capability-detection/spec.md | 37 + .../specs/procedural-animation-system/spec.md | 45 + .../specs/runtime-conflict-resolution/spec.md | 37 + openspec/specs/runtime-controller/spec.md | 35 + openspec/specs/runtime-devtools/spec.md | 71 + .../specs/runtime-filter-pipeline/spec.md | 48 + .../specs/semantic-parameter-layer/spec.md | 58 + .../specs/state-transition-guards/spec.md | 24 + openspec/specs/transition-scheduler/spec.md | 33 + .../changes/runtime-devtools/.openspec.yaml | 2 + .../changes/runtime-devtools/design.md | 94 + .../changes/runtime-devtools/proposal.md | 35 + .../specs/runtime-conflict-resolution/spec.md | 33 + .../specs/runtime-controller/spec.md | 31 + .../specs/runtime-devtools/spec.md | 67 + .../changes/runtime-devtools/tasks.md | 99 ++ packages/live2d/package.json | 8 +- packages/live2d/src/Demo.tsx | 21 + .../Live2dDevTools/Live2dDevTools.ts | 1573 +++++++++++++++++ .../__tests__/Live2dDevTools.test.ts | 117 ++ .../src/components/Live2dDevTools/index.ts | 3 + packages/live2d/src/config/default-config.ts | 105 +- packages/live2d/src/context/config-context.ts | 30 + packages/live2d/src/env.d.ts | 14 + packages/live2d/src/live2d/model.ts | 142 +- .../behavior/__tests__/behavior-fsm.test.ts | 562 ++++++ .../src/runtime/behavior/built-in-states.ts | 209 +++ packages/live2d/src/runtime/behavior/fsm.ts | 293 +++ packages/live2d/src/runtime/behavior/index.ts | 12 + .../live2d/src/runtime/behavior/profile.ts | 87 + packages/live2d/src/runtime/behavior/types.ts | 50 + .../controller/__tests__/controller.test.ts | 151 ++ .../controller/__tests__/coordinator.test.ts | 187 ++ .../src/runtime/controller/controller.ts | 323 ++++ .../src/runtime/controller/coordinator.ts | 128 ++ .../live2d/src/runtime/controller/index.ts | 10 + .../live2d/src/runtime/controller/types.ts | 110 ++ .../__tests__/emotion-timeline.test.ts | 329 ++++ packages/live2d/src/runtime/emotion/index.ts | 9 + .../live2d/src/runtime/emotion/registry.ts | 133 ++ .../live2d/src/runtime/emotion/timeline.ts | 276 +++ packages/live2d/src/runtime/emotion/types.ts | 45 + .../filters/__tests__/filter-pipeline.test.ts | 135 ++ .../live2d/src/runtime/filters/effects.ts | 226 +++ .../src/runtime/filters/filter-pipeline.ts | 282 +++ packages/live2d/src/runtime/filters/index.ts | 19 + packages/live2d/src/runtime/filters/types.ts | 51 + .../__tests__/motion-layer-system.test.ts | 214 +++ .../src/runtime/motion/fade-envelope.ts | 122 ++ packages/live2d/src/runtime/motion/index.ts | 14 + .../src/runtime/motion/motion-layer-system.ts | 231 +++ .../live2d/src/runtime/motion/motion-track.ts | 149 ++ packages/live2d/src/runtime/motion/types.ts | 52 + .../procedural-animation-system.test.ts | 187 ++ .../live2d/src/runtime/procedural/animator.ts | 70 + .../live2d/src/runtime/procedural/easing.ts | 38 + .../live2d/src/runtime/procedural/index.ts | 13 + .../live2d/src/runtime/procedural/modules.ts | 187 ++ .../src/runtime/procedural/parameter-set.ts | 51 + .../procedural/procedural-animation-system.ts | 154 ++ .../live2d/src/runtime/procedural/types.ts | 45 + .../semantic-parameter-layer.test.ts | 329 ++++ .../src/runtime/semantic/default-mappings.ts | 145 ++ packages/live2d/src/runtime/semantic/index.ts | 12 + .../runtime/semantic/parameter-accessor.ts | 114 ++ .../semantic/semantic-parameter-layer.ts | 245 +++ packages/live2d/src/runtime/semantic/types.ts | 35 + packages/live2d/src/utils/unoMixin.ts | 11 +- packages/live2d/vite.config.ts | 4 + pnpm-lock.yaml | 541 ++++++ 166 files changed, 12790 insertions(+), 390 deletions(-) create mode 100644 explore.md rename openspec/changes/{archive/2026-05-13-complete-live2d-modernization-parity => ai-live2d-runtime-hooks}/.openspec.yaml (50%) create mode 100644 openspec/changes/ai-live2d-runtime-hooks/design.md create mode 100644 openspec/changes/ai-live2d-runtime-hooks/proposal.md create mode 100644 openspec/changes/ai-live2d-runtime-hooks/specs/ai-chat/spec.md create mode 100644 openspec/changes/ai-live2d-runtime-hooks/specs/ai-command-bus/spec.md create mode 100644 openspec/changes/ai-live2d-runtime-hooks/specs/ai-stream-parser/spec.md create mode 100644 openspec/changes/ai-live2d-runtime-hooks/specs/emotion-timeline/spec.md create mode 100644 openspec/changes/ai-live2d-runtime-hooks/specs/text-lip-sync/spec.md create mode 100644 openspec/changes/ai-live2d-runtime-hooks/tasks.md delete mode 100644 openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/design.md delete mode 100644 openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/proposal.md delete mode 100644 openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/specs/live2d-widget-behavior-parity/spec.md delete mode 100644 openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/tasks.md delete mode 100644 openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/design.md delete mode 100644 openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/proposal.md delete mode 100644 openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/specs/multi-cubism-live2d-rendering/spec.md delete mode 100644 openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/tasks.md delete mode 100644 openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/design.md delete mode 100644 openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/proposal.md delete mode 100644 openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/specs/live2d-widget-behavior-parity/spec.md delete mode 100644 openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/tasks.md rename openspec/changes/archive/{2026-05-13-replace-live2d-renderer-engine => 2026-05-21-behavior-fsm}/.openspec.yaml (50%) create mode 100644 openspec/changes/archive/2026-05-21-behavior-fsm/design.md create mode 100644 openspec/changes/archive/2026-05-21-behavior-fsm/proposal.md create mode 100644 openspec/changes/archive/2026-05-21-behavior-fsm/specs/behavior-fsm/spec.md create mode 100644 openspec/changes/archive/2026-05-21-behavior-fsm/specs/behavior-profile/spec.md create mode 100644 openspec/changes/archive/2026-05-21-behavior-fsm/specs/state-transition-guards/spec.md create mode 100644 openspec/changes/archive/2026-05-21-behavior-fsm/tasks.md rename openspec/changes/{integrate-modern-live2d-frontend => archive/2026-05-21-emotion-timeline}/.openspec.yaml (50%) create mode 100644 openspec/changes/archive/2026-05-21-emotion-timeline/design.md create mode 100644 openspec/changes/archive/2026-05-21-emotion-timeline/proposal.md create mode 100644 openspec/changes/archive/2026-05-21-emotion-timeline/specs/emotion-registry/spec.md create mode 100644 openspec/changes/archive/2026-05-21-emotion-timeline/specs/emotion-timeline/spec.md create mode 100644 openspec/changes/archive/2026-05-21-emotion-timeline/specs/transition-scheduler/spec.md create mode 100644 openspec/changes/archive/2026-05-21-emotion-timeline/tasks.md rename openspec/changes/archive/{2026-05-14-complete-live2d-modernization-parity => 2026-05-21-integrate-modern-live2d-frontend}/.openspec.yaml (100%) rename openspec/changes/{integrate-modern-live2d-frontend => archive/2026-05-21-integrate-modern-live2d-frontend}/design.md (100%) rename openspec/changes/{integrate-modern-live2d-frontend => archive/2026-05-21-integrate-modern-live2d-frontend}/proposal.md (100%) rename openspec/changes/{integrate-modern-live2d-frontend => archive/2026-05-21-integrate-modern-live2d-frontend}/specs/halo-plugin-frontend-integration/spec.md (100%) rename openspec/changes/{integrate-modern-live2d-frontend => archive/2026-05-21-integrate-modern-live2d-frontend}/specs/live2d-custom-tool-actions/spec.md (100%) rename openspec/changes/{integrate-modern-live2d-frontend => archive/2026-05-21-integrate-modern-live2d-frontend}/specs/live2d-public-runtime-config/spec.md (100%) rename openspec/changes/{integrate-modern-live2d-frontend => archive/2026-05-21-integrate-modern-live2d-frontend}/specs/multi-cubism-live2d-rendering/spec.md (100%) rename openspec/changes/{integrate-modern-live2d-frontend => archive/2026-05-21-integrate-modern-live2d-frontend}/tasks.md (100%) create mode 100644 openspec/changes/archive/2026-05-21-motion-layer-system/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-21-motion-layer-system/design.md create mode 100644 openspec/changes/archive/2026-05-21-motion-layer-system/proposal.md create mode 100644 openspec/changes/archive/2026-05-21-motion-layer-system/specs/cross-layer-blending/spec.md create mode 100644 openspec/changes/archive/2026-05-21-motion-layer-system/specs/fade-transitions/spec.md create mode 100644 openspec/changes/archive/2026-05-21-motion-layer-system/specs/motion-layer-system/spec.md create mode 100644 openspec/changes/archive/2026-05-21-motion-layer-system/specs/motion-track/spec.md create mode 100644 openspec/changes/archive/2026-05-21-motion-layer-system/tasks.md create mode 100644 openspec/changes/archive/2026-05-21-procedural-animation-system/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-21-procedural-animation-system/design.md create mode 100644 openspec/changes/archive/2026-05-21-procedural-animation-system/proposal.md create mode 100644 openspec/changes/archive/2026-05-21-procedural-animation-system/specs/blink-module/spec.md create mode 100644 openspec/changes/archive/2026-05-21-procedural-animation-system/specs/breathing-module/spec.md create mode 100644 openspec/changes/archive/2026-05-21-procedural-animation-system/specs/eye-tracking-module/spec.md create mode 100644 openspec/changes/archive/2026-05-21-procedural-animation-system/specs/procedural-animation-system/spec.md create mode 100644 openspec/changes/archive/2026-05-21-procedural-animation-system/tasks.md create mode 100644 openspec/changes/archive/2026-05-21-runtime-devtools/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-21-runtime-devtools/design.md create mode 100644 openspec/changes/archive/2026-05-21-runtime-devtools/proposal.md create mode 100644 openspec/changes/archive/2026-05-21-runtime-devtools/specs/runtime-conflict-resolution/spec.md create mode 100644 openspec/changes/archive/2026-05-21-runtime-devtools/specs/runtime-controller/spec.md create mode 100644 openspec/changes/archive/2026-05-21-runtime-devtools/specs/runtime-devtools/spec.md create mode 100644 openspec/changes/archive/2026-05-21-runtime-devtools/tasks.md create mode 100644 openspec/changes/archive/2026-05-21-runtime-filter-pipeline/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-21-runtime-filter-pipeline/design.md create mode 100644 openspec/changes/archive/2026-05-21-runtime-filter-pipeline/proposal.md create mode 100644 openspec/changes/archive/2026-05-21-runtime-filter-pipeline/specs/model-part-filtering/spec.md create mode 100644 openspec/changes/archive/2026-05-21-runtime-filter-pipeline/specs/runtime-filter-pipeline/spec.md create mode 100644 openspec/changes/archive/2026-05-21-runtime-filter-pipeline/tasks.md create mode 100644 openspec/changes/archive/2026-05-21-semantic-parameter-layer/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-21-semantic-parameter-layer/design.md create mode 100644 openspec/changes/archive/2026-05-21-semantic-parameter-layer/proposal.md create mode 100644 openspec/changes/archive/2026-05-21-semantic-parameter-layer/specs/parameter-capability-detection/spec.md create mode 100644 openspec/changes/archive/2026-05-21-semantic-parameter-layer/specs/semantic-parameter-layer/spec.md create mode 100644 openspec/changes/archive/2026-05-21-semantic-parameter-layer/tasks.md create mode 100644 openspec/specs/behavior-fsm/spec.md create mode 100644 openspec/specs/behavior-profile/spec.md create mode 100644 openspec/specs/blink-module/spec.md create mode 100644 openspec/specs/breathing-module/spec.md create mode 100644 openspec/specs/cross-layer-blending/spec.md create mode 100644 openspec/specs/emotion-registry/spec.md create mode 100644 openspec/specs/emotion-timeline/spec.md create mode 100644 openspec/specs/eye-tracking-module/spec.md create mode 100644 openspec/specs/fade-transitions/spec.md create mode 100644 openspec/specs/halo-plugin-frontend-integration/spec.md create mode 100644 openspec/specs/live2d-custom-tool-actions/spec.md create mode 100644 openspec/specs/live2d-public-runtime-config/spec.md delete mode 100644 openspec/specs/live2d-widget-behavior-parity/spec.md create mode 100644 openspec/specs/model-part-filtering/spec.md create mode 100644 openspec/specs/motion-layer-system/spec.md create mode 100644 openspec/specs/motion-track/spec.md create mode 100644 openspec/specs/parameter-capability-detection/spec.md create mode 100644 openspec/specs/procedural-animation-system/spec.md create mode 100644 openspec/specs/runtime-conflict-resolution/spec.md create mode 100644 openspec/specs/runtime-controller/spec.md create mode 100644 openspec/specs/runtime-devtools/spec.md create mode 100644 openspec/specs/runtime-filter-pipeline/spec.md create mode 100644 openspec/specs/semantic-parameter-layer/spec.md create mode 100644 openspec/specs/state-transition-guards/spec.md create mode 100644 openspec/specs/transition-scheduler/spec.md create mode 100644 packages/live2d/openspec/changes/runtime-devtools/.openspec.yaml create mode 100644 packages/live2d/openspec/changes/runtime-devtools/design.md create mode 100644 packages/live2d/openspec/changes/runtime-devtools/proposal.md create mode 100644 packages/live2d/openspec/changes/runtime-devtools/specs/runtime-conflict-resolution/spec.md create mode 100644 packages/live2d/openspec/changes/runtime-devtools/specs/runtime-controller/spec.md create mode 100644 packages/live2d/openspec/changes/runtime-devtools/specs/runtime-devtools/spec.md create mode 100644 packages/live2d/openspec/changes/runtime-devtools/tasks.md create mode 100644 packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts create mode 100644 packages/live2d/src/components/Live2dDevTools/__tests__/Live2dDevTools.test.ts create mode 100644 packages/live2d/src/components/Live2dDevTools/index.ts create mode 100644 packages/live2d/src/runtime/behavior/__tests__/behavior-fsm.test.ts create mode 100644 packages/live2d/src/runtime/behavior/built-in-states.ts create mode 100644 packages/live2d/src/runtime/behavior/fsm.ts create mode 100644 packages/live2d/src/runtime/behavior/index.ts create mode 100644 packages/live2d/src/runtime/behavior/profile.ts create mode 100644 packages/live2d/src/runtime/behavior/types.ts create mode 100644 packages/live2d/src/runtime/controller/__tests__/controller.test.ts create mode 100644 packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts create mode 100644 packages/live2d/src/runtime/controller/controller.ts create mode 100644 packages/live2d/src/runtime/controller/coordinator.ts create mode 100644 packages/live2d/src/runtime/controller/index.ts create mode 100644 packages/live2d/src/runtime/controller/types.ts create mode 100644 packages/live2d/src/runtime/emotion/__tests__/emotion-timeline.test.ts create mode 100644 packages/live2d/src/runtime/emotion/index.ts create mode 100644 packages/live2d/src/runtime/emotion/registry.ts create mode 100644 packages/live2d/src/runtime/emotion/timeline.ts create mode 100644 packages/live2d/src/runtime/emotion/types.ts create mode 100644 packages/live2d/src/runtime/filters/__tests__/filter-pipeline.test.ts create mode 100644 packages/live2d/src/runtime/filters/effects.ts create mode 100644 packages/live2d/src/runtime/filters/filter-pipeline.ts create mode 100644 packages/live2d/src/runtime/filters/index.ts create mode 100644 packages/live2d/src/runtime/filters/types.ts create mode 100644 packages/live2d/src/runtime/motion/__tests__/motion-layer-system.test.ts create mode 100644 packages/live2d/src/runtime/motion/fade-envelope.ts create mode 100644 packages/live2d/src/runtime/motion/index.ts create mode 100644 packages/live2d/src/runtime/motion/motion-layer-system.ts create mode 100644 packages/live2d/src/runtime/motion/motion-track.ts create mode 100644 packages/live2d/src/runtime/motion/types.ts create mode 100644 packages/live2d/src/runtime/procedural/__tests__/procedural-animation-system.test.ts create mode 100644 packages/live2d/src/runtime/procedural/animator.ts create mode 100644 packages/live2d/src/runtime/procedural/easing.ts create mode 100644 packages/live2d/src/runtime/procedural/index.ts create mode 100644 packages/live2d/src/runtime/procedural/modules.ts create mode 100644 packages/live2d/src/runtime/procedural/parameter-set.ts create mode 100644 packages/live2d/src/runtime/procedural/procedural-animation-system.ts create mode 100644 packages/live2d/src/runtime/procedural/types.ts create mode 100644 packages/live2d/src/runtime/semantic/__tests__/semantic-parameter-layer.test.ts create mode 100644 packages/live2d/src/runtime/semantic/default-mappings.ts create mode 100644 packages/live2d/src/runtime/semantic/index.ts create mode 100644 packages/live2d/src/runtime/semantic/parameter-accessor.ts create mode 100644 packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts create mode 100644 packages/live2d/src/runtime/semantic/types.ts diff --git a/explore.md b/explore.md new file mode 100644 index 0000000..0bf7339 --- /dev/null +++ b/explore.md @@ -0,0 +1,734 @@ +# plugin-live2d Runtime Evolution Roadmap (2026) + +## Project Positioning + +`plugin-live2d` should not evolve into a model-specific framework. + +Instead, it should become: + +```txt +Universal Live2D Runtime Engine +``` + +Goals: + +* Compatible with Cubism 2.4 / 4 / 5 +* Runtime-first architecture +* Model-agnostic +* Render-pipeline extensible +* AI companion ready +* Web renderer oriented +* Procedural animation capable + +--- + +# Core Philosophy + +Avoid: + +```txt +if Cubism5 -> enable feature +``` + +Prefer: + +```txt +capability detection ++ graceful fallback +``` + +The runtime should provide: + +* unified APIs +* semantic abstractions +* rendering enhancements +* procedural behaviors + +instead of depending on model-specific implementations. + +--- + +# Recommended Architecture + +```txt +packages/ + adapter/ + cubism2/ + cubism4/ + cubism5/ + + runtime/ + motion/ + behavior/ + expression/ + scheduler/ + semantic/ + + render/ + pixi/ + filter/ + pipeline/ + offscreen/ + + procedural/ + eye-tracking/ + breathing/ + idle/ + spring/ + + ai/ + emotion/ + lipsync/ + hooks/ + + utils/ +``` + +--- + +# Priority 1 Features + +These features provide the highest long-term value. + +--- + +# 1. Semantic Parameter Layer + +## Problem + +Different models use different parameter names. + +Examples: + +```txt +PARAM_MOUTH_OPEN_Y +PARAM_MOUTH_A +CUSTOM_MOUTH +``` + +Hardcoded parameter names make runtimes fragile. + +--- + +## Goal + +Provide semantic parameter APIs. + +Example: + +```ts +runtime.setSemantic("mouthOpen", value) +``` + +Internal mapping: + +```ts +{ + mouthOpen: [ + "PARAM_MOUTH_OPEN_Y", + "PARAM_MOUTH_A", + "CUSTOM_MOUTH" + ] +} +``` + +--- + +## Benefits + +* Cubism version agnostic +* Supports non-standard models +* Supports VTubeStudio style models +* Easier AI integration +* Easier procedural animation + +--- + +## Suggested APIs + +```ts +runtime.getSemantic(name) +runtime.setSemantic(name, value) +runtime.registerSemantic(name, mappings) +runtime.hasSemantic(name) +``` + +--- + +# 2. Capability Detection System + +## Problem + +Different Cubism versions support different rendering features. + +Runtime should not hardcode version checks. + +--- + +## Goal + +Use runtime capability flags. + +Example: + +```ts +runtime.capabilities = { + mask: true, + offscreen: false, + blendMode: true, + motionBlend: true, +} +``` + +--- + +## Benefits + +* Better compatibility +* Cleaner architecture +* Easier future upgrades +* Graceful fallback support + +--- + +## Suggested APIs + +```ts +runtime.hasCapability(name) +runtime.requireCapability(name) +``` + +--- + +# 3. Motion Layer System + +## Problem + +Traditional Live2D runtimes usually allow only one active motion. + +Modern AI companion systems need layered motions. + +--- + +## Goal + +Support parallel motion layers. + +Example: + +```txt +Idle Layer +Talking Layer +Expression Layer +Gesture Layer +Physics Layer +``` + +--- + +## Suggested APIs + +```ts +runtime.motion.play({ + layer: "talk", + priority: 10, + blend: "override", +}) +``` + +--- + +## Required Features + +### Motion blending + +```txt +motion A + motion B +``` + +### Layer priority + +```txt +higher layer overrides lower layer +``` + +### Interrupt rules + +```txt +interruptible: true/false +``` + +### Fade transitions + +```txt +fadeIn +fadeOut +crossfade +``` + +--- + +# 4. Procedural Animation System + +## Problem + +Many models have few or no motion files. + +Runtime should generate behaviors procedurally. + +--- + +## Goal + +Support runtime-generated animation. + +--- + +## Suggested APIs + +```ts +runtime.animate({ + target: "PARAM_ANGLE_X", + value: 30, + duration: 1000, + easing: spring, +}) +``` + +--- + +## Suggested Procedural Modules + +### Breathing + +```txt +sin wave chest motion +``` + +### Eye drift + +```txt +small random eye movement +``` + +### Head follow + +```txt +cursor tracking +``` + +### Spring physics + +```txt +secondary motion +``` + +### Idle variation + +```txt +random micro animations +``` + +--- + +# 5. Runtime Filter Pipeline + +## Problem + +Traditional Live2D runtimes have poor rendering extensibility. + +Pixi RenderPipe enables modern rendering effects. + +--- + +## Goal + +Allow runtime-level render effects. + +--- + +## Suggested APIs + +```ts +runtime.filters.add(part, filter) +runtime.filters.remove(id) +``` + +--- + +## Example Effects + +### Bloom + +```txt +eye glow +magic effects +``` + +### Blur + +```txt +soft blush +dreamy effect +``` + +### RGB Shift + +```txt +glitch effect +``` + +### Color Grading + +```txt +emotion lighting +``` + +--- + +## Important + +Effects should NOT depend on model author support. + +All effects should be runtime injected. + +--- + +# Priority 2 Features + +--- + +# 6. Behavior FSM + +## Goal + +Build a high-level behavior state machine. + +Example: + +```txt +idle +thinking +talking +embarrassed +angry +sleepy +``` + +--- + +## Suggested Architecture + +```ts +BehaviorState +BehaviorTransition +BehaviorScheduler +``` + +--- + +## Benefits + +* AI companion ready +* Better interaction quality +* Emotion consistency + +--- + +# 7. Emotion Timeline + +## Problem + +Expressions switching instantly feels unnatural. + +--- + +## Goal + +Support emotion interpolation. + +Example: + +```txt +happy -> shy -> sad +``` + +instead of: + +```txt +setExpression("happy") +``` + +--- + +## Suggested APIs + +```ts +runtime.emotion.transition({ + from: "happy", + to: "sad", + duration: 2000, +}) +``` + +--- + +# 8. AI Runtime Hooks + +## Goal + +Allow AI systems to control runtime behavior. + +--- + +## Suggested APIs + +```ts +runtime.ai.send({ + emotion, + energy, + speaking, + interruptible, +}) +``` + +--- + +## Future AI Integrations + +### LLM + +```txt +emotion extraction +``` + +### TTS + +```txt +speech timing +``` + +### VAD + +```txt +talk state detection +``` + +--- + +# Priority 3 Features + +--- + +# 9. Offscreen Rendering Emulation + +## Problem + +Cubism 2.4 does not support modern offscreen rendering. + +--- + +## Goal + +Use Pixi RenderTexture to emulate newer features. + +--- + +## Example + +```txt +part +-> render texture +-> blend +-> composite +``` + +--- + +## Possible Effects + +### Screen Blend + +### Overlay Blend + +### Glow Composition + +### Post Processing + +--- + +# 10. WebGPU Preparation + +## Goal + +Avoid WebGL-only assumptions. + +--- + +## Recommendations + +Avoid: + +```txt +raw WebGL state assumptions +``` + +Prefer: + +```txt +renderer abstraction +``` + +--- + +# 11. Runtime Graph System + +## Goal + +Support node-based runtime behavior. + +--- + +## Example + +```txt +emotion +-> expression +-> motion +-> filters +-> lipsync +``` + +--- + +# Important Design Principles + +--- + +# 1. Never Depend On Specific Models + +Avoid: + +```txt +if model has param X +``` + +Prefer: + +```txt +semantic lookup +``` + +--- + +# 2. Runtime Over Model + +Enhance behaviors through runtime systems instead of requiring model modifications. + +--- + +# 3. Graceful Fallback + +Every advanced feature should degrade safely. + +--- + +# 4. Render Pipeline Is A Core Strength + +The biggest advantage of this project is: + +```txt +Pixi Render Integration +``` + +not basic motion playback. + +--- + +# 5. Cubism 2.4 Is An Advantage + +Do not treat Cubism 2 support as technical debt. + +Treat it as: + +```txt +compatibility moat +``` + +Many runtimes are abandoning old models. + +Universal compatibility is valuable. + +--- + +# Suggested Long-Term Vision + +```txt +plugin-live2d += +Universal Live2D Runtime Engine +``` + +instead of: + +```txt +Simple Web Live2D Widget +``` + +--- + +# Potential Future Directions + +## AI Companion Runtime + +```txt +LLM +-> emotion +-> motion +-> expression +-> rendering +``` + +--- + +## VTuber Runtime + +```txt +camera tracking +microphone +emotion +realtime rendering +``` + +--- + +## Web Desktop Mascot + +```txt +transparent overlay +multi-window +OS integration +``` + +--- + +# Most Valuable Future Investments + +Highest long-term value: + +1. Semantic Parameter Layer +2. Motion Layer System +3. Procedural Animation +4. Runtime Filter Pipeline +5. Behavior FSM + +These features provide: + +* cross-version compatibility +* future AI compatibility +* modern rendering extensibility +* runtime differentiation +* long-term maintainability + +``` +``` diff --git a/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/.openspec.yaml b/openspec/changes/ai-live2d-runtime-hooks/.openspec.yaml similarity index 50% rename from openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/.openspec.yaml rename to openspec/changes/ai-live2d-runtime-hooks/.openspec.yaml index 93831bd..8b76914 100644 --- a/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/.openspec.yaml +++ b/openspec/changes/ai-live2d-runtime-hooks/.openspec.yaml @@ -1,2 +1,2 @@ schema: spec-driven -created: 2026-05-13 +created: 2026-05-20 diff --git a/openspec/changes/ai-live2d-runtime-hooks/design.md b/openspec/changes/ai-live2d-runtime-hooks/design.md new file mode 100644 index 0000000..9366f1d --- /dev/null +++ b/openspec/changes/ai-live2d-runtime-hooks/design.md @@ -0,0 +1,68 @@ +## Context + +plugin-live2d 的前端已经通过 `untitled-pixi-live2d-engine` 实现了 PixiJS v8 驱动的 Live2D 渲染,支持 Cubism 2~5 全版本模型。AI 聊天功能也已接入:前端 `ChatApi` 通过 SSE 接收后端流式响应,后端 `AiChatEndpoint` 基于 OpenAI API 实现对话。但当前 AI 输出仅作为纯文本展示,Live2D 模型完全无法感知 AI 的情感状态、说话节奏或内容特征。 + +本设计建立在以下前置基础设施之上(由独立的底层变更提供): +- **Semantic Parameter Layer**:统一参数语义 API,使 AI 无需关心底层参数名 +- **Motion Layer System**:分层动画系统,支持 AI 触发的 gesture/override 层 +- **Runtime Filter Pipeline**:PixiJS v8 滤镜管线,支持情绪驱动的渲染效果 + +## Goals / Non-Goals + +**Goals:** +- 建立从 AI 流式输出到 Live2D 模型实时反应的完整数据管道 +- 实现情感标记提取 → 参数过渡 → 唇同步 → 动作触发的端到端链路 +- 保持与现有 AI 聊天功能的完全兼容(可开关) +- 延迟可控:情感反应 < 100ms,唇同步与文本展示同步 + +**Non-Goals:** +- 不实现基于真实音频分析的唇同步(如 VAD / TTS 音频流分析)——使用文本节奏推断作为轻量级替代 +- 不修改 LLM 模型本身(仅通过 prompt engineering 影响输出格式) +- 不实现复杂的面部捕捉或摄像头输入 +- 不替代现有的 motion 文件播放系统,仅作为运行时增强层 + +## Decisions + +### 使用 prompt 内嵌标记协议而非独立情感分析请求 + +在 system prompt 中要求 LLM 输出情感标记(如 `[happy]`、`[shy]`),前端 StreamParser 实时提取。这比独立情感分析请求延迟更低(零额外网络请求),且完全受用户控制(system prompt 可自由调整)。 + +**替代方案**:前端接收完整回复后调用独立情感分类 API。延迟增加 100-300ms,但标记更精确。**选择 prompt 方案**是因为实时性对 Live2D 体验至关重要,且用户已确认可完全控制后端 prompt。 + +### 文本节奏唇同步而非音频分析唇同步 + +基于文本的字符流和标点推断口型节奏(短字符 = 开嘴,标点 = 闭嘴),而非等待 TTS 音频输出后做 FFT 分析。这避免了引入 TTS 依赖和音频处理复杂度。 + +**替代方案**:接入 Web Speech API 或 WASM 语音合成获取真实 phoneme 时序。**选择文本方案**是因为当前后端仅返回文本流,没有音频通道。文本方案可立即工作,未来可无缝升级到音频方案(相同 LipSyncFrame 接口)。 + +### 情感状态机采用离散情感 + 连续强度,而非多情感混合 + +每个时刻只有一个主导情感(如 `happy`),配合 0~1 的强度值。过渡到新情感时做参数插值。这比多情感向量混合更简单且效果足够好。 + +**替代方案**:Plutchik 情感轮的多维向量混合。**选择离散方案**是因为 Live2D 模型通常只有一组预定义的表情参数,多维混合需要更复杂的参数映射,收益有限。 + +### AiCommandBus 采用发布-订阅模式而非直接耦合 + +AI 解析器发布命令到总线,Semantic Layer / Motion Layer / Filter Pipeline 各自订阅感兴趣的命令类型。这避免了 AI 层与各运行时系统的循环依赖。 + +## Risks / Trade-offs + +- **[Risk]** LLM 输出情感标记的位置可能与说话内容不同步(标记在句首但情感应覆盖整句)。→ **Mitigation**:标记生效时间从其出现位置开始,持续到下一个标记或句子结束。提供 `duration` 覆盖机制。 +- **[Risk]** 低端设备上多层动画 + 滤镜可能影响帧率。→ **Mitigation**:所有 AI 联动效果提供 `quality` 配置(低/中/高),低端设备可降级到仅参数变化无滤镜。 +- **[Risk]** 非标准 Live2D 模型缺少常见参数(如 `PARAM_ANGLE_X`),Semantic Layer 检测不到时表情不生效。→ **Mitigation**:Semantic Layer 的 `detectFromModel` 在初始化时报告 missing 参数,AI 层根据可用能力动态调整(Capability Detection 联动)。 +- **[Risk]** 过于频繁的情感切换导致模型抖动。→ **Mitigation**:Emotion Timeline 的过渡时长最短限制为 300ms,同一句内的标记批量合并。 + +## Migration Plan + +1. 后端更新 system prompt 模板(增加情感标记输出规范) +2. 前端新增 `runtime/ai/` 模块(独立目录,不影响现有代码) +3. `ChatApi` 增加可选的 `aiHooks` 回调配置(默认关闭,向后兼容) +4. 在 `Live2dCanvas` 中初始化 AI 联动层(仅在 `config.aiHooksEnabled` 为 true 时) +5. 通过 Halo 插件设置面板暴露 AI 联动开关和高级配置 + +Rollback:关闭 `aiHooksEnabled` 配置即可回退到纯文本聊天模式。 + +## Open Questions + +- 情感标记集合的完整列表是否需要用户可配置?(建议先内置 6~8 种基础情感,后续再扩展) +- 是否需要支持每句话结束后的「返回 idle」行为?(建议默认启用,可配置 idle 延迟时长) diff --git a/openspec/changes/ai-live2d-runtime-hooks/proposal.md b/openspec/changes/ai-live2d-runtime-hooks/proposal.md new file mode 100644 index 0000000..c504bde --- /dev/null +++ b/openspec/changes/ai-live2d-runtime-hooks/proposal.md @@ -0,0 +1,32 @@ +## Why + +plugin-live2d 已具备 AI 流式聊天功能(SSE 后端 + 前端消息展示),但 Live2D 模型与 AI 输出之间是完全割裂的——模型不会根据 AI 的情感、说话节奏、内容产生任何反应。这使得「AI 看板娘」的体验停留在「文本聊天 + 静态立绘」的层面,无法发挥 Live2D 的动态表现力。建立 AI Runtime Hooks 层,让 LLM 输出直接驱动模型的表情、动作、渲染效果,是实现真正「AI 伴侣级」体验的关键一步。 + +## What Changes + +- 在后端 AI Chat system prompt 中注入情感标记协议(`[happy]`, `[shy]`, `[surprised]` 等) +- 前端流式响应解析器:从 SSE chunk 中提取情感标记,转化为结构化 `AiCommand` +- AI Command Bus:统一分发情感、唇同步、动作、滤镜四类运行时指令 +- 情感时间线系统(Emotion Timeline):支持表情参数的平滑过渡而非瞬间切换 +- 轻量级唇同步(Text-based Lip Sync):基于文本节奏和标点推断口型变化 +- 与 Motion Layer System 的集成点:AI 触发的高优先级动作层(gesture/override) +- 与 Runtime Filter Pipeline 的集成点:AI 情绪驱动渲染效果(色温、光晕) + +## Capabilities + +### New Capabilities +- `ai-stream-parser`: 前端 SSE 流式文本解析,提取情感标记和纯文本内容 +- `ai-command-bus`: AI 指令总线,统一接收和分发运行时指令到各子系统 +- `emotion-timeline`: 情感状态机与参数插值过渡系统 +- `text-lip-sync`: 基于文本节奏的轻量级唇同步生成器 + +### Modified Capabilities +- `ai-chat`: 后端 ChatCompletion 接口的 system prompt 需增加情感标记输出规范 + +## Impact + +- **后端**: `AiChatEndpoint.java` 的 system prompt 模板需要更新 +- **前端 runtime**: 新增 `packages/live2d/src/runtime/ai/` 目录及模块 +- **前端 chat**: `ChatApi` 的流式处理逻辑需接入 `AiStreamParser` +- **依赖**: 需要 Semantic Parameter Layer 和 Motion Layer System 作为前置基础设施(这两个系统属于独立的底层变更) +- **配置**: 新增 AI 联动相关的运行时配置项(情感标记开关、过渡时长等) diff --git a/openspec/changes/ai-live2d-runtime-hooks/specs/ai-chat/spec.md b/openspec/changes/ai-live2d-runtime-hooks/specs/ai-chat/spec.md new file mode 100644 index 0000000..6fe5f88 --- /dev/null +++ b/openspec/changes/ai-live2d-runtime-hooks/specs/ai-chat/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Backend system prompt includes emotion markers +The backend `AiChatEndpoint` SHALL include emotion marker guidance in the system prompt sent to the LLM when AI hooks are enabled. + +#### Scenario: Emotion marker output +- **WHEN** a chat request is processed with AI hooks enabled +- **THEN** the system prompt instructs the LLM to embed emotion markers like `[happy]`, `[shy]`, `[surprised]` at appropriate positions in the response text + +#### Scenario: Backward compatibility when disabled +- **WHEN** AI hooks are disabled +- **THEN** the system prompt does not include emotion marker instructions +- **AND** the LLM responds with plain text only + +## ADDED Requirements + +### Requirement: Frontend configuration for AI hooks +The public runtime config SHALL include an `aiHooksEnabled` boolean field defaulting to `false`. + +#### Scenario: Config-driven enablement +- **WHEN** the config contains `aiHooksEnabled: true` +- **THEN** the frontend initializes the AI Runtime Hooks layer +- **WHEN** the config contains `aiHooksEnabled: false` +- **THEN** the AI Runtime Hooks layer is not initialized and chat behaves as before diff --git a/openspec/changes/ai-live2d-runtime-hooks/specs/ai-command-bus/spec.md b/openspec/changes/ai-live2d-runtime-hooks/specs/ai-command-bus/spec.md new file mode 100644 index 0000000..2726ad6 --- /dev/null +++ b/openspec/changes/ai-live2d-runtime-hooks/specs/ai-command-bus/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Publish-subscribe command dispatch +The `AiCommandBus` SHALL support publishing commands and subscribing handlers by command type. Multiple subscribers SHALL receive each matching command. + +#### Scenario: Subscribe and receive emotion command +- **WHEN** a handler subscribes to command type `"emotion"` +- **AND** a command `{ type: "emotion", emotion: "happy", intensity: 0.8 }` is published +- **THEN** the handler receives the command + +#### Scenario: Multiple subscribers for same type +- **WHEN** two handlers subscribe to command type `"lipSync"` +- **AND** a lip sync command is published +- **THEN** both handlers receive the command + +### Requirement: Support command types +The command bus SHALL support at minimum these command types: `emotion`, `lipSync`, `motion`, `filter`. + +#### Scenario: Motion command dispatch +- **WHEN** a command `{ type: "motion", layer: "gesture", motion: "nod" }` is published +- **THEN** the Motion Layer subscriber receives and processes it + +### Requirement: Commands carry timing metadata +Every command SHALL carry an `estimatedTime` field representing the estimated display time offset from the start of the AI response. + +#### Scenario: Lip sync timing +- **WHEN** a lip sync command with `estimatedTime: 1200` is published +- **THEN** the Lip Sync subscriber schedules the mouth shape change at 1200ms from response start + +### Requirement: Async-safe publishing +The command bus SHALL handle commands published during an active transition without dropping or corrupting state. + +#### Scenario: Rapid emotion changes +- **WHEN** three emotion commands are published within 50ms +- **THEN** all three are queued and processed in order by the Emotion Timeline diff --git a/openspec/changes/ai-live2d-runtime-hooks/specs/ai-stream-parser/spec.md b/openspec/changes/ai-live2d-runtime-hooks/specs/ai-stream-parser/spec.md new file mode 100644 index 0000000..a3fa785 --- /dev/null +++ b/openspec/changes/ai-live2d-runtime-hooks/specs/ai-stream-parser/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Extract emotion markers from SSE chunks +The parser SHALL scan incoming SSE text chunks for embedded emotion markers in the format `[emotion-name]` where `emotion-name` is a registered emotional state key. + +#### Scenario: Single emotion marker in chunk +- **WHEN** an SSE chunk contains text `你好呀![happy] 今天真开心~` +- **THEN** the parser emits `{ text: "你好呀! 今天真开心~", commands: [{ type: "emotion", emotion: "happy", position: 4 }] }` + +#### Scenario: Multiple emotion markers in chunk +- **WHEN** an SSE chunk contains `[happy]欢迎![shy]人家有点紧张……` +- **THEN** the parser emits two emotion commands with their respective positions and the stripped text `欢迎!人家有点紧张……` + +### Requirement: Support configurable emotion vocabulary +The parser SHALL accept a configurable set of valid emotion marker names at initialization time. Unrecognized markers SHALL be ignored and left in the text. + +#### Scenario: Unknown marker is preserved +- **WHEN** the configured vocabulary is `["happy", "shy", "sad"]` and a chunk contains `[unknown]hello` +- **THEN** the parser emits `{ text: "[unknown]hello", commands: [] }` + +### Requirement: Estimate timestamp for each command +The parser SHALL assign an estimated display timestamp to each extracted command based on character position within the cumulative text stream. + +#### Scenario: Timestamp estimation +- **WHEN** 10 characters have already been processed and a new chunk of `hello[happy]world` arrives +- **THEN** the `happy` command receives a timestamp estimate of `5` (the position of the marker in the chunk) + +## MODIFIED Requirements + +### Requirement: Chat stream processing supports AI hooks +The `ChatApi.sendMessage` method SHALL optionally invoke an `AiStreamParser` to process each SSE chunk before displaying text, when AI hooks are enabled in configuration. + +#### Scenario: AI hooks enabled +- **WHEN** `ChatApi` is configured with `aiHooksEnabled: true` +- **AND** an SSE chunk arrives containing emotion markers +- **THEN** the parsed text (with markers removed) is displayed to the user +- **AND** extracted commands are dispatched to the `AiCommandBus` + +#### Scenario: AI hooks disabled (backward compatibility) +- **WHEN** `ChatApi` is configured with `aiHooksEnabled: false` (default) +- **AND** an SSE chunk arrives +- **THEN** the raw text is displayed without parsing diff --git a/openspec/changes/ai-live2d-runtime-hooks/specs/emotion-timeline/spec.md b/openspec/changes/ai-live2d-runtime-hooks/specs/emotion-timeline/spec.md new file mode 100644 index 0000000..fcaa34e --- /dev/null +++ b/openspec/changes/ai-live2d-runtime-hooks/specs/emotion-timeline/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Discrete emotional states with intensity +The system SHALL maintain a single active emotional state consisting of a named emotion and an intensity value in the range [0, 1]. + +#### Scenario: Set emotion state +- **WHEN** the emotion timeline receives `{ emotion: "happy", intensity: 0.8 }` +- **THEN** the active state becomes `happy` at intensity `0.8` + +### Requirement: Smooth parameter interpolation between emotions +The system SHALL interpolate semantic parameter values between the outgoing and incoming emotional states over a configurable duration. + +#### Scenario: Transition from neutral to happy +- **WHEN** the current state is `neutral` and a transition to `happy` with duration `800ms` is requested +- **THEN** over the next 800ms, the model's expression parameters smoothly blend from neutral values to happy values + +### Requirement: Minimum transition duration +The system SHALL enforce a minimum transition duration of 300ms to prevent visual jitter from rapid emotion changes. + +#### Scenario: Rapid successive emotions +- **WHEN** an emotion change is requested while another transition is in progress +- **AND** the remaining time of the current transition is less than 300ms +- **THEN** the system extends or completes the current transition before starting the new one + +### Requirement: Emotion parameter mapping registry +The system SHALL use a configurable mapping from emotion names to sets of semantic parameter values. + +#### Scenario: Happy emotion parameters +- **WHEN** the "happy" emotion is mapped to `{ mouthOpen: 0.3, eyeSmile: 0.7, cheek: 0.4 }` +- **AND** the emotion timeline transitions to "happy" +- **THEN** those semantic parameters are driven to the mapped values via the Semantic Parameter Layer + +### Requirement: Auto-return to idle +The system SHALL automatically transition back to a configurable default emotion (typically "neutral") after a configurable idle timeout following the last emotion command. + +#### Scenario: Return to idle after timeout +- **WHEN** the idle timeout is configured to 2000ms +- **AND** no new emotion command arrives within 2000ms after the last one +- **THEN** the system transitions back to the default emotion diff --git a/openspec/changes/ai-live2d-runtime-hooks/specs/text-lip-sync/spec.md b/openspec/changes/ai-live2d-runtime-hooks/specs/text-lip-sync/spec.md new file mode 100644 index 0000000..03d0783 --- /dev/null +++ b/openspec/changes/ai-live2d-runtime-hooks/specs/text-lip-sync/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Generate lip sync frames from text stream +The system SHALL generate a sequence of lip sync frames from incoming text chunks, where each frame specifies a mouth shape value and duration. + +#### Scenario: Simple text lip sync +- **WHEN** the text chunk `"hello"` arrives +- **THEN** the system generates frames that open the mouth during characters and briefly close at the end + +### Requirement: Map text patterns to mouth shapes +The system SHALL use a simple heuristic mapping: alphabetic characters → open mouth, whitespace/punctuation → closed or partially open mouth. + +#### Scenario: Punctuation causes mouth close +- **WHEN** the text chunk `"Hi! How are you?"` arrives +- **THEN** the mouth closes at `!`, opens at `H`, closes at ` `, opens at `a`, etc. + +### Requirement: Synchronize with text display timing +The generated lip sync frames SHALL align with the estimated text display timing so the model's mouth movements appear synchronized with the text streaming onto screen. + +#### Scenario: Timing alignment +- **WHEN** text chunks arrive with delays of 100-200ms between them +- **THEN** the lip sync frames are paced to match those delays + +### Requirement: Graceful fallback when mouth parameter unavailable +If the current model does not expose a mouth-related semantic parameter, the lip sync system SHALL silently disable itself without errors. + +#### Scenario: Missing mouth parameter +- **WHEN** `SemanticLayer.hasSemantic("mouthOpen")` returns `false` +- **THEN** lip sync frames are generated but not applied to the model diff --git a/openspec/changes/ai-live2d-runtime-hooks/tasks.md b/openspec/changes/ai-live2d-runtime-hooks/tasks.md new file mode 100644 index 0000000..f911c4c --- /dev/null +++ b/openspec/changes/ai-live2d-runtime-hooks/tasks.md @@ -0,0 +1,50 @@ +## 1. Backend Prompt Update + +- [ ] 1.1 Update `AiChatEndpoint.java` system prompt template to include emotion marker instructions when AI hooks are enabled +- [ ] 1.2 Add `aiHooksEnabled` flag to backend AI chat configuration model +- [ ] 1.3 Ensure backward compatibility: prompt without markers when hooks disabled + +## 2. Frontend AI Stream Parser + +- [ ] 2.1 Create `packages/live2d/src/runtime/ai/AiStreamParser.ts` with emotion marker extraction +- [ ] 2.2 Implement configurable emotion vocabulary at parser initialization +- [ ] 2.3 Implement estimated timestamp calculation for extracted commands +- [ ] 2.4 Add unit tests for marker extraction edge cases (multiple markers, unknown markers, nested brackets) + +## 3. AI Command Bus + +- [ ] 3.1 Create `packages/live2d/src/runtime/ai/AiCommandBus.ts` with pub-sub interface +- [ ] 3.2 Define TypeScript types for all command types: `emotion`, `lipSync`, `motion`, `filter` +- [ ] 3.3 Implement command queuing for rapid successive publishes +- [ ] 3.4 Wire Command Bus into `ChatApi.handleStreamResponse` (behind `aiHooksEnabled` flag) + +## 4. Emotion Timeline System + +- [ ] 4.1 Create `packages/live2d/src/runtime/ai/EmotionTimeline.ts` +- [ ] 4.2 Implement discrete emotional state with intensity [0, 1] +- [ ] 4.3 Implement smooth parameter interpolation with configurable duration (min 300ms) +- [ ] 4.4 Create emotion-to-parameter mapping registry (happy, shy, sad, surprised, angry, thinking, neutral) +- [ ] 4.5 Implement auto-return to idle after configurable timeout +- [ ] 4.6 Integrate with Semantic Parameter Layer (via command bus subscription) + +## 5. Text Lip Sync Generator + +- [ ] 5.1 Create `packages/live2d/src/runtime/ai/TextLipSync.ts` +- [ ] 5.2 Implement text-to-mouth-shape heuristic mapping +- [ ] 5.3 Implement frame timing synchronized with SSE chunk arrival timing +- [ ] 5.4 Add graceful fallback when `mouthOpen` semantic parameter is unavailable +- [ ] 5.5 Integrate with Command Bus (publish `lipSync` commands) + +## 6. Integration & Configuration + +- [ ] 6.1 Add `aiHooksEnabled` and related fields to `Live2dConfig` interface +- [ ] 6.2 Initialize AI runtime layer in `Live2dCanvas` when config enables it +- [ ] 6.3 Ensure ChatApi backward compatibility (hooks disabled by default) +- [ ] 6.4 Add Halo plugin settings UI fields for AI hooks toggle and emotion timeout + +## 7. Testing & Validation + +- [ ] 7.1 End-to-end test: AI response with `[happy]` marker triggers model smile +- [ ] 7.2 End-to-end test: Rapid emotion changes do not cause visual jitter +- [ ] 7.3 Test backward compatibility: disabled hooks mode works identically to before +- [ ] 7.4 Performance test: AI hooks do not drop frame rate below 30fps on mid-tier devices diff --git a/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/design.md b/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/design.md deleted file mode 100644 index 24ca147..0000000 --- a/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/design.md +++ /dev/null @@ -1,29 +0,0 @@ -## Context - -The Lit-based frontend has already replaced the legacy autoload script for rendering, tips dispatch, and toolbar actions. After narrowing the parity review, the confirmed missing feature is specific: when a configured full TIPS source fails to load, the modern runtime does not fall back to the bundled `live2d-tips.json` file the way the legacy script did. - -Other previously discussed items are intentionally excluded from this change. In particular, `hitokoto` handling is currently treated as standardized behavior in the new runtime rather than a confirmed feature regression, and broader lifecycle or config-compatibility items are treated as follow-up optimizations unless they later prove to be user-facing breakages. - -## Goals / Non-Goals - -**Goals:** -- Restore the legacy tips fallback contract in a typed, explicit way. -- Keep the modern component architecture, UnoCSS styling, and event-driven runtime unchanged outside the fallback fix. -- Limit the work to the one confirmed user-facing parity gap. - -**Non-Goals:** -- Recreate the legacy autoload DOM structure or CSS implementation. -- Change backend API contracts or toolbar behavior. -- Reclassify standardization or optimization items as feature regressions without new evidence. - -## Decisions - -### Make tips loading return an explicit success-or-fallback signal -The legacy script treated a missing or invalid configured tips file as a trigger to fall back to the bundled default tips. The modern loader currently resolves an empty object on failure, which prevents the fallback branch from being reached. The replacement should make the fallback decision explicit by distinguishing “loaded tip config” from “failed/empty result,” then merging plugin/theme/default tips only after the full source is chosen. - -**Alternative considered:** keep the current helper shape and infer failure from empty objects later. This keeps call sites unchanged, but it is brittle because an empty object is also a valid JavaScript value and silently hides fallback decisions. - -## Risks / Trade-offs - -- **Tips fallback changes may alter current edge-case behavior for malformed custom files** → Keep the merge order unchanged and only change the source-selection decision when the configured full tips file is unusable. -- **A narrow fix can leave adjacent cleanup for later** → Explicitly document that the current scope is intentionally limited to the confirmed TIPS fallback regression. diff --git a/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/proposal.md b/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/proposal.md deleted file mode 100644 index af5ce50..0000000 --- a/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/proposal.md +++ /dev/null @@ -1,23 +0,0 @@ -## Why - -The modern Live2D runtime already covers the rendering, tool, and most tip flows. After narrowing the comparison with `src/main/resources/static/js/live2d-autoload.js`, the only clear missing user-facing feature is that a broken or unavailable custom full TIPS source no longer falls back to the bundled default TIPS file. - -## What Changes - -- Restore the legacy full-TIPS fallback contract so the bundled default `live2d-tips.json` is used when a configured custom `tipsPath` resource is missing or invalid. -- Keep the modern Lit and UnoCSS architecture while correcting the source-selection logic for full TIPS loading. -- Add regression coverage for the fallback chain so future refactors do not silently drop default TIPS behavior again. -- Record that other previously discussed items, such as `hitokoto` response handling, are currently treated as standardization or optimization rather than confirmed missing features. - -## Capabilities - -### New Capabilities -- `live2d-widget-behavior-parity`: Preserve the remaining confirmed user-facing parity gap by restoring bundled default TIPS fallback when custom full TIPS loading fails. - -### Modified Capabilities - -## Impact - -- Affected code: `packages/live2d/src/events/tip-events.ts`, `packages/live2d/src/helpers/loadTipsResource.ts` -- Affected behavior: fallback from configured `tipsPath` to bundled default full TIPS -- No backend API or UI surface expansion is required; changes stay within the existing frontend tip-loading flow diff --git a/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/specs/live2d-widget-behavior-parity/spec.md b/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/specs/live2d-widget-behavior-parity/spec.md deleted file mode 100644 index 6331fb7..0000000 --- a/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/specs/live2d-widget-behavior-parity/spec.md +++ /dev/null @@ -1,14 +0,0 @@ -## ADDED Requirements - -### Requirement: Full tips sources SHALL fall back to the bundled defaults when unavailable -The runtime SHALL continue using plugin tips and theme tips, but it MUST fall back to the bundled full tips file when a configured full tips source cannot be loaded or parsed. - -#### Scenario: Missing custom tips file falls back to bundled defaults -- **WHEN** `tipsPath` is configured and the referenced resource cannot be fetched successfully -- **THEN** the runtime MUST load the bundled `live2d-tips.json` file as the full tips source -- **AND** plugin-level and theme-level tips MUST still be merged with that fallback source using the existing priority order - -#### Scenario: Invalid custom tips file falls back to bundled defaults -- **WHEN** `tipsPath` points to a resource whose response cannot be parsed into the expected tips structure -- **THEN** the runtime MUST ignore that invalid full tips source -- **AND** it MUST continue initialization with the bundled default full tips file diff --git a/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/tasks.md b/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/tasks.md deleted file mode 100644 index ab7abb3..0000000 --- a/openspec/changes/archive/2026-05-13-complete-live2d-modernization-parity/tasks.md +++ /dev/null @@ -1,4 +0,0 @@ -## 1. TIPS fallback parity - -- [x] 1.1 Refactor full tips loading so missing or invalid configured `tipsPath` resources fall back to the bundled `live2d-tips.json` file before merge. -- [x] 1.2 Add regression coverage for the fallback chain so default full TIPS remain available when custom full TIPS loading fails. diff --git a/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/design.md b/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/design.md deleted file mode 100644 index 7326118..0000000 --- a/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/design.md +++ /dev/null @@ -1,76 +0,0 @@ -## Context - -The current frontend renderer lives in `packages/live2d/src/live2d/model.ts` and is tightly coupled to `pixi-live2d-display` plus PixiJS v6-style initialization. It imports both legacy Cubism runtime scripts, creates a `PIXI.Application` directly from the target canvas, loads models from the existing backend `get/?id=-` endpoint, and exposes toolbar-facing methods for model switching, texture switching, and screenshot capture. - -The replacement engine, `untitled-pixi-live2d-engine`, targets PixiJS v8 and explicitly supports Cubism 2 / 3 / 4 / 5. That introduces a dependency upgrade, renderer bootstrap changes, and some API adaptation work, but the surrounding widget, event system, backend endpoints, and UnoCSS-driven UI should remain stable. - -## Goals / Non-Goals - -**Goals:** -- Move the renderer in `packages/live2d` to `untitled-pixi-live2d-engine`. -- Support Cubism 2 / 3 / 4 / 5 model assets without changing the current backend endpoint contract. -- Preserve the public widget lifecycle and existing tool behaviors (`loadModel`, `loadRandTextures`, `loadOtherModel`, `capture`). -- Keep the plugin's frontend integration self-contained so the server-side injection flow does not need a parallel legacy runtime path. - -**Non-Goals:** -- Redesign the widget UI, tips system, or tool ordering. -- Change backend model management endpoints or add new model metadata APIs unless implementation proves it is strictly required. -- Add new Live2D features from the new engine such as lip-sync, parallel motion, or custom filters in this migration. - -## Decisions - -### 1. Upgrade the frontend renderer stack to PixiJS v8 together with the new Live2D engine - -`untitled-pixi-live2d-engine` is built for PixiJS v8, so the migration should upgrade `pixi.js` and remove `pixi-live2d-display` in the same change. Keeping PixiJS v6 would force compatibility shims around the renderer core and defeat the purpose of moving to a maintained engine. - -**Alternatives considered** -- Stay on `pixi-live2d-display`: rejected because the current maintenance gap is the reason for the change. -- Try to mix PixiJS v8 engine code with PixiJS v6 application code: rejected because the renderer and extraction APIs differ enough to make that fragile. - -### 2. Keep a stable `Model` wrapper and adapt engine-specific behavior behind it - -The widget and tooling already depend on the `Model` class methods in `packages/live2d/src/live2d/model.ts`. The migration should preserve that wrapper shape and refactor its internals so the rest of the component tree, custom tools, and event dispatch continue to work unchanged. - -**Alternatives considered** -- Expose the engine model directly to the rest of the app: rejected because it would spread migration work across many files and make future renderer swaps harder. -- Keep the old wrapper and add a second parallel runtime: rejected because it would increase bundle and maintenance complexity without a clear need. - -### 3. Use the engine entrypoint that can handle both legacy and modern Cubism runtimes - -The current backend endpoints return asset URLs but do not expose Cubism generation metadata that the frontend can rely on ahead of time. The migration should therefore use the engine entrypoint that supports both legacy and modern Cubism models, while continuing to ship the required `live2d.min.js` and `live2dcubismcore.min.js` assets. - -**Alternatives considered** -- Dynamically choose `cubism-legacy` vs `cubism` engine entrypoints per model: rejected for now because the current API does not provide a reliable version discriminator. -- Add a backend metadata endpoint first: rejected because it expands scope beyond the renderer replacement. - -### 4. Preserve current model loading and toolbar workflows, but update Pixi bootstrap and extraction to v8-compatible patterns - -The migrated runtime should continue to: -- resolve the initial model and texture IDs from config/local storage, -- fetch switch/texture data from the existing endpoints, -- emit the same user-facing messages, -- expose screenshot capture through the toolbar. - -Internally, the Pixi application bootstrap, stage cleanup, and screenshot extraction should be rewritten for the v8 stack and validated inside the existing `live2d-canvas` lifecycle. - -**Alternatives considered** -- Change tool behavior during the migration: rejected because the user-facing contract is already established and unrelated to engine selection. - -## Risks / Trade-offs - -- **[PixiJS v8 API migration may break canvas initialization or screenshot capture]** → Mitigation: isolate Pixi bootstrap/extract logic inside the `Model` wrapper and validate the current tool flows against the upgraded runtime. -- **[Loading both Cubism runtimes keeps bundle/runtime cost higher than a version-specific path]** → Mitigation: keep the current deferred page initialization and revisit per-model runtime selection in a later optimization change if needed. -- **[Backend model assets may expose edge cases the old engine tolerated differently]** → Mitigation: preserve endpoint contracts, surface load failures clearly, and test representative Cubism 2 and Cubism 3/4/5 models during implementation. - -## Migration Plan - -1. Replace frontend dependencies and regenerate the lockfile/build output required by the package. -2. Refactor `packages/live2d/src/live2d/model.ts` to initialize PixiJS v8 and `untitled-pixi-live2d-engine` behind the existing `Model` API. -3. Update any affected component/runtime integration points, including canvas initialization and screenshot extraction. -4. Validate that initial load, switch model, switch texture, and screenshot flows still work against the current backend endpoints. -5. Roll back by reverting the dependency/runtime changes if the new engine cannot load the existing model inventory safely. - -## Open Questions - -- Does the chosen engine integration require adding `@pixi/sound` immediately for the current feature set, or only when lip-sync/audio APIs are used? -- Do the plugin's existing model assets include at least one Cubism 2 model and one Cubism 3/4/5 model that can be used as regression fixtures during implementation? diff --git a/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/proposal.md b/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/proposal.md deleted file mode 100644 index 8e07aed..0000000 --- a/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/proposal.md +++ /dev/null @@ -1,25 +0,0 @@ -## Why - -The frontend Live2D runtime currently depends on `pixi-live2d-display`, an unmaintained renderer tied to an older PixiJS stack. That blocks reliable support for newer Cubism model formats and makes future renderer fixes increasingly risky. - -## What Changes - -- Replace the frontend renderer dependency in `packages/live2d` from `pixi-live2d-display` to `untitled-pixi-live2d-engine`. -- Upgrade the Live2D canvas/runtime integration so the plugin can render Cubism 2 / 3 / 4 / 5 models through the new engine. -- Keep the existing plugin-facing behavior for model loading, model switching, texture switching, screenshot capture, and event dispatch while adapting the underlying renderer implementation. -- Preserve compatibility with the current backend model endpoints (`get`, `switch`, `rand_textures`) so the server contract does not need to change for this migration. -- Refresh package/build metadata and bundled runtime loading so the new renderer's PixiJS and Cubism requirements are satisfied in the shipped frontend bundle. - -## Capabilities - -### New Capabilities -- `multi-cubism-live2d-rendering`: Render Live2D models in the plugin with a maintained Pixi-based engine that supports Cubism 2 / 3 / 4 / 5 while preserving the current widget lifecycle and user tools. - -### Modified Capabilities -- None. - -## Impact - -- Affected code: `packages/live2d/package.json`, `packages/live2d/src/live2d/model.ts`, `packages/live2d/src/components/Live2dCanvas.tsx`, and related runtime/tool integration files. -- Dependencies: replace `pixi-live2d-display`, upgrade `pixi.js`, and align bundled Cubism runtime assets with the new engine. -- Systems: frontend rendering pipeline, model lifecycle handling, screenshot extraction, and compatibility with existing Live2D API responses. diff --git a/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/specs/multi-cubism-live2d-rendering/spec.md b/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/specs/multi-cubism-live2d-rendering/spec.md deleted file mode 100644 index dddf699..0000000 --- a/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/specs/multi-cubism-live2d-rendering/spec.md +++ /dev/null @@ -1,34 +0,0 @@ -## ADDED Requirements - -### Requirement: Plugin SHALL render supported Live2D models through the maintained renderer -The plugin SHALL replace `pixi-live2d-display` with a maintained renderer integration that can load the plugin's configured Live2D assets for Cubism 2 / 3 / 4 / 5 models. - -#### Scenario: Initial widget load uses the maintained renderer -- **WHEN** the widget initializes on a supported desktop page with a configured default model -- **THEN** the frontend runtime MUST create the Live2D scene with the maintained renderer stack -- **AND** the requested model asset from the existing backend `get` endpoint MUST be rendered in the canvas - -### Requirement: Existing model interaction flows SHALL remain available after the renderer migration -The plugin SHALL preserve the current model interaction flows exposed by the widget runtime so that renderer replacement does not break end-user tools. - -#### Scenario: Switching to another model keeps using the current backend contract -- **WHEN** a user triggers the switch-model tool -- **THEN** the frontend runtime MUST request the next model from the existing `switch` endpoint -- **AND** the returned model MUST replace the current model in the canvas without requiring a page reload - -#### Scenario: Switching model textures keeps using the current backend contract -- **WHEN** a user triggers the switch-texture tool for a model that has alternate textures -- **THEN** the frontend runtime MUST request the next texture from the existing `rand_textures` endpoint -- **AND** the returned texture selection MUST be rendered on the current model canvas - -#### Scenario: Capturing a screenshot remains available -- **WHEN** a user triggers the screenshot tool after a model has loaded -- **THEN** the runtime MUST export the current canvas content as an image download - -### Requirement: Renderer migration SHALL not require a new server-side model metadata API -The renderer migration SHALL work with the plugin's current server-side model loading endpoints and injected configuration contract. - -#### Scenario: Existing initialization contract remains sufficient -- **WHEN** the frontend runtime receives the same injected config fields used by the current widget -- **THEN** it MUST determine the initial model and texture selection without requiring additional backend metadata fields -- **AND** it MUST continue loading the model through the current API path conventions diff --git a/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/tasks.md b/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/tasks.md deleted file mode 100644 index c251030..0000000 --- a/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/tasks.md +++ /dev/null @@ -1,16 +0,0 @@ -## 1. Dependency and runtime setup - -- [x] 1.1 Replace `pixi-live2d-display` with `untitled-pixi-live2d-engine` in `packages/live2d/package.json` and align `pixi.js` with the renderer's supported major version. -- [x] 1.2 Add any required companion dependencies/runtime assets for the new engine and refresh the workspace lockfile/build inputs. - -## 2. Renderer migration - -- [x] 2.1 Refactor `packages/live2d/src/live2d/model.ts` to initialize PixiJS v8 and `untitled-pixi-live2d-engine` behind the existing `Model` wrapper API. -- [x] 2.2 Keep initial model loading compatible with the current config and `get/?id=-` endpoint contract while supporting Cubism 2 / 3 / 4 / 5 assets. -- [x] 2.3 Update model replacement, texture switching, and screenshot export logic to use the new renderer without changing toolbar behavior. - -## 3. Integration and verification - -- [x] 3.1 Adjust any affected component/runtime integration points so `Live2dCanvas` and widget lifecycle events continue to work with the migrated renderer. -- [x] 3.2 Build the frontend package and verify initial load, switch-model, switch-texture, and screenshot flows against representative plugin model assets. -- [x] 3.3 Update directly related documentation or migration notes if dependency/runtime requirements changed for plugin maintainers. diff --git a/openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/design.md b/openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/design.md deleted file mode 100644 index 07c775b..0000000 --- a/openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/design.md +++ /dev/null @@ -1,46 +0,0 @@ -## Context - -`packages/live2d` has replaced most of `live2d-autoload.js` with Lit components, typed config normalization, and modular tools. The remaining gaps are not renderer-level gaps anymore; they are compatibility gaps in widget lifecycle and console status behavior. - -Today the modern widget unmounts the canvas subtree when it is hidden, which forces model recreation and re-runs initialization flows on reopen. The modern runtime also normalizes `consoleShowStatu`, but it only logs model-load completion instead of preserving the legacy console status output. - -## Goals / Non-Goals - -**Goals:** -- Preserve the modern Lit/UnoCSS architecture while restoring the remaining user-visible legacy behaviors that still matter. -- Keep widget hide/show behavior stateful within the same page session so reopen does not recreate the runtime unnecessarily. -- Reintroduce the `consoleShowStatu` compatibility behavior in a maintainable way. - -**Non-Goals:** -- Reintroduce legacy DOM structure, legacy CSS files, or script-loader based initialization. -- Change backend APIs or introduce new server-side endpoints. -- Change the current hitokoto implementation beyond leaving it in its present modernized state. -- Achieve byte-for-byte parity with the old script where the modern implementation is intentionally better. - -## Decisions - -### Keep the widget subtree mounted and separate visibility from initialization -The modern runtime should stop using conditional rendering to remove the widget subtree on hide. Instead, the widget should mount once, keep the canvas/tools/tips subtree alive, and switch between visible and hidden presentation states. - -This preserves the current model instance, avoids repeat model fetches on reopen, and matches the legacy expectation that dismissing the widget is a temporary hide instead of a teardown. The alternative was to keep the current remount behavior and patch over the regressions with cache/localStorage state, but that still recreates listeners and model state and would not match the legacy behavior as closely. - -### Extract console compatibility output into a dedicated helper -The legacy `consoleShowStatu` behavior combined a compatibility banner with status logging. The modern runtime should implement that as an explicit helper with stable metadata constants instead of reproducing the obfuscated legacy snippet. - -This keeps the behavior readable and testable while preserving the observable contract exposed by the legacy config flag. The alternative was to leave the partial implementation in `model.ts`, but that would continue to advertise compatibility without actually providing the legacy output. - -## Risks / Trade-offs - -- **[Risk]** Keeping the widget mounted means the renderer stays alive while hidden. → **Mitigation:** only preserve state within the current page session and continue using the existing explicit quit/toggle controls instead of background polling or reloading. -- **[Risk]** Console compatibility output may diverge from the old banner formatting. → **Mitigation:** preserve the same information contract (`version`, `updateTime`, and status intent) without copying the obfuscated implementation. - -## Migration Plan - -1. Add spec coverage for widget lifecycle parity and console status compatibility. -2. Refactor the widget visibility flow so quit/toggle hide the mounted runtime instead of tearing it down. -3. Add a console compatibility helper and wire it to normalized config flags. -4. Verify the existing modern runtime still preserves the already-migrated renderer and tips behaviors. - -## Open Questions - -- None. The remaining gaps are well-defined by the legacy script and current implementation. diff --git a/openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/proposal.md b/openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/proposal.md deleted file mode 100644 index 8810df6..0000000 --- a/openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/proposal.md +++ /dev/null @@ -1,23 +0,0 @@ -## Why - -`packages/live2d` has already modernized most of the legacy `live2d-autoload.js` runtime, but a few legacy behaviors still have no equivalent or have regressed during the migration. Capturing those gaps now gives the project a concrete parity target before the old script stops being the functional reference. - -## What Changes - -- Audit the remaining behavior gaps between `packages/live2d` and `src/main/resources/static/js/live2d-autoload.js`, then codify the ones that should still be preserved in the modern runtime. -- Restore the legacy dismiss/reopen behavior so hiding the widget does not unnecessarily destroy and recreate the mounted runtime state during the same page session. -- Restore the `consoleShowStatu` compatibility contract so the modern runtime still exposes the legacy console status output beyond the current model-load log line. - -## Capabilities - -### New Capabilities -- None. - -### Modified Capabilities -- `live2d-widget-behavior-parity`: expand parity requirements to cover the remaining legacy widget lifecycle and console status behaviors that are still missing from the modernized frontend. - -## Impact - -- Affected code: `packages/live2d/src/components/*`, `packages/live2d/src/live2d/model.ts`, and related config/runtime helpers where compatibility behavior is defined. -- Affected behavior: widget hide/show lifecycle, console compatibility output, and parity expectations tracked in OpenSpec. -- No backend API changes are expected; the work stays within the existing frontend runtime and current server contracts. diff --git a/openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/specs/live2d-widget-behavior-parity/spec.md b/openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/specs/live2d-widget-behavior-parity/spec.md deleted file mode 100644 index 37f211a..0000000 --- a/openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/specs/live2d-widget-behavior-parity/spec.md +++ /dev/null @@ -1,21 +0,0 @@ -## ADDED Requirements - -### Requirement: Widget dismissal SHALL preserve mounted runtime state during the current page session -The modern widget SHALL treat quit and toggle dismissal as a visibility change instead of tearing down the mounted runtime subtree, so the current model and tool state remain available when the widget is reopened on the same page. - -#### Scenario: Quit hides the mounted widget without resetting the current model -- **WHEN** a user dismisses the widget through the `quit` tool after switching to another model or texture -- **THEN** the widget MUST transition to a hidden state without recreating the Live2D runtime immediately -- **AND** reopening the widget during the same page session MUST continue from the current model state instead of reloading the default model - -#### Scenario: Reopening after dismissal does not duplicate initialization side effects -- **WHEN** the widget is hidden and then shown again on the same page -- **THEN** the runtime MUST not register duplicate tip listeners or re-run first-open initialization solely because of that visibility change - -### Requirement: Console status compatibility SHALL remain available through the legacy config flag -When `consoleShowStatus` or its legacy alias `consoleShowStatu` is enabled, the modern runtime SHALL preserve the observable console compatibility output expected from the legacy widget runtime. - -#### Scenario: Console compatibility output includes widget metadata and load status -- **WHEN** the widget initializes with console status output enabled -- **THEN** the runtime MUST emit readable console output that includes the plugin version/update metadata represented by the legacy runtime -- **AND** it MUST continue emitting model load completion status for the requested model selection diff --git a/openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/tasks.md b/openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/tasks.md deleted file mode 100644 index 2b7bca0..0000000 --- a/openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/tasks.md +++ /dev/null @@ -1,13 +0,0 @@ -## 1. Widget lifecycle parity - -- [x] 1.1 Refactor `Live2dWidget` and related components so hide/show toggles visibility without unmounting the mounted runtime subtree during the same page session. -- [x] 1.2 Keep quit/toggle dismissal behavior compatible with the legacy 24-hour suppression flow while preventing duplicate initialization side effects on reopen. - -## 2. Compatibility behavior restoration - -- [x] 2.1 Add a readable console compatibility helper that restores the legacy `consoleShowStatu` metadata/status output through the normalized config flag. - -## 3. Validation - -- [x] 3.1 Add or update coverage for the restored parity behaviors at the spec-relevant frontend layer. -- [x] 3.2 Run the existing package build and relevant checks to confirm the modern runtime still works after the parity fixes. diff --git a/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/.openspec.yaml b/openspec/changes/archive/2026-05-21-behavior-fsm/.openspec.yaml similarity index 50% rename from openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/.openspec.yaml rename to openspec/changes/archive/2026-05-21-behavior-fsm/.openspec.yaml index 93831bd..8b76914 100644 --- a/openspec/changes/archive/2026-05-13-replace-live2d-renderer-engine/.openspec.yaml +++ b/openspec/changes/archive/2026-05-21-behavior-fsm/.openspec.yaml @@ -1,2 +1,2 @@ schema: spec-driven -created: 2026-05-13 +created: 2026-05-20 diff --git a/openspec/changes/archive/2026-05-21-behavior-fsm/design.md b/openspec/changes/archive/2026-05-21-behavior-fsm/design.md new file mode 100644 index 0000000..e8b430f --- /dev/null +++ b/openspec/changes/archive/2026-05-21-behavior-fsm/design.md @@ -0,0 +1,140 @@ +## Context + +plugin-live2d now has a Motion Layer System for parallel animation tracks, a Filter Pipeline for visual effects, and a Semantic Parameter Layer for unified parameter access. However, there is no high-level concept that ties these together. A Behavior FSM provides a declarative way to say "the character is embarrassed right now" and have that automatically map to: a specific expression on the `expression` layer, a blush filter, reduced eye contact (eye tracking offset), and an idle motion with higher breath amplitude. + +## Goals / Non-Goals + +**Goals:** +- Define named behavior states with associated motion/filter/parameter profiles +- Support state transitions with guards and hooks +- Integrate with MotionLayerSystem, FilterPipeline, and SemanticParameterLayer +- Provide a simple API: `fsm.transitionTo('happy')` + +**Non-Goals:** +- Not a full Hierarchical State Machine (no nested states for now) +- Not event-driven triggers (no complex event bus); transitions are explicit calls +- Not replacing motion file playback — states reference motions but don't define them + +## Decisions + +### States defined as data, not code + +Each state is a plain object (`BehaviorState`) describing what to activate, not a class with methods. This keeps states declarative and serializable. + +**Alternative**: Class-based states with `onEnter()` / `onExit()` methods. **Rejected** because data-driven states are easier to configure and extend. + +### BehaviorProfile is a snapshot of effects + +A `BehaviorProfile` captures: motion layer parameters, filter presets, and semantic parameter targets. When entering a state, the FSM "applies" this snapshot to the runtime systems. + +### Entry/Exit hooks are optional callbacks + +For extensibility, states support optional `onEnter` and `onExit` callbacks that receive the runtime systems as context. These are for custom logic that can't be expressed in the profile. + +### Transitions are immediate with optional crossfade + +When `transitionTo('newState')` is called, the old state's effects are stopped/reversed and the new state's effects are applied. If `crossfade` is enabled, the transition blends over time. + +## Risks / Trade-offs + +- **[Risk]** Multiple states trying to control the same motion layer could conflict. → **Mitigation**: Each state specifies which layers it controls; layers not mentioned by a state are left untouched. +- **[Risk]** State profiles could become large and unwieldy. → **Mitigation**: Support profile inheritance (base profile + state-specific overrides). +- **[Risk]** Rapid state transitions could cause visual chaos. → **Mitigation**: Enforce a minimum time between transitions (debounce), configurable per state. + +## Migration Plan + +1. Create `runtime/behavior/` module +2. Define built-in states and their profiles +3. Integrate into `Model` lifecycle +4. Expose `Model.getBehaviorFSM()` + +## Open Questions + +- Should states support animation sequences (e.g., enter `happy` → wait 2s → auto-transition to `idle`)? (Future enhancement.) +- Should states be hot-reloadable from config? (Yes, useful for tweaking without rebuild.) + +## Architecture + +``` +BehaviorFSM +├── states: Map +├── currentState: string +└── transitionTo(newState): void + ├── validate transition (guard) + ├── call currentState.onExit() + ├── apply currentState.exitProfile (reverse effects) + ├── call newState.onEnter() + └── apply newState.entryProfile + +BehaviorState +├── name: string +├── entryProfile: BehaviorProfile +├── exitProfile: BehaviorProfile (optional, to reverse effects) +├── onEnter?: (context) => void +├── onExit?: (context) => void +└── transitionGuard?: (from, to) => boolean + +BehaviorProfile +├── motionLayers: Record +├── filters: EffectPreset[] +├── semanticParameters: Record +└── proceduralOverrides: { module, enabled } +``` + +## Specs + +- `behavior-fsm`: Core FSM engine, state registry, transitions +- `behavior-profile`: Profile definition, application, reversal +- `state-transition-guards`: Guard evaluation, validation + +## Tasks + +### 1. Module Setup + +- [ ] 1.1 Create `packages/live2d/src/runtime/behavior/` directory +- [ ] 1.2 Define TypeScript interfaces: `BehaviorFSM`, `BehaviorState`, `BehaviorProfile`, `TransitionGuard` +- [ ] 1.3 Define built-in states: idle, thinking, talking, embarrassed, angry, sleepy, happy, sad + +### 2. Core FSM Implementation + +- [ ] 2.1 Implement `BehaviorFSM` class with state registry +- [ ] 2.2 Implement `transitionTo()` with guard validation +- [ ] 2.3 Implement state entry/exit hooks +- [ ] 2.4 Implement `getCurrentState()` and `canTransitionTo()` query APIs +- [ ] 2.5 Implement transition debounce (minimum time between transitions) + +### 3. Profile System + +- [ ] 3.1 Implement `BehaviorProfile` application to MotionLayerSystem +- [ ] 3.2 Implement `BehaviorProfile` application to FilterPipeline +- [ ] 3.3 Implement `BehaviorProfile` application to SemanticParameterLayer +- [ ] 3.4 Implement profile reversal on state exit +- [ ] 3.5 Support profile inheritance (base + override) + +### 4. Built-in States + +- [ ] 4.1 Define `idle` state profile +- [ ] 4.2 Define `happy` state profile +- [ ] 4.3 Define `sad` state profile +- [ ] 4.4 Define `angry` state profile +- [ ] 4.5 Define `embarrassed` state profile (blush filter) +- [ ] 4.6 Define `thinking` state profile +- [ ] 4.7 Define `talking` state profile +- [ ] 4.8 Define `sleepy` state profile + +### 5. Integration + +- [ ] 5.1 Add `behaviorFSM` property to `Model` class +- [ ] 5.2 Initialize FSM in `Model` lifecycle after MotionLayerSystem +- [ ] 5.3 Expose `Model.getBehaviorFSM()` public accessor +- [ ] 5.4 Add `behaviorFSM` config to `Live2dConfig` + +### 6. Testing + +- [ ] 6.1 Unit test: State transition with valid guard +- [ ] 6.2 Unit test: State transition blocked by invalid guard +- [ ] 6.3 Unit test: Entry profile applied on state enter +- [ ] 6.4 Unit test: Exit profile applied on state exit +- [ ] 6.5 Unit test: Transition debounce prevents rapid switching +- [ ] 6.6 Unit test: Profile inheritance works correctly +- [ ] 6.7 Integration test: Full state cycle idle → happy → embarrassed → idle diff --git a/openspec/changes/archive/2026-05-21-behavior-fsm/proposal.md b/openspec/changes/archive/2026-05-21-behavior-fsm/proposal.md new file mode 100644 index 0000000..9e28052 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-behavior-fsm/proposal.md @@ -0,0 +1,32 @@ +## Why + +Current Live2D models have no high-level behavioral concept. They play motions and expressions independently without a unifying state that describes "what the character is doing right now." A Behavior FSM (Finite State Machine) introduces states like `idle`, `thinking`, `talking`, `embarrassed`, `sleepy` — each mapping to a coherent combination of motion layers, expression parameters, and filter effects. This provides a declarative way to drive complex multi-system behavior from a single state transition. + +## What Changes + +- Introduce `BehaviorFSM` class with states, transitions, and transition guards +- Define built-in states: `idle`, `thinking`, `talking`, `embarrassed`, `angry`, `sleepy`, `happy`, `sad` +- Each state maps to a `BehaviorProfile`: which motion layers to activate, which semantic parameters to set, which filters to apply +- Support state entry/exit hooks for custom logic +- Support transition conditions (guards) that prevent invalid state changes +- Integrate with MotionLayerSystem (states play on specific layers) and FilterPipeline (states apply mood effects) +- Expose `transitionTo(state)` API that automatically manages motion layer playback and filter application + +## Capabilities + +### New Capabilities +- `behavior-fsm`: Finite state machine for high-level character behavior states +- `behavior-profile`: Per-state configuration mapping states to motion/filter/parameter effects +- `state-transition-guards`: Conditional transition rules preventing invalid state changes + +### Modified Capabilities +- `motion-layer-system`: States will use `play()` on specific motion layers when entering a state +- `runtime-filter-pipeline`: States will apply/change presets via `applyPreset()` on entry + +## Impact + +- **Frontend runtime**: New `packages/live2d/src/runtime/behavior/` directory +- **Motion layer system**: Used by FSM to play motions on state entry +- **Filter pipeline**: Used by FSM to apply mood presets on state entry +- **Semantic parameter layer**: Used by FSM to set expression parameters on state entry +- **Model class**: Gains `behaviorFSM` property when initialized diff --git a/openspec/changes/archive/2026-05-21-behavior-fsm/specs/behavior-fsm/spec.md b/openspec/changes/archive/2026-05-21-behavior-fsm/specs/behavior-fsm/spec.md new file mode 100644 index 0000000..5efb632 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-behavior-fsm/specs/behavior-fsm/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Register named behavior states +The system SHALL support registering named behavior states, each with an optional entry profile, exit profile, entry hook, exit hook, and transition guard. + +#### Scenario: Register idle state +- **WHEN** a state named `idle` is registered with an entry profile +- **THEN** the state is stored and can be transitioned to + +### Requirement: Transition between states +The system SHALL support transitioning from the current state to a target state via `transitionTo(target)`. + +#### Scenario: Valid transition +- **WHEN** the current state is `idle` +- **AND** `transitionTo("happy")` is called +- **THEN** the current state becomes `happy` + +#### Scenario: Same-state transition is a no-op +- **WHEN** the current state is `idle` +- **AND** `transitionTo("idle")` is called +- **THEN** no transition occurs + diff --git a/openspec/changes/archive/2026-05-21-behavior-fsm/specs/behavior-profile/spec.md b/openspec/changes/archive/2026-05-21-behavior-fsm/specs/behavior-profile/spec.md new file mode 100644 index 0000000..8f089c2 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-behavior-fsm/specs/behavior-profile/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: BehaviorProfile maps to runtime systems +A `BehaviorProfile` SHALL define effects on motion layers, filters, and semantic parameters. + +#### Scenario: Profile with motion layer parameters +- **WHEN** a profile specifies `{ talk: { mouthOpen: 0.8 } }` +- **AND** the profile is applied +- **THEN** `motionLayerSystem.play({ layer: "talk", parameters: { mouthOpen: 0.8 } })` is called + +#### Scenario: Profile with filter preset +- **WHEN** a profile specifies `filters: ["happy-glow"]` +- **AND** the profile is applied +- **THEN** `filterPipeline.applyPreset("happy-glow")` is called + +### Requirement: Profile reversal on state exit +The system SHALL support reversing a profile's effects on state exit. + +#### Scenario: Stop motion on exit +- **WHEN** a state with a talk layer motion is exited +- **THEN** the talk layer is stopped with fade out + +### Requirement: Profile inheritance +Profiles SHALL support inheritance from a base profile with state-specific overrides. + +#### Scenario: Inherit and override +- **WHEN** a base profile sets `idle: { breath: 0.1 }` +- **AND** a state profile inherits the base and overrides `idle: { breath: 0.2 }` +- **THEN** the effective profile uses `breath: 0.2` diff --git a/openspec/changes/archive/2026-05-21-behavior-fsm/specs/state-transition-guards/spec.md b/openspec/changes/archive/2026-05-21-behavior-fsm/specs/state-transition-guards/spec.md new file mode 100644 index 0000000..4cb308b --- /dev/null +++ b/openspec/changes/archive/2026-05-21-behavior-fsm/specs/state-transition-guards/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Guard functions prevent invalid transitions +The system SHALL support guard functions that return `boolean` to allow or block a transition. + +#### Scenario: Guard blocks transition +- **WHEN** a transition from `talking` to `sleepy` has a guard requiring `!isSpeaking` +- **AND** `isSpeaking` is `true` +- **THEN** `transitionTo("sleepy")` returns `false` and no transition occurs + +#### Scenario: Guard allows transition +- **WHEN** a transition guard returns `true` +- **THEN** `transitionTo()` returns `true` and the transition succeeds + +### Requirement: Query if transition is possible +The system SHALL provide `canTransitionTo(target)` to check if a transition would succeed without executing it. + +#### Scenario: Check transition validity +- **WHEN** `canTransitionTo("sleepy")` is called while speaking +- **THEN** it returns `false` diff --git a/openspec/changes/archive/2026-05-21-behavior-fsm/tasks.md b/openspec/changes/archive/2026-05-21-behavior-fsm/tasks.md new file mode 100644 index 0000000..29a1104 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-behavior-fsm/tasks.md @@ -0,0 +1,49 @@ +## 1. Module Setup + +- [x] 1.1 Create `packages/live2d/src/runtime/behavior/` directory structure +- [x] 1.2 Define TypeScript interfaces: `BehaviorFSM`, `BehaviorState`, `BehaviorProfile`, `TransitionGuard` +- [x] 1.3 Define built-in states: idle, thinking, talking, embarrassed, angry, sleepy, happy, sad + +## 2. Core FSM Implementation + +- [x] 2.1 Implement `BehaviorFSM` class with state registry +- [x] 2.2 Implement `transitionTo()` with guard validation +- [x] 2.3 Implement state entry/exit hooks +- [x] 2.4 Implement `getCurrentState()` and `canTransitionTo()` query APIs +- [x] 2.5 Implement transition debounce (minimum time between transitions) + +## 3. Profile System + +- [x] 3.1 Implement `BehaviorProfile` application to MotionLayerSystem +- [x] 3.2 Implement `BehaviorProfile` application to FilterPipeline +- [x] 3.3 Implement `BehaviorProfile` application to SemanticParameterLayer +- [x] 3.4 Implement profile reversal on state exit +- [x] 3.5 Support profile inheritance (base + override) + +## 4. Built-in States + +- [x] 4.1 Define `idle` state profile +- [x] 4.2 Define `happy` state profile +- [x] 4.3 Define `sad` state profile +- [x] 4.4 Define `angry` state profile +- [x] 4.5 Define `embarrassed` state profile (blush filter) +- [x] 4.6 Define `thinking` state profile +- [x] 4.7 Define `talking` state profile +- [x] 4.8 Define `sleepy` state profile + +## 5. Integration + +- [x] 5.1 Add `behaviorFSM` property to `Model` class +- [x] 5.2 Initialize FSM in `Model` lifecycle after MotionLayerSystem +- [x] 5.3 Expose `Model.getBehaviorFSM()` public accessor +- [x] 5.4 Add `behaviorFSM` config to `Live2dConfig` + +## 6. Testing + +- [x] 6.1 Unit test: State transition with valid guard +- [x] 6.2 Unit test: State transition blocked by invalid guard +- [x] 6.3 Unit test: Entry profile applied on state enter +- [x] 6.4 Unit test: Exit profile applied on state exit +- [x] 6.5 Unit test: Transition debounce prevents rapid switching +- [x] 6.6 Unit test: Profile inheritance works correctly +- [x] 6.7 Integration test: Full state cycle idle -> happy -> embarrassed -> idle diff --git a/openspec/changes/integrate-modern-live2d-frontend/.openspec.yaml b/openspec/changes/archive/2026-05-21-emotion-timeline/.openspec.yaml similarity index 50% rename from openspec/changes/integrate-modern-live2d-frontend/.openspec.yaml rename to openspec/changes/archive/2026-05-21-emotion-timeline/.openspec.yaml index 66dd08a..8b76914 100644 --- a/openspec/changes/integrate-modern-live2d-frontend/.openspec.yaml +++ b/openspec/changes/archive/2026-05-21-emotion-timeline/.openspec.yaml @@ -1,2 +1,2 @@ schema: spec-driven -created: 2026-05-14 +created: 2026-05-20 diff --git a/openspec/changes/archive/2026-05-21-emotion-timeline/design.md b/openspec/changes/archive/2026-05-21-emotion-timeline/design.md new file mode 100644 index 0000000..01d7601 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-emotion-timeline/design.md @@ -0,0 +1,121 @@ +## Context + +Expression changes in Live2D are currently instantaneous jumps: a parameter goes from value A to value B in a single frame. This is unnatural — real emotions transition gradually. The Emotion Timeline system provides smooth interpolation between emotional states, making expression changes feel organic and lifelike. + +This system builds on top of the Semantic Parameter Layer (which provides uniform parameter access) and will later be driven by AI output (emotion labels extracted from LLM responses). + +## Goals / Non-Goals + +**Goals:** +- Define named emotions mapped to semantic parameter target values +- Support smooth interpolation between emotions over configurable duration +- Support easing curves for natural acceleration/deceleration +- Support emotion interrupting (new target overrides ongoing transition) +- Auto-return to `neutral` after configurable timeout +- Integrate with FilterPipeline for emotion-driven color grading + +**Non-Goals:** +- Not a full animation sequencer with keyframes +- Not physics-based emotion dynamics (no momentum/bounce) +- Not facial capture input + +## Decisions + +### Emotion registry is a static mapping + +A `EMOTION_REGISTRY` object maps emotion names to `{ semantic: value }` targets. This is loaded at initialization and can be extended at runtime. + +**Alternative**: Dynamic emotion discovery. **Rejected** because a predefined registry is sufficient and easier to reason about. + +### Transitions use the current interpolated value as the starting point + +When a new transition starts mid-transition, the starting value is the current interpolated position, not the original start value. This prevents jumps. + +### Minimum transition duration is enforced + +Default 300ms. This prevents jitter from rapid emotion commands (e.g., from a streaming AI that changes its mind). + +### Color grading is linked to the dominant emotion + +Each emotion can specify a filter preset. During transition, the filter preset crossfades from the old emotion to the new one. + +## Risks / Trade-offs + +- **[Risk]** Parameter interpolation may conflict with motion layer output. → **Mitigation**: Emotion timeline writes to the `expression` motion layer, so it participates in the normal layer blending system. +- **[Risk]** Filter preset crossfading may be expensive. → **Mitigation**: Only update filter intensity once per frame, not per parameter. +- **[Risk]** Auto-return to neutral may feel artificial. → **Mitigation**: Configurable idle timeout; can be disabled per emotion. + +## Architecture + +``` +EmotionTimeline +├── registry: Map +├── currentState: { emotion, intensity, startTime } +├── transition: { from, to, duration, easing, progress } +├── idleTimer?: number +└── transitionTo(emotion, duration?, easing?): void + +EmotionProfile +├── parameters: Record +├── filterPreset?: EffectPreset +├── filterIntensity?: number +└── idleTimeout?: number (auto-return to neutral after this ms) +``` + +## Specs + +- `emotion-timeline`: Core interpolation engine, transition management +- `emotion-registry`: Emotion-to-parameter mapping definitions +- `transition-scheduler`: Queue, interrupt, and blend overlapping transitions + +## Tasks + +### 1. Module Setup + +- [ ] 1.1 Create `packages/live2d/src/runtime/emotion/` directory +- [ ] 1.2 Define TypeScript interfaces: `EmotionTimeline`, `EmotionProfile`, `TransitionState` +- [ ] 1.3 Define easing support for transitions + +### 2. Core Timeline Implementation + +- [ ] 2.1 Implement `EmotionTimeline` class with registry +- [ ] 2.2 Implement `transitionTo()` with configurable duration and easing +- [ ] 2.3 Implement interpolation loop (current value → target value over time) +- [ ] 2.4 Implement interrupt handling (new transition mid-transition) +- [ ] 2.5 Implement minimum transition duration enforcement +- [ ] 2.6 Implement auto-return to neutral after idle timeout + +### 3. Emotion Registry + +- [ ] 3.1 Create default `EMOTION_REGISTRY` with 8 built-in emotions +- [ ] 3.2 Map `neutral` emotion (all parameters at default) +- [ ] 3.3 Map `happy` emotion (eyeSmile, mouthOpen, cheek) +- [ ] 3.4 Map `sad` emotion (browY, eyeScale, mouthForm) +- [ ] 3.5 Map `angry` emotion (browAngle, eyeOpen, mouthOpen) +- [ ] 3.6 Map `shy` / `embarrassed` emotion (eyeScale, cheek, headAngle) +- [ ] 3.7 Map `surprised` emotion (eyeOpen, mouthOpen, browY) +- [ ] 3.8 Map `sleepy` emotion (eyeOpen, headAngle, breath) +- [ ] 3.9 Support runtime registration of custom emotions + +### 4. Filter Integration + +- [ ] 4.1 Link emotion profiles to filter presets +- [ ] 4.2 Crossfade filter intensity during emotion transitions +- [ ] 4.3 Support per-emotion filter intensity override + +### 5. Integration + +- [ ] 5.1 Add `emotionTimeline` property to `Model` class +- [ ] 5.2 Initialize timeline in `Model` lifecycle +- [ ] 5.3 Expose `Model.getEmotionTimeline()` public accessor +- [ ] 5.4 Add `emotionTimeline` config to `Live2dConfig` + +### 6. Testing + +- [ ] 6.1 Unit test: Transition from neutral to happy over 500ms +- [ ] 6.2 Unit test: Parameter values interpolate correctly at 50% +- [ ] 6.3 Unit test: Interrupt mid-transition uses current value as start +- [ ] 6.4 Unit test: Minimum duration prevents instant transitions +- [ ] 6.5 Unit test: Auto-return to neutral after idle timeout +- [ ] 6.6 Unit test: Custom emotion registration works +- [ ] 6.7 Integration test: Full transition cycle neutral → happy → sad → neutral diff --git a/openspec/changes/archive/2026-05-21-emotion-timeline/proposal.md b/openspec/changes/archive/2026-05-21-emotion-timeline/proposal.md new file mode 100644 index 0000000..1923371 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-emotion-timeline/proposal.md @@ -0,0 +1,33 @@ +## Why + +Current expression changes in Live2D are instantaneous: `setSemantic('mouthOpen', 0.8)` jumps directly to the target value. This produces robotic, unnatural transitions. An Emotion Timeline system enables smooth interpolation between emotional states over time — e.g., transitioning from `neutral` to `happy` over 800ms with parameter curves, making the character feel alive and emotionally coherent. + +## What Changes + +- Introduce `EmotionTimeline` class that manages transitions between named emotional states +- Each emotion maps to a set of semantic parameter target values (e.g., `happy` → `{ eyeSmile: 0.7, mouthOpen: 0.3, cheek: 0.4 }`) +- Support configurable transition duration and easing curves +- Support interruptible transitions (new emotion target can override ongoing transition) +- Minimum transition duration enforced (default 300ms) to prevent jitter +- Auto-return to `neutral` after a configurable idle timeout +- Integrate with SemanticParameterLayer for parameter output +- Integrate with FilterPipeline for emotion-driven color grading + +## Capabilities + +### New Capabilities +- `emotion-timeline`: Smooth parameter interpolation between named emotional states +- `emotion-registry`: Mapping from emotion names to semantic parameter target values +- `transition-scheduler`: Queue and manage overlapping/interrupted transitions + +### Modified Capabilities +- `semantic-parameter-layer`: Timeline will write parameters via `setSemantic()` during interpolation +- `runtime-filter-pipeline`: Timeline will apply color-grading presets matching the target emotion + +## Impact + +- **Frontend runtime**: New `packages/live2d/src/runtime/emotion/` directory +- **Semantic layer**: Timeline drives parameter values during transitions +- **Filter pipeline**: Timeline applies mood presets for emotional atmosphere +- **Model class**: Gains `emotionTimeline` property when initialized +- **Future AI Hooks**: AI emotion output will directly trigger timeline transitions diff --git a/openspec/changes/archive/2026-05-21-emotion-timeline/specs/emotion-registry/spec.md b/openspec/changes/archive/2026-05-21-emotion-timeline/specs/emotion-registry/spec.md new file mode 100644 index 0000000..91d9119 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-emotion-timeline/specs/emotion-registry/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Built-in emotion registry +The system SHALL ship with a default registry mapping 8 emotion names to semantic parameter target values. + +#### Scenario: Built-in emotions exist +- **WHEN** the system initializes +- **THEN** emotions `neutral`, `happy`, `sad`, `angry`, `shy`, `surprised`, `sleepy`, and `embarrassed` are registered + +### Requirement: Register custom emotions +The system SHALL support registering custom emotions at runtime. + +#### Scenario: Custom emotion +- **WHEN** `registerEmotion("excited", { eyeSmile: 0.9, mouthOpen: 0.6 })` is called +- **THEN** `transitionTo("excited")` uses the registered parameter targets + +### Requirement: Emotion maps to filter preset +Each emotion SHALL optionally specify a filter preset. + +#### Scenario: Happy emotion with warm glow +- **WHEN** `happy` emotion specifies `filterPreset: "happy-glow"` +- **AND** `transitionTo("happy")` is called +- **THEN** the filter preset is applied during the transition diff --git a/openspec/changes/archive/2026-05-21-emotion-timeline/specs/emotion-timeline/spec.md b/openspec/changes/archive/2026-05-21-emotion-timeline/specs/emotion-timeline/spec.md new file mode 100644 index 0000000..6fd4b8c --- /dev/null +++ b/openspec/changes/archive/2026-05-21-emotion-timeline/specs/emotion-timeline/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: Transition between named emotions +The system SHALL support `transitionTo(emotion, duration?, easing?)` to interpolate from the current emotional state to a target emotion. + +#### Scenario: Neutral to happy transition +- **WHEN** `transitionTo("happy", 800)` is called from `neutral` +- **THEN** over 800ms, all parameters smoothly interpolate from neutral values to happy values + +#### Scenario: Default duration +- **WHEN** `transitionTo("happy")` is called without specifying duration +- **THEN** the default duration (500ms) is used + +### Requirement: Enforce minimum transition duration +The system SHALL enforce a minimum transition duration (default 300ms) to prevent jitter. + +#### Scenario: Too-short duration is clamped +- **WHEN** `transitionTo("happy", 50)` is called +- **THEN** the actual transition duration is clamped to 300ms + +### Requirement: Support transition interrupt +A new `transitionTo()` call during an ongoing transition SHALL use the current interpolated values as the new starting point. + +#### Scenario: Interrupt mid-transition +- **WHEN** a transition from `neutral` to `happy` is at 50% (halfway) +- **AND** `transitionTo("sad")` is called +- **THEN** the new transition starts from the current halfway values toward `sad` + diff --git a/openspec/changes/archive/2026-05-21-emotion-timeline/specs/transition-scheduler/spec.md b/openspec/changes/archive/2026-05-21-emotion-timeline/specs/transition-scheduler/spec.md new file mode 100644 index 0000000..d55a9ba --- /dev/null +++ b/openspec/changes/archive/2026-05-21-emotion-timeline/specs/transition-scheduler/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Schedule and execute transitions +The system SHALL manage a transition queue and execute the active transition each frame. + +#### Scenario: Active transition updates +- **WHEN** a transition is active with duration 500ms +- **AND** 250ms have elapsed +- **THEN** all affected parameters are at approximately 50% between start and target + +### Requirement: Auto-return to neutral after idle +The system SHALL automatically transition back to `neutral` after a configurable idle timeout. + +#### Scenario: Auto-return +- **WHEN** `transitionTo("happy")` is called with the happy profile specifying `idleTimeout: 2000` +- **AND** no new transition occurs for 2000ms +- **THEN** the system automatically transitions back to `neutral` + +#### Scenario: Disable auto-return +- **WHEN** an emotion profile specifies `idleTimeout: null` +- **THEN** the system does not auto-return to neutral + +### Requirement: Transition completion callback +The system SHALL support an optional callback invoked when a transition completes. + +#### Scenario: Callback on completion +- **WHEN** `transitionTo("happy", 500, undefined, onComplete)` is called +- **AND** the transition completes +- **THEN** `onComplete` is invoked diff --git a/openspec/changes/archive/2026-05-21-emotion-timeline/tasks.md b/openspec/changes/archive/2026-05-21-emotion-timeline/tasks.md new file mode 100644 index 0000000..c497098 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-emotion-timeline/tasks.md @@ -0,0 +1,49 @@ +## 1. Module Setup + +- [x] 1.1 Create `packages/live2d/src/runtime/emotion/` directory structure +- [x] 1.2 Define TypeScript interfaces: `EmotionTimeline`, `EmotionProfile`, `TransitionState` +- [x] 1.3 Define easing support for transitions + +## 2. Core Timeline Implementation + +- [x] 2.1 Implement `EmotionTimeline` class with registry +- [x] 2.2 Implement `transitionTo()` with configurable duration and easing +- [x] 2.3 Implement interpolation loop (current value -> target value over time) +- [x] 2.4 Implement interrupt handling (new transition mid-transition) +- [x] 2.5 Implement minimum transition duration enforcement +- [x] 2.6 Implement auto-return to neutral after idle timeout + +## 3. Emotion Registry + +- [x] 3.1 Create default `EMOTION_REGISTRY` with 8 built-in emotions +- [x] 3.2 Map `neutral` emotion (all parameters at default) +- [x] 3.3 Map `happy` emotion (eyeSmile, mouthOpen, cheek) +- [x] 3.4 Map `sad` emotion (browY, eyeScale, mouthForm) +- [x] 3.5 Map `angry` emotion (browAngle, eyeOpen, mouthOpen) +- [x] 3.6 Map `shy` / `embarrassed` emotion (eyeScale, cheek, headAngle) +- [x] 3.7 Map `surprised` emotion (eyeOpen, mouthOpen, browY) +- [x] 3.8 Map `sleepy` emotion (eyeOpen, headAngle, breath) +- [x] 3.9 Support runtime registration of custom emotions + +## 4. Filter Integration + +- [x] 4.1 Link emotion profiles to filter presets +- [x] 4.2 Crossfade filter intensity during emotion transitions +- [x] 4.3 Support per-emotion filter intensity override + +## 5. Integration + +- [x] 5.1 Add `emotionTimeline` property to `Model` class +- [x] 5.2 Initialize timeline in `Model` lifecycle +- [x] 5.3 Expose `Model.getEmotionTimeline()` public accessor +- [x] 5.4 Add `emotionTimeline` config to `Live2dConfig` + +## 6. Testing + +- [x] 6.1 Unit test: Transition from neutral to happy over 500ms +- [x] 6.2 Unit test: Parameter values interpolate correctly at 50% +- [x] 6.3 Unit test: Interrupt mid-transition uses current value as start +- [x] 6.4 Unit test: Minimum duration prevents instant transitions +- [x] 6.5 Unit test: Auto-return to neutral after idle timeout +- [x] 6.6 Unit test: Custom emotion registration works +- [x] 6.7 Integration test: Full transition cycle neutral -> happy -> sad -> neutral diff --git a/openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/.openspec.yaml b/openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/.openspec.yaml similarity index 100% rename from openspec/changes/archive/2026-05-14-complete-live2d-modernization-parity/.openspec.yaml rename to openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/.openspec.yaml diff --git a/openspec/changes/integrate-modern-live2d-frontend/design.md b/openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/design.md similarity index 100% rename from openspec/changes/integrate-modern-live2d-frontend/design.md rename to openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/design.md diff --git a/openspec/changes/integrate-modern-live2d-frontend/proposal.md b/openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/proposal.md similarity index 100% rename from openspec/changes/integrate-modern-live2d-frontend/proposal.md rename to openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/proposal.md diff --git a/openspec/changes/integrate-modern-live2d-frontend/specs/halo-plugin-frontend-integration/spec.md b/openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/specs/halo-plugin-frontend-integration/spec.md similarity index 100% rename from openspec/changes/integrate-modern-live2d-frontend/specs/halo-plugin-frontend-integration/spec.md rename to openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/specs/halo-plugin-frontend-integration/spec.md diff --git a/openspec/changes/integrate-modern-live2d-frontend/specs/live2d-custom-tool-actions/spec.md b/openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/specs/live2d-custom-tool-actions/spec.md similarity index 100% rename from openspec/changes/integrate-modern-live2d-frontend/specs/live2d-custom-tool-actions/spec.md rename to openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/specs/live2d-custom-tool-actions/spec.md diff --git a/openspec/changes/integrate-modern-live2d-frontend/specs/live2d-public-runtime-config/spec.md b/openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/specs/live2d-public-runtime-config/spec.md similarity index 100% rename from openspec/changes/integrate-modern-live2d-frontend/specs/live2d-public-runtime-config/spec.md rename to openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/specs/live2d-public-runtime-config/spec.md diff --git a/openspec/changes/integrate-modern-live2d-frontend/specs/multi-cubism-live2d-rendering/spec.md b/openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/specs/multi-cubism-live2d-rendering/spec.md similarity index 100% rename from openspec/changes/integrate-modern-live2d-frontend/specs/multi-cubism-live2d-rendering/spec.md rename to openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/specs/multi-cubism-live2d-rendering/spec.md diff --git a/openspec/changes/integrate-modern-live2d-frontend/tasks.md b/openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/tasks.md similarity index 100% rename from openspec/changes/integrate-modern-live2d-frontend/tasks.md rename to openspec/changes/archive/2026-05-21-integrate-modern-live2d-frontend/tasks.md diff --git a/openspec/changes/archive/2026-05-21-motion-layer-system/.openspec.yaml b/openspec/changes/archive/2026-05-21-motion-layer-system/.openspec.yaml new file mode 100644 index 0000000..8b76914 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-motion-layer-system/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-20 diff --git a/openspec/changes/archive/2026-05-21-motion-layer-system/design.md b/openspec/changes/archive/2026-05-21-motion-layer-system/design.md new file mode 100644 index 0000000..fab890d --- /dev/null +++ b/openspec/changes/archive/2026-05-21-motion-layer-system/design.md @@ -0,0 +1,73 @@ +## Context + +plugin-live2d currently supports motion playback through `untitled-pixi-live2d-engine`'s built-in motion manager (`model.motion()`), but this is a single-track system: playing a new motion interrupts the current one. The recently implemented `ProceduralAnimationSystem` also writes directly to `SemanticParameterLayer`, which means procedural animations and motion playback can conflict on the same parameters. + +A Motion Layer System is needed to: +1. Enable concurrent motion playback on separate tracks +2. Resolve parameter conflicts between layers via priority and blend modes +3. Provide smooth transitions between motion states via fade envelopes + +## Goals / Non-Goals + +**Goals:** +- Support 5 standard motion layers: `idle`, `expression`, `talk`, `gesture`, `physics` +- Each layer has its own `MotionTrack` with independent playback, fade, and priority +- Cross-layer blending: resolve parameter conflicts by priority (`override` > `add`) +- Fade transitions: `fadeIn` / `fadeOut` with configurable duration +- Interrupt rules: tracks can be marked `interruptible`, higher priority can override +- State queries: inspect active layers, playback progress, pending transitions +- Integrate with `ProceduralAnimationSystem` (procedural outputs to `physics` layer) + +**Non-Goals:** +- Not a full animation editor or timeline sequencer +- Not skeletal IK (out of scope, requires Cubism 5 SDK) +- Not real-time physics simulation (cloth, hair) +- Not replacing the engine's native motion parser (`.motion3.json` loading remains unchanged) + +## Decisions + +### Layer output goes through SemanticParameterLayer + +After all layers compute their parameter outputs, the `MotionLayerSystem` writes to `SemanticParameterLayer` using the same semantic names. This maintains the abstraction: motion layers don't need to know actual parameter IDs. + +**Alternative**: Write directly to model core. **Rejected** because it bypasses the semantic abstraction and would re-introduce model-specific parameter dependencies. + +### Fade envelope is per-track, not global + +Each `MotionTrack` manages its own fade state (`fadingIn`, `active`, `fadingOut`). This allows one layer to fade out while another fades in independently. + +**Alternative**: Global crossfader that manages all layers. **Rejected** because per-track fading is more flexible (e.g., idle layer stays active while talk layer fades in/out). + +### `physics` layer is the base layer for procedural animation + +The `ProceduralAnimationSystem`'s modules (breathing, eye tracking) will output to the `physics` layer at the lowest priority. This ensures procedural animations are always active unless explicitly overridden by a higher-priority layer. + +**Alternative**: Procedural system stays independent. **Rejected** because without layering, procedural and motion outputs would continue to conflict on shared parameters. + +### Interrupt rules use priority + explicit flag + +A track can be interrupted if: +1. The incoming track has higher priority, OR +2. The current track has `interruptible: true` + +Priority levels: `0` (physics) < `1` (idle) < `2` (expression) < `3` (talk) < `4` (gesture) < `10` (force override) + +## Risks / Trade-offs + +- **[Risk]** Multiple active layers with overlapping parameters could cause visual glitches if blend weights sum incorrectly. → **Mitigation**: Normalize blend weights per parameter when multiple `add` layers target the same semantic. +- **[Risk]** Fade transitions may conflict with the engine's native motion fading. → **Mitigation**: Disable engine-level fading when Motion Layer System is active; our fade envelope replaces it. +- **[Risk]** Performance overhead from computing multiple layers every frame. → **Mitigation**: Skip inactive layers; only update layers whose `weight > 0` or that have active motions. +- **[Risk]** ProceduralAnimationSystem refactor could break existing behavior. → **Mitigation**: Keep the old direct-write path as fallback when motion layers are disabled. + +## Migration Plan + +1. Create `runtime/motion/` module with `MotionLayerSystem`, `MotionTrack`, `FadeEnvelope` +2. Refactor `ProceduralAnimationSystem` to output to `physics` layer instead of direct semantic writes +3. Integrate into `Model` lifecycle: initialize after `ProceduralAnimationSystem` +4. Add `model.getMotionLayerSystem()` public accessor +5. Keep existing `.motion()` API backward compatible (routes to `idle` layer with default priority) + +## Open Questions + +- Should layers support dynamic creation (user-defined layers beyond the 5 standard ones)? (No for now — 5 layers cover all use cases.) +- Should fade curves be configurable (linear, easeIn, easeOut)? (Yes, default easeInOut, configurable per-play call.) diff --git a/openspec/changes/archive/2026-05-21-motion-layer-system/proposal.md b/openspec/changes/archive/2026-05-21-motion-layer-system/proposal.md new file mode 100644 index 0000000..b0bd55d --- /dev/null +++ b/openspec/changes/archive/2026-05-21-motion-layer-system/proposal.md @@ -0,0 +1,33 @@ +## Why + +Traditional Live2D runtimes allow only one active motion at a time. This means a model cannot simultaneously breathe (idle), speak (lip sync), express emotion, and gesture — motions interrupt each other instead of layering. A Motion Layer System enables parallel motion tracks with priority-based blending, fade transitions, and interrupt rules, bringing plugin-live2d from a "single-motion player" to a "multi-track animation runtime." + +## What Changes + +- Introduce `MotionLayerSystem` with named layers: `idle`, `expression`, `talk`, `gesture`, `physics` +- Implement `MotionTrack` per layer with its own playback state, fade envelope, and priority +- Implement cross-layer parameter blending: higher priority `override` wins, same-priority `add` blends +- Support fadeIn / fadeOut / crossfade transitions per track (duration configurable) +- Support interrupt rules: `interruptible` flag on tracks, `force` priority for emergency overrides +- Provide `play({ layer, motion, priority, fadeIn, blend })` API +- Expose layer state query API: `getLayerState(layer)`, `isPlaying(layer)`, `getActiveLayers()` +- Integrate with existing `SemanticParameterLayer` for parameter output + +## Capabilities + +### New Capabilities +- `motion-layer-system`: Multi-track parallel motion playback with priority and blending +- `motion-track`: Single-layer motion track with fade envelope and lifecycle +- `cross-layer-blending`: Parameter blending rules across concurrent layers +- `fade-transitions`: Smooth fadeIn/fadeOut/crossfade between motion states + +### Modified Capabilities +- `procedural-animation-system`: Procedural modules (breathing, eye tracking) will output to the `physics` layer as a base layer instead of directly writing to `SemanticParameterLayer`, ensuring they blend correctly with motion playback + +## Impact + +- **Frontend runtime**: New `packages/live2d/src/runtime/motion/` directory +- **Procedural animation**: Modules will output to Motion Layer System instead of directly to Semantic Layer +- **Model class**: Gains `motionLayerSystem` property, initialized after `ProceduralAnimationSystem` +- **Existing motion playback**: Current `.motion()` API remains backward compatible (maps to a default layer) +- **Future AI Hooks**: AI commands will target specific motion layers (e.g., `talk` layer for speaking motions) diff --git a/openspec/changes/archive/2026-05-21-motion-layer-system/specs/cross-layer-blending/spec.md b/openspec/changes/archive/2026-05-21-motion-layer-system/specs/cross-layer-blending/spec.md new file mode 100644 index 0000000..b74d990 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-motion-layer-system/specs/cross-layer-blending/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Override blend mode +When a higher-priority track uses `blend: "override"` on a parameter, it SHALL replace the value from all lower-priority tracks. + +#### Scenario: Gesture override +- **WHEN** the `gesture` layer (priority 4) sets `angleX` to 20 with `override` +- **AND** the `idle` layer (priority 1) sets `angleX` to 5 +- **THEN** the final `angleX` value is 20 + +### Requirement: Add blend mode +When multiple tracks use `blend: "add"` on the same parameter, their values SHALL be summed. + +#### Scenario: Physics叠加 +- **WHEN** the `physics` layer adds `0.1` to `breath` +- **AND** another layer also adds `0.05` to `breath` +- **THEN** the final `breath` value includes `0.15` from add layers + +### Requirement: Blend weight normalization +When multiple `add` layers target the same parameter, the system SHALL normalize their weights if the sum exceeds a safe threshold. + +#### Scenario: Weight normalization +- **WHEN** three layers each add `1.0` to the same parameter +- **THEN** the system normalizes so the total add contribution does not exceed `1.0` diff --git a/openspec/changes/archive/2026-05-21-motion-layer-system/specs/fade-transitions/spec.md b/openspec/changes/archive/2026-05-21-motion-layer-system/specs/fade-transitions/spec.md new file mode 100644 index 0000000..e7f0cb3 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-motion-layer-system/specs/fade-transitions/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Fade-in on motion start +When a motion begins playing, the track SHALL optionally fade in from weight `0` to weight `1` over a configurable duration. + +#### Scenario: Smooth fade in +- **WHEN** `play({ layer: "talk", fadeIn: 300 })` is called +- **THEN** over 300ms, the talk track's weight increases from `0` to `1` + +### Requirement: Fade-out on motion end +When a motion ends or is stopped, the track SHALL optionally fade out from weight `1` to weight `0`. + +#### Scenario: Smooth fade out +- **WHEN** a track with `fadeOut: 200` stops playing +- **THEN** over 200ms, the track's weight decreases from `1` to `0` + +### Requirement: Crossfade between consecutive motions +When a new motion replaces an existing motion on the same layer, the system SHALL support crossfade: the old motion fades out while the new one fades in. + +#### Scenario: Expression crossfade +- **WHEN** expression A is active +- **AND** expression B is played on the same layer with crossfade +- **THEN** expression A fades out while expression B fades in + +### Requirement: Configurable fade curve +The fade curve SHALL be configurable: `linear`, `easeIn`, `easeOut`, `easeInOut`. + +#### Scenario: Ease-in fade +- **WHEN** `play({ fadeIn: 500, fadeCurve: "easeIn" })` is called +- **THEN** the fade starts slowly and accelerates diff --git a/openspec/changes/archive/2026-05-21-motion-layer-system/specs/motion-layer-system/spec.md b/openspec/changes/archive/2026-05-21-motion-layer-system/specs/motion-layer-system/spec.md new file mode 100644 index 0000000..ac0dad4 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-motion-layer-system/specs/motion-layer-system/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Support named motion layers +The system SHALL support at minimum these named layers: `idle`, `expression`, `talk`, `gesture`, `physics`. + +#### Scenario: Create layers on initialization +- **WHEN** the `MotionLayerSystem` is initialized +- **THEN** all 5 standard layers are created with default priorities + +### Requirement: Play motion on a specific layer +The system SHALL provide a `play()` method that accepts `layer`, `motion`, `priority`, `fadeIn`, and `blend` options. + +#### Scenario: Play motion on talk layer +- **WHEN** `play({ layer: "talk", motion: greetingMotion, priority: 3 })` is called +- **THEN** the talk layer begins playing the motion + +### Requirement: Query layer state +The system SHALL provide methods to inspect the current state of each layer. + +#### Scenario: Check if layer is active +- **WHEN** `isPlaying("talk")` is called while the talk layer has an active motion +- **THEN** it returns `true` + +#### Scenario: List active layers +- **WHEN** `getActiveLayers()` is called while idle and talk layers are active +- **THEN** it returns `["idle", "talk"]` + diff --git a/openspec/changes/archive/2026-05-21-motion-layer-system/specs/motion-track/spec.md b/openspec/changes/archive/2026-05-21-motion-layer-system/specs/motion-track/spec.md new file mode 100644 index 0000000..535bfa9 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-motion-layer-system/specs/motion-track/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Independent playback per track +Each `MotionTrack` SHALL maintain its own playback state independently of other tracks. + +#### Scenario: Concurrent playback +- **WHEN** the `idle` track is playing an idle motion +- **AND** the `talk` track begins playing a talk motion +- **THEN** both tracks continue playing simultaneously + +### Requirement: Track priority system +Each track SHALL have a priority level. Higher priority tracks override lower priority tracks on conflicting parameters. + +#### Scenario: Gesture overrides idle head movement +- **WHEN** the `idle` track is affecting `angleX` at priority 1 +- **AND** the `gesture` track begins affecting `angleX` at priority 4 +- **THEN** the `gesture` track's value takes precedence for `angleX` + +### Requirement: Interruptible flag +A track SHALL support an `interruptible` flag. If `false`, the track cannot be stopped by lower-priority incoming tracks. + +#### Scenario: Non-interruptible expression +- **WHEN** an expression track is playing with `interruptible: false` +- **AND** a lower-priority motion attempts to play on the same layer +- **THEN** the new motion is queued or rejected, not interrupting the current one + +### Requirement: Track lifecycle states +A track SHALL have states: `idle`, `fadingIn`, `active`, `fadingOut`, `stopped`. + +#### Scenario: Track state transitions +- **WHEN** a motion starts playing +- **THEN** the track transitions from `idle` → `fadingIn` → `active` +- **AND** when the motion ends or is stopped, it transitions `active` → `fadingOut` → `stopped` diff --git a/openspec/changes/archive/2026-05-21-motion-layer-system/tasks.md b/openspec/changes/archive/2026-05-21-motion-layer-system/tasks.md new file mode 100644 index 0000000..fd42864 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-motion-layer-system/tasks.md @@ -0,0 +1,49 @@ +## 1. Module Setup + +- [x] 1.1 Create `packages/live2d/src/runtime/motion/` directory structure +- [x] 1.2 Define TypeScript interfaces: `MotionLayer`, `MotionTrack`, `FadeEnvelope`, `LayerState` +- [x] 1.3 Define standard layer priorities and default configurations + +## 2. Core System Implementation + +- [x] 2.1 Implement `MotionLayerSystem` class with layer registry +- [x] 2.2 Implement `MotionTrack` with independent playback state and lifecycle +- [x] 2.3 Implement `FadeEnvelope` with configurable duration and curve +- [x] 2.4 Implement cross-layer parameter blending (override vs add, weight normalization) +- [x] 2.5 Implement interrupt rules (priority check + interruptible flag) +- [x] 2.6 Implement layer state query API (`isPlaying`, `getActiveLayers`, `getLayerState`) +- [x] 2.7 Integrate with `SemanticParameterLayer` for final parameter output + +## 3. Integration with Existing Systems + +- [x] 3.1 Refactor `ProceduralAnimationSystem` to output through `physics` layer +- [x] 3.2 Add fallback direct-write path when `MotionLayerSystem` is disabled +- [x] 3.3 Add `motionLayerSystem` property to `Model` class +- [x] 3.4 Initialize `MotionLayerSystem` in `Model` lifecycle +- [x] 3.5 Expose `Model.getMotionLayerSystem()` public accessor + +## 4. API Design + +- [x] 4.1 Implement `play({ layer, motion, priority, fadeIn, fadeOut, blend, interruptible })` +- [x] 4.2 Implement `stop(layer)` with optional fadeOut +- [x] 4.3 Implement `crossfade({ layer, from, to, duration })` +- [x] 4.4 Keep existing `.motion()` API backward compatible (route to `idle` layer) + +## 5. Configuration + +- [x] 5.1 Add `motionLayers` config to `Live2dConfig`: enable/disable system +- [x] 5.2 Add per-layer default priority and fade duration config +- [x] 5.3 Add global crossfade default duration config + +## 6. Testing + +- [x] 6.1 Unit test: Concurrent playback on idle + talk layers +- [x] 6.2 Unit test: Higher priority overrides lower priority on same parameter +- [x] 6.3 Unit test: Add blend mode sums values from multiple layers +- [x] 6.4 Unit test: Fade-in weight increases over configured duration +- [x] 6.5 Unit test: Fade-out weight decreases over configured duration +- [x] 6.6 Unit test: Crossfade between consecutive motions on same layer +- [x] 6.7 Unit test: Non-interruptible track rejects lower-priority incoming motion +- [x] 6.8 Unit test: Procedural animation outputs to physics layer when motion layers enabled +- [x] 6.9 Unit test: Backward compatibility — direct write when motion layers disabled +- [x] 6.10 Integration test: Full layer stack (idle + expression + talk + gesture + physics) diff --git a/openspec/changes/archive/2026-05-21-procedural-animation-system/.openspec.yaml b/openspec/changes/archive/2026-05-21-procedural-animation-system/.openspec.yaml new file mode 100644 index 0000000..8b76914 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-procedural-animation-system/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-20 diff --git a/openspec/changes/archive/2026-05-21-procedural-animation-system/design.md b/openspec/changes/archive/2026-05-21-procedural-animation-system/design.md new file mode 100644 index 0000000..245d347 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-procedural-animation-system/design.md @@ -0,0 +1,73 @@ +## Context + +plugin-live2d's `Model` class renders Live2D models via PixiJS v8 but currently does not add any ambient animation. Models with motion files play them, but many models (especially Cubism 2 and user-created ones) have limited or no motions. Even well-equipped models benefit from subtle procedural behaviors — breathing, natural blinking, eye tracking — that make the character feel alive during idle periods. + +The Pixi `Application` provides a `ticker` that runs every frame. `untitled-pixi-live2d-engine` accepts this ticker in `Live2DModel.from({ ticker })`, meaning model parameter updates can be synchronized with Pixi's render loop. + +## Goals / Non-Goals + +**Goals:** +- Implement a modular procedural animation system driven by Pixi ticker +- Provide built-in ambient modules: breathing, blinking, eye/cursor tracking +- Each module outputs semantic parameter changes (via Semantic Parameter Layer) +- Support runtime module registration/unregistration +- Provide a one-shot animation API for scripted procedural motions (spring, ease) +- Integrate cleanly with model lifecycle: attach on ready, detach on destroy + +**Non-Goals:** +- Not a replacement for motion file playback (`.mtn`, `.motion3.json`) +- Not a physics engine (no cloth, hair, or complex rigid body simulation) +- Not facial capture or camera input +- Not IK-based animation (that requires Cubism 5 SDK features not available in all models) + +## Decisions + +### Module-based architecture with per-frame update + +The `ProceduralAnimationSystem` maintains an array of `ProceduralModule` instances. Each frame, the system calls `module.update(dt, parameterSet)` where `parameterSet` is a write-only accumulator. After all modules update, the system applies accumulated values to the model via Semantic Parameter Layer. + +**Alternative**: Each module directly calls `semanticLayer.set()`. **Rejected** because direct writes cause race conditions when multiple modules target the same parameter (e.g., eye tracking and head motion both affect `angleX`). + +### ParameterSet accumulator with blend mode resolution + +`ParameterSet` collects writes from all modules. When multiple modules write the same semantic with different blend modes (`override` vs `add`), the system resolves them: `override` wins over `add` if both are present for the same semantic; if only `add`s, they are summed. + +**Alternative**: No accumulation, modules coordinate explicitly. **Rejected** because explicit coordination is fragile and requires modules to know about each other. + +### One-shot animations via tween-like API + +A `ProceduralAnimator` class supports one-shot animations: `animate({ target: 'angleX', to: 30, duration: 1000, easing: 'easeOutSpring' })`. These are registered as temporary modules that auto-unregister on completion. + +**Alternative**: Integrate with a full tweening library like GSAP. **Rejected** because the scope is small enough for a lightweight internal implementation. GSAP is heavy and adds a dependency. + +### Eye tracking supports multiple input sources + +The `EyeTrackingModule` accepts a normalized `(x, y)` target in [-1, 1] range. Input can come from: +- Mouse/touch position (default) +- AI command bus (future: AI-specified gaze direction) +- Scripted animations (one-shot look-at) + +The module smooths the target using exponential decay (spring-like lerp) to avoid jarring jumps. + +## Risks / Trade-offs + +- **[Risk]** Multiple modules updating parameters may conflict with motion playback. → **Mitigation**: Procedural system runs at a lower priority than motion playback. Parameters written by procedural system are treated as "base layer" and can be overridden by active motions. (Full priority system comes with Motion Layer System in Phase 2.) +- **[Risk]** `dt` (delta time) from Pixi ticker may vary causing inconsistent animation speed. → **Mitigation**: All modules use `dt` for time-based calculations, not frame count. Cap `dt` at 100ms to prevent large jumps on tab switch. +- **[Risk]** Blinking may desync with model's native auto-blink if enabled. → **Mitigation**: Detect if model has native auto-blink and disable the procedural blink module in that case. +- **[Risk]** Spring physics can overshoot or oscillate on low frame rates. → **Mitigation**: Use critically-damped spring (no oscillation) with velocity clamping. + +## Migration Plan + +1. Create `runtime/procedural/` module with `ProceduralAnimationSystem`, `ParameterSet`, and built-in modules +2. Integrate into `Model` lifecycle: instantiate after `SemanticParameterLayer` detection, attach to Pixi ticker +3. Add default config to enable/disable each module (all enabled by default) +4. Wire mouse position to `EyeTrackingModule` via window event listeners +5. Document module behavior and configuration options + +Rollback: Call `system.detach()` before model destroy. Removes ticker callback and clears all modules. + +## Open Questions + +- Should breathing module adjust its frequency/intensity based on "arousal" level (from AI emotion)? (Yes, but requires AI Hooks integration — design the hook point now.) +- Should modules be hot-swappable at runtime (e.g., disable blink during a specific animation)? (Yes, expose `enableModule(name)` / `disableModule(name)` APIs.) +- Should one-shot animations support chained sequences? (Future enhancement; keep initial API simple.) diff --git a/openspec/changes/archive/2026-05-21-procedural-animation-system/proposal.md b/openspec/changes/archive/2026-05-21-procedural-animation-system/proposal.md new file mode 100644 index 0000000..4067ab5 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-procedural-animation-system/proposal.md @@ -0,0 +1,30 @@ +## Why + +Many Live2D models ship with few or no motion files, especially older Cubism 2 models and user-created models. Even models with motions often lack subtle ambient behaviors like breathing, natural blinking, or idle eye drift. A Procedural Animation System generates these behaviors at runtime, making every model feel alive regardless of its motion asset completeness. This also provides the foundation for AI-driven animation (e.g., head following the cursor, spring physics for secondary motion). + +## What Changes + +- Create a `ProceduralAnimationSystem` that registers modules and updates them via the Pixi ticker +- Implement built-in modules: `BreathingModule`, `BlinkModule`, `EyeTrackingModule` +- Each module outputs semantic parameter changes via the Semantic Parameter Layer +- Support module registration/unregistration at runtime +- Provide a fluent animation API for one-shot procedural animations (e.g., `animate({ target: 'angleX', value: 30, duration: 1000, easing: 'spring' })`) +- Integrate with `Model` lifecycle: attach on model ready, detach on destroy + +## Capabilities + +### New Capabilities +- `procedural-animation-system`: Runtime-generated ambient and reactive animations +- `breathing-module`: Subtle sine-wave chest motion via semantic parameters +- `blink-module`: Random-interval eye blinking with natural timing +- `eye-tracking-module`: Smooth head/eyeball following of cursor or AI-specified gaze target + +### Modified Capabilities +- (none — additive runtime enhancement) + +## Impact + +- **Frontend runtime**: New `packages/live2d/src/runtime/procedural/` directory +- **Model lifecycle**: `Model` attaches/detaches `ProceduralAnimationSystem` alongside Pixi ticker +- **Dependency**: Requires Semantic Parameter Layer to be implemented first +- **Performance**: Modules run every frame via Pixi ticker; designed to be lightweight diff --git a/openspec/changes/archive/2026-05-21-procedural-animation-system/specs/blink-module/spec.md b/openspec/changes/archive/2026-05-21-procedural-animation-system/specs/blink-module/spec.md new file mode 100644 index 0000000..77dfc9b --- /dev/null +++ b/openspec/changes/archive/2026-05-21-procedural-animation-system/specs/blink-module/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Trigger random-interval blinks +The module SHALL trigger eye blinks at random intervals within a configurable range. + +#### Scenario: Random blink timing +- **WHEN** the module is configured with interval range `[2000, 6000]` ms +- **THEN** blinks occur at random times, no sooner than 2s and no later than 6s apart + +### Requirement: Animate blink with natural curve +Each blink SHALL animate the `eyeLOpen` and `eyeROpen` parameters from open (`1`) to closed (`0`) and back to open over approximately 150ms. + +#### Scenario: Blink animation curve +- **WHEN** a blink starts at time `T` +- **THEN** at `T+75ms`, both eye parameters are near `0` +- **AND** at `T+150ms`, both parameters return to `1` + +### Requirement: Disable when model has native auto-blink +If the model's native settings have auto-blink enabled, the procedural blink module SHALL disable itself to avoid conflict. + +#### Scenario: Native auto-blink detected +- **WHEN** the loaded model has `autoBlink: true` in its settings +- **THEN** the procedural blink module does not activate + +### Requirement: Configurable blink duration +The module SHALL support configuring the duration of each blink animation. + +#### Scenario: Slow blink +- **WHEN** the module is configured with `duration: 300` +- **THEN** each blink takes 300ms from open to closed to open diff --git a/openspec/changes/archive/2026-05-21-procedural-animation-system/specs/breathing-module/spec.md b/openspec/changes/archive/2026-05-21-procedural-animation-system/specs/breathing-module/spec.md new file mode 100644 index 0000000..0b6b5ae --- /dev/null +++ b/openspec/changes/archive/2026-05-21-procedural-animation-system/specs/breathing-module/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Generate sine-wave breathing motion +The module SHALL produce a continuous sine-wave value for the `breath` semantic parameter. + +#### Scenario: Breathing cycle +- **WHEN** the module is active +- **THEN** the `breath` parameter oscillates in a smooth sine wave with a period of approximately 3 seconds + +### Requirement: Configurable breathing frequency and amplitude +The module SHALL accept configuration for breathing frequency (period in seconds) and amplitude (max parameter offset). + +#### Scenario: Custom breathing settings +- **WHEN** the module is configured with `period: 4000` and `amplitude: 0.2` +- **THEN** the breathing cycle completes every 4 seconds +- **AND** the parameter offset ranges from `0` to `0.2` + +### Requirement: Disable when breath parameter unavailable +If the model does not have a resolvable `breath` semantic, the module SHALL silently become a no-op. + +#### Scenario: Missing breath parameter +- **WHEN** `CapabilityProfile` reports `breath` as missing +- **THEN** the breathing module does not attempt to set any parameter diff --git a/openspec/changes/archive/2026-05-21-procedural-animation-system/specs/eye-tracking-module/spec.md b/openspec/changes/archive/2026-05-21-procedural-animation-system/specs/eye-tracking-module/spec.md new file mode 100644 index 0000000..83c2e04 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-procedural-animation-system/specs/eye-tracking-module/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Follow cursor position by default +The module SHALL track the mouse/touch position and drive `angleX`, `angleY`, `eyeBallX`, and `eyeBallY` semantic parameters to make the model look toward the cursor. + +#### Scenario: Cursor in upper-right +- **WHEN** the cursor is at the upper-right of the canvas +- **THEN** `angleX` and `eyeBallX` increase (look right) +- **AND** `angleY` and `eyeBallY` decrease (look up) + +### Requirement: Smooth movement with exponential interpolation +The module SHALL smooth target transitions using exponential decay to avoid jarring jumps. + +#### Scenario: Rapid cursor movement +- **WHEN** the cursor jumps from left to right instantly +- **THEN** the model's head and eyes smoothly follow over approximately 200ms + +### Requirement: Accept programmatic gaze targets +The module SHALL accept a normalized `(x, y)` target in `[-1, 1]` range from sources other than mouse input. + +#### Scenario: AI-directed gaze +- **WHEN** `setTarget(0.5, -0.3)` is called programmatically +- **THEN** the model smoothly looks toward the specified direction +- **AND** mouse input is temporarily overridden until explicitly released + +### Requirement: Disable tracking when cursor leaves canvas +When the cursor leaves the Live2D canvas area, the module SHALL gradually return the gaze to center (`0, 0`) instead of snapping. + +#### Scenario: Cursor leave +- **WHEN** the mouse moves off the canvas +- **THEN** over approximately 500ms, all tracked parameters return to their default values + +### Requirement: Configurable tracking range +The module SHALL support configuring the maximum angle and eyeball offset ranges. + +#### Scenario: Limited head movement +- **WHEN** configured with `maxAngleX: 15` and `maxEyeBallX: 1.0` +- **THEN** `angleX` ranges from `-15` to `15` +- **AND** `eyeBallX` ranges from `-1.0` to `1.0` diff --git a/openspec/changes/archive/2026-05-21-procedural-animation-system/specs/procedural-animation-system/spec.md b/openspec/changes/archive/2026-05-21-procedural-animation-system/specs/procedural-animation-system/spec.md new file mode 100644 index 0000000..8d7b8a6 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-procedural-animation-system/specs/procedural-animation-system/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Register and update procedural modules +The system SHALL support registering `ProceduralModule` instances that receive per-frame updates via the Pixi ticker. + +#### Scenario: Register breathing module +- **WHEN** a `BreathingModule` is registered with the system +- **AND** the Pixi ticker advances by 16ms +- **THEN** the module's `update(dt, parameterSet)` method is called with `dt = 16` + +### Requirement: Accumulate parameter changes from all modules +After all modules update, the system SHALL resolve and apply accumulated semantic parameter changes to the model. + +#### Scenario: Multiple modules target same parameter +- **WHEN** `BreathingModule` writes `angleX: 2` with `add` blend mode +- **AND** `EyeTrackingModule` writes `angleX: 15` with `override` blend mode +- **THEN** the final `angleX` value is `15` (override wins) + +### Requirement: Support one-shot procedural animations +The system SHALL support registering temporary one-shot animations that auto-unregister on completion. + +#### Scenario: Head nod animation +- **WHEN** `animate({ target: 'angleX', to: 10, duration: 300, easing: 'easeOut' })` is called +- **THEN** over 300ms, `angleX` smoothly animates from current value to `10` +- **AND** the animation auto-unregisters after completion + +### Requirement: Cap delta time to prevent large jumps +The system SHALL cap `dt` passed to modules at 100ms to prevent visual jumps when the tab is backgrounded or frame rate drops. + +#### Scenario: Tab backgrounded +- **WHEN** the user switches tabs for 5 seconds +- **AND** returns to the tab +- **THEN** the next update uses `dt = 100` (capped), not `5000` + +### Requirement: Attach and detach with model lifecycle +The system SHALL attach to the Pixi ticker when the model is ready and detach when the model is destroyed. + +#### Scenario: Model destroy cleanup +- **WHEN** `model.destroy()` is called +- **THEN** the procedural system's ticker callback is removed +- **AND** all modules are cleared diff --git a/openspec/changes/archive/2026-05-21-procedural-animation-system/tasks.md b/openspec/changes/archive/2026-05-21-procedural-animation-system/tasks.md new file mode 100644 index 0000000..b29d121 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-procedural-animation-system/tasks.md @@ -0,0 +1,44 @@ +## 1. Module Setup + +- [x] 1.1 Create `packages/live2d/src/runtime/procedural/` directory structure +- [x] 1.2 Define TypeScript interfaces: `ProceduralModule`, `ParameterSet`, `AnimationOptions` +- [x] 1.3 Define easing functions: `linear`, `easeOut`, `easeInOut`, `spring` + +## 2. Core System Implementation + +- [x] 2.1 Implement `ParameterSet` accumulator with blend mode resolution (`override` vs `add`) +- [x] 2.2 Implement `ProceduralAnimationSystem` with module registration/unregistration +- [x] 2.3 Implement per-frame update loop via Pixi ticker with `dt` capping at 100ms +- [x] 2.4 Implement `ProceduralAnimator` for one-shot animations with auto-unregister +- [x] 2.5 Implement `animate()` fluent API with target, duration, easing support + +## 3. Built-in Modules + +- [x] 3.1 Implement `BreathingModule` with configurable sine-wave period and amplitude +- [x] 3.2 Implement `BlinkModule` with random intervals and natural blink curve +- [x] 3.3 Implement `EyeTrackingModule` with mouse/touch following and smooth interpolation +- [x] 3.4 Add native auto-blink detection to disable procedural blink when conflicting + +## 4. Model Integration + +- [x] 4.1 Add `proceduralSystem` property to `Model` class +- [x] 4.2 Initialize system after `SemanticParameterLayer` detection (dependency) +- [x] 4.3 Attach to Pixi ticker on model ready, detach on model destroy +- [x] 4.4 Wire mouse/touch events to `EyeTrackingModule` via window listeners + +## 5. Configuration + +- [x] 5.1 Add procedural animation config to `Live2dConfig`: enable/disable per module +- [x] 5.2 Add breathing frequency/amplitude config options +- [x] 5.3 Add blink interval range and duration config options +- [x] 5.4 Add eye tracking max angle and eyeball range config options + +## 6. Testing + +- [x] 6.1 Visual test: Breathing module produces visible subtle chest motion +- [x] 6.2 Visual test: Blink module triggers natural-looking blinks at random intervals +- [x] 6.3 Visual test: Eye tracking smoothly follows cursor +- [x] 6.4 Test: One-shot animation completes and auto-unregisters +- [x] 6.5 Test: ParameterSet correctly resolves override vs add blend modes +- [x] 6.6 Test: System detaches cleanly on model destroy (no ticker leaks) +- [x] 6.7 Test: `dt` capping prevents large jumps after tab backgrounding diff --git a/openspec/changes/archive/2026-05-21-runtime-devtools/.openspec.yaml b/openspec/changes/archive/2026-05-21-runtime-devtools/.openspec.yaml new file mode 100644 index 0000000..8b76914 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-runtime-devtools/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-20 diff --git a/openspec/changes/archive/2026-05-21-runtime-devtools/design.md b/openspec/changes/archive/2026-05-21-runtime-devtools/design.md new file mode 100644 index 0000000..a042cfc --- /dev/null +++ b/openspec/changes/archive/2026-05-21-runtime-devtools/design.md @@ -0,0 +1,94 @@ +## Context + +plugin-live2d now has five independently-operating runtime subsystems: +- `BehaviorFSM` — high-level behavioral states (idle, talking, happy...) +- `EmotionTimeline` — smooth parameter interpolation between emotions +- `MotionLayerSystem` — parallel animation tracks on named layers +- `FilterPipeline` — per-model visual effects (blush, glow, color grading) +- `ProceduralAnimationSystem` — continuous animations (breathing, blinking, eye tracking) +- `SemanticParameterLayer` — unified semantic parameter access + +Each is initialized separately in `Model.loadModel()`, owns its own resources, and writes to the semantic layer independently. There is no coordination layer. A developer running `pnpm dev` sees the Live2D model with breathing/blinking (procedural system), but has no way to observe or trigger the other four systems. + +## Goals / Non-Goals + +**Goals:** +- Create a single `Live2dRuntimeController` that owns and coordinates all runtime subsystems +- Provide a unified public API for external consumers (AI hooks, chat reactions, manual triggers) +- Build a developer-only floating panel showing real-time system status +- Enable manual triggering of states, emotions, filters, and motions from the panel +- Detect and resolve cross-system parameter conflicts + +**Non-Goals:** +- Not a production-facing UI (dev-only, tree-shaken in prod builds) +- Not replacing individual system APIs (controller delegates, doesn't wrap) +- Not a full animation sequencer with keyframes +- Not persisting debug state across page reloads + +## Decisions + +### Controller owns subsystem lifecycle + +`Live2dRuntimeController` is instantiated in `Model.loadModel()` after the Pixi app is ready. It creates all subsystems internally and exposes them via getters. `Model` delegates to the controller instead of directly owning `#behaviorFSM`, `#emotionTimeline`, etc. + +**Alternative**: Keep direct Model ownership and add controller as a thin wrapper. **Rejected** because Model is already cluttered with 6 private fields for runtime systems. Centralizing ownership simplifies Model and makes the relationship explicit. + +### DevTools is a standalone React/Lit component + +The panel is a web component (``) rendered as a sibling to ``. It consumes the controller via a context or direct reference. It only renders when `import.meta.env.DEV` is true. + +**Alternative**: Build into Live2dTools (the existing user-facing toolbar). **Rejected** because the existing toolbar is user-facing (screenshot, switch model, etc.) and should not expose runtime internals. + +### Conflict resolution: priority-based last-write-wins + +When two systems target the same semantic parameter, the controller resolves conflicts by priority: +1. Manual override (dev tools slider) — highest +2. Behavior FSM state profiles +3. Emotion Timeline transitions +4. Motion Layer System (expression layer) +5. Procedural Animation System — lowest + +Each frame, the controller collects all pending parameter writes, sorts by priority, and applies the highest-priority value. + +**Alternative**: Blend all contributions mathematically. **Rejected** because different systems use different blend semantics (override vs add) and blending them all would produce unpredictable results. Priority is simpler and debuggable. + +### DevTools panel layout: accordion sections + +The panel is divided into collapsible sections, one per subsystem: +- **Behavior FSM**: Current state, transition history, state buttons +- **Emotion Timeline**: Current emotion, transition progress bar, emotion buttons +- **Motion Layers**: Layer status table (active/stopped, weight, priority) +- **Filter Pipeline**: Active effects list with intensity sliders +- **Semantic Parameters**: Live parameter value grid +- **Procedural**: Module toggle switches (breathing, blink, eye tracking) + +**Alternative**: Tabbed layout. **Rejected** because accordion allows seeing multiple systems at once, which is useful for understanding interactions. + +### DevTools toggle: keyboard shortcut + corner indicator + +- `Ctrl+Shift+D` toggles panel visibility +- A small `🔧` indicator in the bottom-right corner shows panel is available +- Panel position is draggable and remembers position in `localStorage` + +## Risks / Trade-offs + +- **[Risk]** Controller adds abstraction overhead between Model and subsystems. → **Mitigation**: Controller is a thin coordinator; subsystems retain their full APIs. No performance-critical path goes through the controller. +- **[Risk]** DevTools bundle size in production even if tree-shaken. → **Mitigation**: DevTools is in a separate entry file (`Demo.tsx` imports it, production entry `index.ts` does not). Verified by analyzing build output. +- **[Risk]** Conflict resolution hides bugs by silently suppressing parameter writes. → **Mitigation**: DevTools shows suppressed writes in a "conflict log" section with reason (which system won and why). +- **[Risk]** Developers may accidentally ship with DevTools enabled. → **Mitigation**: Guard every render with `import.meta.env.DEV`. Add an eslint rule forbidding `Live2dDevTools` import outside dev entry. + +## Migration Plan + +1. Create `Live2dRuntimeController` and move subsystem initialization from Model +2. Refactor Model to use controller getters +3. Create DevTools component with basic status display +4. Add manual trigger buttons +5. Add conflict resolution logging +6. Verify DevTools does not appear in production build + +Rollback: Revert Model to direct subsystem ownership. Controller and DevTools are purely additive. + +## Open Questions + +- Should the controller expose an event bus for cross-system communication (e.g. FSM entering "talking" automatically triggers a "speaking" emotion)? +- Should DevTools support recording/replaying interaction sequences for regression testing? diff --git a/openspec/changes/archive/2026-05-21-runtime-devtools/proposal.md b/openspec/changes/archive/2026-05-21-runtime-devtools/proposal.md new file mode 100644 index 0000000..a72da4f --- /dev/null +++ b/openspec/changes/archive/2026-05-21-runtime-devtools/proposal.md @@ -0,0 +1,35 @@ +## Why + +plugin-live2d has built five runtime subsystems (Behavior FSM, Emotion Timeline, Motion Layer System, Filter Pipeline, Procedural Animation, Semantic Parameter Layer), but they operate in isolation. Developers cannot see what each system is doing, cannot manually trigger state changes for testing, and have no single point of control. Without visibility and manual triggers, these systems might as well not exist from a development or debugging perspective. + +## What Changes + +- Introduce `Live2dRuntimeController` — a centralized coordinator that owns all runtime subsystems and exposes a unified control API +- Create a developer-only `RuntimeDevTools` floating panel (React/Lit component) visible only in `import.meta.env.DEV` +- Panel displays real-time status of all runtime systems (current FSM state, active emotion, motion layers, filters, semantic params) +- Panel provides manual trigger buttons for FSM states, emotions, filter presets, and motion layers +- Panel shows live parameter value readouts with progress bars for transitions +- Runtime controller prevents cross-system conflicts (e.g. FSM and Timeline fighting for the same parameter) +- DevTools toggles via `Ctrl+Shift+D` or a small corner indicator + +## Capabilities + +### New Capabilities +- `runtime-controller`: Centralized ownership and coordination of all runtime subsystems +- `runtime-devtools`: Developer-only floating panel for state visibility and manual triggering +- `runtime-conflict-resolution`: Cross-system parameter conflict detection and resolution rules + +### Modified Capabilities +- `behavior-fsm`: Will register with controller instead of direct Model ownership +- `emotion-timeline`: Will register with controller instead of direct Model ownership +- `runtime-filter-pipeline`: Will register with controller instead of direct Model ownership +- `motion-layer-system`: Will register with controller instead of direct Model ownership +- `procedural-animation-system`: Will register with controller instead of direct Model ownership + +## Impact + +- **Frontend runtime**: New `packages/live2d/src/runtime/controller/` directory +- **Dev tools UI**: New `packages/live2d/src/components/Live2dDevTools/` directory +- **Model class**: Refactored to delegate runtime initialization to controller instead of direct ownership +- **Build**: DevTools component is tree-shaken in production builds (dev-only import) +- **Config**: `Live2dConfig` gains `devTools` option for panel positioning and feature toggles diff --git a/openspec/changes/archive/2026-05-21-runtime-devtools/specs/runtime-conflict-resolution/spec.md b/openspec/changes/archive/2026-05-21-runtime-devtools/specs/runtime-conflict-resolution/spec.md new file mode 100644 index 0000000..fa76c47 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-runtime-devtools/specs/runtime-conflict-resolution/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Detect cross-system parameter conflicts +The system SHALL detect when multiple runtime systems attempt to write to the same semantic parameter within the same frame. + +#### Scenario: FSM and Timeline target same parameter +- **WHEN** the FSM sets `mouthSmile: 0.5` and the EmotionTimeline sets `mouthSmile: 0.8` in the same frame +- **THEN** the controller detects a conflict on `mouthSmile` + +### Requirement: Resolve conflicts by priority +The system SHALL resolve conflicts using a defined priority order. + +#### Scenario: Higher priority wins +- **WHEN** ProceduralAnimation (priority 5) writes `breath: 0.1` +- **AND** EmotionTimeline (priority 3) writes `breath: 0.3` +- **THEN** the effective value is `breath: 0.3` (EmotionTimeline wins) + +#### Scenario: Manual override has highest priority +- **WHEN** a dev tools slider manually sets `eyeLOpen: 0.5` +- **AND** any other system writes a different value to `eyeLOpen` +- **THEN** the manual value `0.5` is applied + +### Requirement: Log suppressed writes for debugging +The system SHALL record which writes were suppressed and why. + +#### Scenario: Conflict log entry +- **WHEN** a conflict occurs and the EmotionTimeline wins +- **THEN** a log entry is created showing: parameter name, losing system, winning system, both values + +#### Scenario: DevTools displays conflict log +- **WHEN** the DevTools panel is open +- **AND** conflicts have occurred +- **THEN** a "Conflict Log" section displays all recent conflicts with timestamps diff --git a/openspec/changes/archive/2026-05-21-runtime-devtools/specs/runtime-controller/spec.md b/openspec/changes/archive/2026-05-21-runtime-devtools/specs/runtime-controller/spec.md new file mode 100644 index 0000000..4c902be --- /dev/null +++ b/openspec/changes/archive/2026-05-21-runtime-devtools/specs/runtime-controller/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: Controller owns subsystem lifecycle +The system SHALL provide a `Live2dRuntimeController` class that instantiates and manages all runtime subsystems. + +#### Scenario: Controller initializes after model load +- **WHEN** a Live2D model is loaded +- **AND** `Live2dRuntimeController` is instantiated with the loaded model +- **THEN** all runtime subsystems (BehaviorFSM, EmotionTimeline, MotionLayerSystem, FilterPipeline, ProceduralAnimationSystem, SemanticParameterLayer) are created and initialized + +#### Scenario: Controller exposes subsystem access +- **WHEN** external code calls `controller.getBehaviorFSM()` +- **THEN** the BehaviorFSM instance is returned +- **AND** calling `controller.getEmotionTimeline()` returns the EmotionTimeline instance + +### Requirement: Controller provides unified transition API +The system SHALL expose a unified `transitionTo({ fsm?, emotion?, filter? })` method that coordinates cross-system transitions. + +#### Scenario: Coordinated state and emotion transition +- **WHEN** `controller.transitionTo({ fsm: 'talking', emotion: 'happy' })` is called +- **THEN** the FSM transitions to `talking` +- **AND** the EmotionTimeline transitions to `happy` +- **AND** parameter conflicts are resolved by the controller + +### Requirement: Controller destroys all subsystems +The system SHALL provide a `destroy()` method that cleans up all owned subsystems. + +#### Scenario: Model destruction propagates to controller +- **WHEN** `controller.destroy()` is called +- **THEN** all subsystems are destroyed in dependency order +- **AND** no memory leaks or dangling timers remain diff --git a/openspec/changes/archive/2026-05-21-runtime-devtools/specs/runtime-devtools/spec.md b/openspec/changes/archive/2026-05-21-runtime-devtools/specs/runtime-devtools/spec.md new file mode 100644 index 0000000..0457be7 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-runtime-devtools/specs/runtime-devtools/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: DevTools panel displays runtime status +The system SHALL provide a floating panel that displays the real-time status of all runtime subsystems. + +#### Scenario: Panel shows FSM state +- **WHEN** the DevTools panel is open +- **AND** the current FSM state is `idle` +- **THEN** the panel displays "idle" as the current state + +#### Scenario: Panel shows emotion transition progress +- **WHEN** the DevTools panel is open +- **AND** a transition from `neutral` to `happy` is 50% complete +- **THEN** the panel shows a progress bar at 50% with "neutral → happy" + +#### Scenario: Panel shows motion layer statuses +- **WHEN** the DevTools panel is open +- **AND** the `talk` layer is active with weight 0.8 +- **THEN** the panel displays the talk layer as active with weight 0.8 + +#### Scenario: Panel shows active filters +- **WHEN** the DevTools panel is open +- **AND** a `happy-glow` filter is active +- **THEN** the panel lists "happy-glow" with its intensity + +#### Scenario: Panel shows semantic parameter values +- **WHEN** the DevTools panel is open +- **AND** the `mouthSmile` parameter has value 0.6 +- **THEN** the panel displays `mouthSmile: 0.6` + +### Requirement: DevTools provides manual trigger buttons +The system SHALL provide buttons to manually trigger FSM states, emotions, and filter presets. + +#### Scenario: Trigger FSM state from panel +- **WHEN** the user clicks the "happy" state button in the FSM section +- **THEN** `fsm.transitionTo('happy')` is called + +#### Scenario: Trigger emotion from panel +- **WHEN** the user clicks the "angry" emotion button +- **THEN** `emotionTimeline.transitionTo('angry')` is called + +#### Scenario: Apply filter from panel +- **WHEN** the user clicks the "shy-blush" filter button +- **THEN** `filterPipeline.applyPreset('shy-blush')` is called + +#### Scenario: Toggle procedural module from panel +- **WHEN** the user unchecks the "Blink" toggle +- **THEN** the Blink module is disabled in the procedural system + +### Requirement: DevTools is dev-only +The system SHALL ensure the DevTools component is not included in production builds. + +#### Scenario: Production build excludes DevTools +- **WHEN** the application is built for production +- **THEN** the DevTools component code is not included in the bundle + +### Requirement: DevTools toggle mechanism +The system SHALL support toggling the panel via keyboard shortcut and a corner indicator. + +#### Scenario: Keyboard shortcut toggles panel +- **WHEN** the user presses `Ctrl+Shift+D` +- **THEN** the DevTools panel visibility toggles + +#### Scenario: Corner indicator shows availability +- **WHEN** the DevTools panel is hidden +- **THEN** a small indicator icon is visible in the bottom-right corner +- **AND** clicking the indicator opens the panel diff --git a/openspec/changes/archive/2026-05-21-runtime-devtools/tasks.md b/openspec/changes/archive/2026-05-21-runtime-devtools/tasks.md new file mode 100644 index 0000000..9e7ca3d --- /dev/null +++ b/openspec/changes/archive/2026-05-21-runtime-devtools/tasks.md @@ -0,0 +1,99 @@ +## 1. Runtime Controller Core + +- [x] 1.1 Create `packages/live2d/src/runtime/controller/` directory +- [x] 1.2 Define `Live2dRuntimeController` class with subsystem ownership +- [x] 1.3 Move BehaviorFSM initialization from Model to Controller +- [x] 1.4 Move EmotionTimeline initialization from Model to Controller +- [x] 1.5 Move MotionLayerSystem initialization from Model to Controller +- [x] 1.6 Move FilterPipeline initialization from Model to Controller +- [x] 1.7 Move ProceduralAnimationSystem initialization from Model to Controller +- [x] 1.8 Implement `controller.get*()` accessors for all subsystems +- [x] 1.9 Implement `controller.destroy()` with proper cleanup order +- [x] 1.10 Refactor Model class to delegate to Controller + +## 2. Conflict Resolution + +- [x] 2.1 Define `SystemPriority` enum (manual=1, fsm=2, emotion=3, motion=4, procedural=5) +- [x] 2.2 Implement `ParameterWriteQueue` to collect writes per frame +- [x] 2.3 Implement conflict detection (same param, same frame, different sources) +- [x] 2.4 Implement priority-based resolution (highest wins) +- [x] 2.5 Implement conflict log (parameter, losing system, winning system, values) +- [x] 2.6 Integrate resolution into controller's update cycle + +## 3. Unified Transition API + +- [x] 3.1 Implement `controller.transitionTo({ fsm?, emotion?, filter? })` +- [x] 3.2 Ensure cross-system transitions are atomic +- [x] 3.3 Add `controller.getCurrentState()` returning composite state +- [x] 3.4 Unit test: FSM + emotion coordinated transition +- [x] 3.5 Unit test: Filter application during state transition + +## 4. DevTools Panel Component + +- [x] 4.1 Create `packages/live2d/src/components/Live2dDevTools/` directory +- [x] 4.2 Create `Live2dDevTools` Lit/React component scaffold +- [x] 4.3 Implement panel container with drag handle +- [x] 4.4 Implement accordion section layout +- [x] 4.5 Implement `Ctrl+Shift+D` keyboard shortcut toggle +- [x] 4.6 Implement corner indicator (`🔧`) when panel is hidden +- [x] 4.7 Store panel position in localStorage +- [x] 4.8 Guard rendering with `import.meta.env.DEV` + +## 5. DevTools FSM Section + +- [x] 5.1 Display current FSM state name +- [x] 5.2 Display transition history (last 5 transitions) +- [x] 5.3 Add state trigger buttons (idle, happy, thinking, talking, etc.) +- [x] 5.4 Show guard status (can/cannot transition to each state) +- [x] 5.5 Unit test: Clicking state button triggers transition + +## 6. DevTools Emotion Section + +- [x] 6.1 Display current emotion name +- [x] 6.2 Display transition progress bar (when transitioning) +- [x] 6.3 Add emotion trigger buttons (neutral, happy, sad, angry, etc.) +- [x] 6.4 Show current interpolated parameter values +- [x] 6.5 Unit test: Clicking emotion button triggers transition + +## 7. DevTools Motion Layer Section + +- [x] 7.1 Display layer status table (name, state, weight, priority) +- [x] 7.2 Show active parameters per layer +- [x] 7.3 Add layer trigger buttons (play/stop for each layer) +- [x] 7.4 Unit test: Layer status updates in real-time + +## 8. DevTools Filter Section + +- [x] 8.1 Display active effects list +- [x] 8.2 Add intensity sliders for each active effect +- [x] 8.3 Add preset trigger buttons (happy-glow, shy-blush, angry-red, etc.) +- [x] 8.4 Add "Clear All" button +- [x] 8.5 Unit test: Clicking preset button applies effect + +## 9. DevTools Semantic Parameter Section + +- [x] 9.1 Display live parameter value grid (name + value) +- [x] 9.2 Add manual parameter sliders +- [x] 9.3 Highlight parameters currently being written by any system +- [x] 9.4 Unit test: Slider changes parameter value + +## 10. DevTools Procedural Section + +- [x] 10.1 Display module toggle switches (breathing, blink, eye tracking) +- [x] 10.2 Show module configuration (period, amplitude, etc.) +- [x] 10.3 Unit test: Toggling module enables/disables it + +## 11. DevTools Conflict Log Section + +- [x] 11.1 Display recent conflicts in a scrollable list +- [x] 11.2 Show timestamp, parameter, losing system, winning system +- [x] 11.3 Add "Clear Log" button +- [x] 11.4 Unit test: Conflict appears in log when detected + +## 12. Integration & Production Safety + +- [x] 12.1 Integrate DevTools into `Demo.tsx` (dev-only) +- [x] 12.2 Ensure DevTools is NOT imported in `index.ts` (production entry) +- [x] 12.3 Add `devTools` config to `Live2dConfig` +- [x] 12.4 Verify production build excludes DevTools code +- [x] 12.5 Integration test: Full workflow — trigger state → observe emotion → apply filter diff --git a/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/.openspec.yaml b/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/.openspec.yaml new file mode 100644 index 0000000..8b76914 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-20 diff --git a/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/design.md b/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/design.md new file mode 100644 index 0000000..9536951 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/design.md @@ -0,0 +1,68 @@ +## Context + +plugin-live2d renders via PixiJS v8 through `untitled-pixi-live2d-engine`, which integrates natively with Pixi's Render Pipe. This means `Live2DModel` instances can use Pixi filters directly: `model.filters = [new BlurFilter({ strength: 8 })]`. However, there is currently no structured way to manage, compose, or animate filters at runtime. This design establishes a pipeline that treats visual effects as first-class runtime features. + +PixiJS v8's filter system uses `Filter` with `GlProgram.from()` for shaders and typed resources for uniforms. Effects like color grading, glow, and blur are all achievable without model author support. + +## Goals / Non-Goals + +**Goals:** +- Provide a `FilterPipeline` API for adding/removing runtime visual effects on Live2D models +- Implement emotion-oriented built-in effects: mood lighting, blush, glow +- Support part-level filtering when the engine exposes drawable access +- Ensure filter resolution matches renderer resolution (no blurring from downsampling) +- Provide a stable handle system so effects can be referenced and removed individually + +**Non-Goals:** +- Not a general-purpose shader authoring tool (no custom GLSL fragment editor) +- Not post-processing for the entire scene (scope is per-model or per-part) +- Not real-time raytracing or advanced lighting (Pixi v8 WebGPU preparation is Phase 4) +- No breaking changes to existing rendering path + +## Decisions + +### Effects as stateful objects with lifecycle + +Each effect (e.g., `MoodLightingEffect`, `BlushEffect`) is a class that manages its own Pixi `Filter` instance and uniform updates. The `FilterPipeline` owns the array of active effects and rebuilds `model.filters` when effects are added or removed. + +**Alternative**: Direct filter array manipulation by callers. **Rejected** because managing filter order, resolution, and cleanup is error-prone and should be centralized. + +### Built-in effects use Pixi v8 native filters where possible + +- `ColorMatrixFilter` for mood lighting (warm/cool tint) +- `BlurFilter` for soft glow and blush +- Custom `Filter` with simple GLSL for RGB shift / glitch + +**Alternative**: Write all effects as custom shaders. **Rejected** because native filters are optimized and well-tested; custom shaders only where native filters are insufficient. + +### Part-level filtering via drawable name matching + +When `untitled-pixi-live2d-engine` exposes drawable/part access, the pipeline supports targeting filters to specific drawables by name pattern (e.g., `/eye/` → only eye parts get the glow). If the engine does not expose this, the filter falls back to applying to the entire model. + +**Alternative**: Require engine modifications for part filtering. **Rejected** because we want to work with the engine as-is; part filtering is a progressive enhancement. + +### Effect intensity as a normalized [0, 1] value + +All built-in effects accept an `intensity` parameter. The effect implementation maps this to actual filter uniform values. This allows smooth animation of effect strength over time (e.g., blush fading in/out). + +## Risks / Trade-offs + +- **[Risk]** `untitled-pixi-live2d-engine` may not expose all drawable internals needed for part-level filtering. → **Mitigation**: Graceful fallback to full-model filtering. Document the limitation. +- **[Risk]** Multiple stacked filters can hurt performance on low-end GPUs. → **Mitigation**: Provide a `quality` tier setting (low/medium/high) that disables expensive filters. Effects are always optional. +- **[Risk]** Filter rendering may differ between WebGL and WebGPU backends. → **Mitigation**: Stick to standard Pixi filters that are tested on both backends. Avoid custom shaders that assume WebGL-only features. +- **[Risk]** `ColorMatrixFilter` tinting may look unnatural on some models. → **Mitigation**: Subtle default intensities (0.1-0.3). Allow per-effect override in config. + +## Migration Plan + +1. Create `runtime/filters/` module with `FilterPipeline` and built-in effect classes +2. Add `filterPipeline` property to `Model` class, initialized after Pixi app is ready +3. Expose filter controls through a runtime API (for future AI Hooks integration) +4. Add demo/test page showing each built-in effect +5. Document filter quality settings for performance tuning + +Rollback: Set `model.filters = null` in `FilterPipeline.destroy()`. No persistent state. + +## Open Questions + +- Should effects support keyframe animation natively, or should that be handled by a separate animation driver? (Separate driver keeps concerns clean.) +- Should we expose effect presets (e.g., `preset: 'evening-warm'`) for common moods? (Yes, as a convenience layer on top of intensity-based effects.) diff --git a/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/proposal.md b/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/proposal.md new file mode 100644 index 0000000..5bd69bf --- /dev/null +++ b/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/proposal.md @@ -0,0 +1,28 @@ +## Why + +Traditional Live2D runtimes have limited rendering extensibility — effects like bloom, blush glow, or mood lighting typically require model author support. Since plugin-live2d already uses PixiJS v8 with `untitled-pixi-live2d-engine`, we have access to Pixi's full Filter and RenderTexture pipeline. A Runtime Filter Pipeline allows injecting visual effects at runtime without modifying models, turning rendering into a core differentiator of this project. + +## What Changes + +- Create a `FilterPipeline` class that manages PixiJS v8 filters applied to Live2D models +- Implement part-level filter targeting (when engine exposes drawable access) +- Build a set of built-in emotion-oriented effects: mood lighting, blush, glow, subtle color grading +- Provide `add()`, `remove()`, and `clear()` APIs with stable filter handles +- Integrate with the AI Runtime Hooks layer (future) so emotion commands can trigger visual effects +- Ensure filter resolution inherits from the Pixi renderer to avoid blurring + +## Capabilities + +### New Capabilities +- `runtime-filter-pipeline`: Runtime-injected visual effects via PixiJS v8 Filter system +- `model-part-filtering`: Apply filters to specific model parts/drawables + +### Modified Capabilities +- (none — purely additive rendering enhancement) + +## Impact + +- **Frontend runtime**: New `packages/live2d/src/runtime/filters/` directory +- **Model wrapper**: `Model` class gains a `filterPipeline` property after initialization +- **Pixi dependency**: Uses existing PixiJS v8 `Filter`, `ColorMatrixFilter`, `BlurFilter` +- **Performance**: Filter overhead scales with effect complexity; all effects are optional diff --git a/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/specs/model-part-filtering/spec.md b/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/specs/model-part-filtering/spec.md new file mode 100644 index 0000000..584bc34 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/specs/model-part-filtering/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Target filters to specific model parts +When the engine exposes drawable access, the system SHALL support applying filters to specific parts of the model by drawable name pattern. + +#### Scenario: Eye glow effect +- **WHEN** a glow filter is configured with target pattern `/eye/i` +- **AND** the model has drawables named `eyeL` and `eyeR` +- **THEN** the glow is applied only to the eye drawables + +### Requirement: Fallback to full-model filtering +If part-level targeting is not supported by the engine or no drawables match the pattern, the filter SHALL apply to the entire model. + +#### Scenario: Pattern mismatch fallback +- **WHEN** a filter targets pattern `/nonexistent/i` +- **AND** no drawables match +- **THEN** the filter applies to the entire model + +### Requirement: Part filtering does not affect other drawables +When a filter targets a specific part, other drawables SHALL render without that filter. + +#### Scenario: Isolated part effect +- **WHEN** a blush filter targets only drawable `cheekL` +- **THEN** `cheekL` renders with blush +- **AND** all other drawables render normally diff --git a/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/specs/runtime-filter-pipeline/spec.md b/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/specs/runtime-filter-pipeline/spec.md new file mode 100644 index 0000000..cefa066 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/specs/runtime-filter-pipeline/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Add runtime filter effects to Live2D model +The system SHALL provide a `FilterPipeline` that can attach PixiJS v8 filters to a `Live2DModel` instance at runtime. + +#### Scenario: Add mood lighting effect +- **WHEN** `filterPipeline.add(new MoodLightingEffect({ color: 'warm', intensity: 0.3 }))` is called +- **THEN** the model renders with a warm color tint + +#### Scenario: Remove effect by handle +- **WHEN** a filter is added and returns handle `{ id: 'abc-123' }` +- **AND** `filterPipeline.remove('abc-123')` is called +- **THEN** the effect is removed and the model returns to normal rendering + +### Requirement: Support multiple concurrent effects +The system SHALL support multiple active effects simultaneously, compositing them in the order they were added. + +#### Scenario: Stacked effects +- **WHEN** a blur effect and a color matrix effect are both active +- **THEN** the model renders with both effects applied in sequence + +### Requirement: Adjust effect intensity at runtime +Active effects SHALL support dynamic intensity adjustment. + +#### Scenario: Fade in blush effect +- **WHEN** a blush effect is added with intensity `0` +- **AND** its intensity is gradually increased to `0.5` over 500ms +- **THEN** the model's blush rendering smoothly fades in + +### Requirement: Clear all effects +The system SHALL provide a `clear()` method that removes all active effects. + +#### Scenario: Clear pipeline +- **WHEN** three effects are active +- **AND** `filterPipeline.clear()` is called +- **THEN** no filters remain on the model + +### Requirement: Inherit renderer resolution +All filters SHALL inherit the Pixi renderer's resolution to prevent visual degradation from downsampling. + +#### Scenario: High-DPI display +- **WHEN** the device pixel ratio is `2` +- **AND** a blur filter is applied +- **THEN** the blur appears at correct visual strength (not overly blurred) diff --git a/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/tasks.md b/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/tasks.md new file mode 100644 index 0000000..61ffb1d --- /dev/null +++ b/openspec/changes/archive/2026-05-21-runtime-filter-pipeline/tasks.md @@ -0,0 +1,41 @@ +## 1. Module Setup + +- [x] 1.1 Create `packages/live2d/src/runtime/filters/` directory structure +- [x] 1.2 Define TypeScript interfaces: `FilterEffect`, `FilterHandle`, `FilterPipeline` +- [x] 1.3 Define `EffectIntensity` type and `QualityTier` enum + +## 2. Core Pipeline Implementation + +- [x] 2.1 Implement `FilterPipeline` class with `add()`, `remove()`, `clear()` methods +- [x] 2.2 Implement stable handle generation and effect tracking +- [x] 2.3 Ensure filter resolution inherits from Pixi renderer +- [x] 2.4 Implement dynamic intensity adjustment on active effects +- [x] 2.5 Wire `FilterPipeline` into `Model` class as `filterPipeline` property + +## 3. Built-in Effects + +- [x] 3.1 Implement `MoodLightingEffect` using Pixi `ColorMatrixFilter` +- [x] 3.2 Implement `BlushEffect` using Pixi `BlurFilter` + tint +- [x] 3.3 Implement `GlowEffect` using Pixi `BlurFilter` + blend mode +- [x] 3.4 Implement `ColorGradingEffect` for warm/cool emotion tones +- [x] 3.5 Create effect presets: `evening-warm`, `morning-cool`, `neutral` + +## 4. Part-level Filtering (Progressive Enhancement) + +- [x] 4.1 Investigate `untitled-pixi-live2d-engine` drawable exposure API +- [x] 4.2 Implement part-level targeting by drawable name pattern (if API available) +- [x] 4.3 Implement fallback to full-model filtering when part targeting unavailable + +## 5. Integration & Configuration + +- [x] 5.1 Add `filterQuality` config option to `Live2dConfig` +- [x] 5.2 Initialize `FilterPipeline` in `Model` after Pixi app is ready +- [x] 5.3 Add public runtime API for external systems (future AI Hooks integration) + +## 6. Testing + +- [x] 6.1 Visual test: Mood lighting warm effect renders correctly +- [x] 6.2 Visual test: Blush effect fades in/out with intensity animation +- [x] 6.3 Performance test: 3 stacked filters maintain 60fps on mid-tier GPU +- [x] 6.4 Test `clear()` removes all effects and restores normal rendering +- [x] 6.5 Test backward compatibility: no filters applied by default diff --git a/openspec/changes/archive/2026-05-21-semantic-parameter-layer/.openspec.yaml b/openspec/changes/archive/2026-05-21-semantic-parameter-layer/.openspec.yaml new file mode 100644 index 0000000..8b76914 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-semantic-parameter-layer/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-20 diff --git a/openspec/changes/archive/2026-05-21-semantic-parameter-layer/design.md b/openspec/changes/archive/2026-05-21-semantic-parameter-layer/design.md new file mode 100644 index 0000000..4655207 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-semantic-parameter-layer/design.md @@ -0,0 +1,71 @@ +## Context + +plugin-live2d uses `untitled-pixi-live2d-engine` which wraps Live2D models with a `Live2DModel` class exposing `internalModel`. Parameters are accessed directly via `internalModel.parameters` using string IDs. The current codebase in `model.ts` hardcodes parameter access patterns like `HEAD_HIT_AREA_PATTERN = /(head|flickhead)/i` and manually iterates hit areas. This approach breaks when models use non-standard naming conventions. + +The project supports Cubism 2 through 5, and users may load VTubeStudio exports, custom models, or community models with varying parameter conventions. A semantic abstraction layer is needed before any higher-level runtime feature (procedural animation, AI hooks) can safely manipulate model parameters. + +## Goals / Non-Goals + +**Goals:** +- Provide a runtime-discovered mapping from semantic names to model-specific parameter IDs +- Support default mappings for common parameters across Cubism 2/4/5 conventions +- Allow runtime registration of custom semantic mappings +- Expose a `CapabilityProfile` showing which semantics are available on the current model +- Keep the API minimal: `get`, `set`, `has`, `register` + +**Non-Goals:** +- Not a full parameter alias system (no user-defined aliases at runtime) +- No automatic parameter creation or modification of model structure +- No interpolation or animation — that belongs to Procedural Animation and Motion Layer systems +- No breaking changes to existing direct parameter access + +## Decisions + +### Two-phase initialization: detection then binding + +The `SemanticParameterLayer` is instantiated empty, then populated via `detectFromModel(model)` after the model loads. This separates construction from the async model loading process and allows re-detection if the model changes. + +**Alternative**: Detect inside the constructor. **Rejected** because model loading is async and the layer may need to exist before the model is ready (e.g., for pre-registration of custom mappings). + +### Default mappings as a static registry with override support + +A built-in `DEFAULT_SEMANTIC_MAPPINGS` object maps semantic names to arrays of candidate parameter IDs in priority order. Users can call `registerSemantic('customName', ['CANDIDATE_A', 'CANDIDATE_B'])` to add or override mappings before detection. + +**Alternative**: Load mappings from an external JSON file. **Rejected** because the built-in set covers 90% of cases and external loading adds async complexity for marginal benefit. + +### `set` supports `override` and `add` blend modes + +When multiple systems (procedural animation, AI hooks, motion playback) try to set the same semantic parameter, they need a merge strategy. `set(semantic, value, 'override')` replaces the current value; `set(semantic, value, 'add')` adds to it (useful for breathing叠加). + +**Alternative**: No blend mode, last-write-wins. **Rejected** because breathing叠加 on top of motion is a common and visually important case. + +### CapabilityProfile exposes detected/missing/not-applicable + +After detection, the layer produces a profile categorizing each semantic as: +- `detected`: parameter found and mapped +- `missing`: parameter not found but expected (warned, not errored) +- `not-applicable`: semantic known to not exist for this model type (e.g., Cubism 2 breath parameter on Cubism 5 model with different convention) + +This lets higher-level systems adapt behavior (e.g., disable lip sync if `mouthOpen` is missing). + +## Risks / Trade-offs + +- **[Risk]** `untitled-pixi-live2d-engine`'s `internalModel.parameters` structure may differ between Cubism 2 and Cubism 4/5. → **Mitigation**: Abstract the parameter enumeration in a `ModelParameterEnumerator` helper that normalizes both structures to a common `{ ids: string[], getValue(id), setValue(id, value) }` interface. +- **[Risk]** Non-standard models may have parameters that semantically match but use completely unexpected names. → **Mitigation**: `registerSemantic()` allows users to add mappings. Future: optional fuzzy matching based on parameter range and default value heuristics. +- **[Risk]** Parameter IDs in Cubism 5 may include new prefixes or conventions not in our default registry. → **Mitigation**: The registry is a plain object, easily extensible. We target Cubism 2/4/5 common conventions. +- **[Risk]** Overhead of semantic lookup on every frame. → **Mitigation**: After detection, store direct references to the parameter index/ID. `set()` is O(1) after initialization. + +## Migration Plan + +1. Create `runtime/semantic/` module with `SemanticParameterLayer` and `CapabilityProfile` +2. Integrate into `Model.create()`: instantiate layer, call `detectFromModel()`, store on model instance +3. Optionally refactor `model.ts` hit area detection to use semantic `head` lookup (demonstrates usage) +4. Expose layer to external consumers via `Model.getSemanticLayer()` +5. Higher-level systems (Procedural Animation, AI Hooks) import and use the layer + +Rollback: Remove `detectFromModel()` call in `Model.create()`; all other code is additive. + +## Open Questions + +- Should the layer cache parameter values to avoid redundant `get()` calls? (Probably yes, with a `sync()` method called once per frame.) +- How should `add` mode handle out-of-bounds values? (Clamp to parameter's defined min/max range.) diff --git a/openspec/changes/archive/2026-05-21-semantic-parameter-layer/proposal.md b/openspec/changes/archive/2026-05-21-semantic-parameter-layer/proposal.md new file mode 100644 index 0000000..a7f3195 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-semantic-parameter-layer/proposal.md @@ -0,0 +1,28 @@ +## Why + +Different Cubism versions and non-standard models use different parameter names for the same semantic concept. For example, mouth openness might be `PARAM_MOUTH_OPEN_Y` (Cubism 2), `PARAM_MOUTH_A` (Cubism 4/5), or `CUSTOM_MOUTH` (VTubeStudio-style models). Currently, any code that manipulates Live2D parameters must hardcode specific parameter names, making the runtime fragile and model-dependent. A Semantic Parameter Layer provides a unified API where callers use semantic names (like `mouthOpen`, `eyeLOpen`) while the runtime maps these to the actual model parameters. + +## What Changes + +- Introduce `SemanticParameterLayer` class that maps semantic names to model-specific parameter names +- Implement runtime parameter detection: scan a loaded model to discover which semantic parameters are available +- Provide `getSemantic()`, `setSemantic()`, `hasSemantic()`, and `registerSemantic()` APIs +- Build a default mapping registry covering common parameters across Cubism 2/4/5 conventions +- Update existing model parameter access (e.g., hit area detection in `model.ts`) to use the semantic layer where applicable +- Expose a capability profile (`CapabilityProfile`) listing detected vs missing semantic parameters per model + +## Capabilities + +### New Capabilities +- `semantic-parameter-layer`: Unified semantic API for Live2D parameter access with automatic model detection +- `parameter-capability-detection`: Runtime discovery of which semantic parameters a model supports + +### Modified Capabilities +- (none — this is a foundational layer that existing code can opt into incrementally) + +## Impact + +- **Frontend runtime**: New `packages/live2d/src/runtime/semantic/` directory +- **Model initialization**: `Model.create()` will instantiate and populate `SemanticParameterLayer` after loading +- **Existing code**: `model.ts` hit area / head detection can optionally use semantic lookups; no breaking changes +- **Future features**: Procedural Animation and AI Runtime Hooks will depend on this layer diff --git a/openspec/changes/archive/2026-05-21-semantic-parameter-layer/specs/parameter-capability-detection/spec.md b/openspec/changes/archive/2026-05-21-semantic-parameter-layer/specs/parameter-capability-detection/spec.md new file mode 100644 index 0000000..adb022f --- /dev/null +++ b/openspec/changes/archive/2026-05-21-semantic-parameter-layer/specs/parameter-capability-detection/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Detect available semantic parameters from model +After model loading, the system SHALL scan the model's parameters and produce a `CapabilityProfile` listing which semantic parameters are available. + +#### Scenario: Full capability detection +- **WHEN** a model with parameters `['PARAM_ANGLE_X', 'PARAM_MOUTH_A', 'PARAM_EYE_L_OPEN']` is loaded +- **AND** the semantic registry includes `angleX`, `mouthOpen`, `eyeLOpen`, `breath` +- **THEN** the capability profile reports `angleX`, `mouthOpen`, `eyeLOpen` as detected +- **AND** reports `breath` as missing + +### Requirement: Categorize parameter availability +The capability profile SHALL categorize each known semantic as `detected`, `missing`, or `not-applicable`. + +#### Scenario: Not-applicable semantics +- **WHEN** a Cubism 2 model is loaded +- **AND** a Cubism-5-only semantic (e.g., `paramRepeat`) is in the registry +- **THEN** that semantic is categorized as `not-applicable` + +### Requirement: Expose missing parameters for diagnostics +The capability profile SHALL expose a list of missing semantics so higher-level systems can adapt their behavior or warn users. + +#### Scenario: Missing mouth parameter disables lip sync +- **WHEN** the `mouthOpen` semantic is missing from the capability profile +- **AND** the text lip sync system checks capability before activation +- **THEN** lip sync is gracefully disabled + +### Requirement: O(1) parameter access after detection +After initial detection, semantic parameter read/write operations SHALL be O(1) via cached direct references. + +#### Scenario: Performance guarantee +- **WHEN** 50 semantic `set` operations are performed in a single frame +- **THEN** total time is under 1ms on mid-tier hardware diff --git a/openspec/changes/archive/2026-05-21-semantic-parameter-layer/specs/semantic-parameter-layer/spec.md b/openspec/changes/archive/2026-05-21-semantic-parameter-layer/specs/semantic-parameter-layer/spec.md new file mode 100644 index 0000000..8e350d4 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-semantic-parameter-layer/specs/semantic-parameter-layer/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Map semantic names to model parameters +The system SHALL maintain a registry that maps semantic names (e.g., `mouthOpen`) to arrays of candidate parameter IDs. When a model is loaded, the system SHALL resolve each semantic to the first available candidate parameter on that model. + +#### Scenario: Parameter resolution for Cubism 4 model +- **WHEN** a Cubism 4 model is loaded with parameter `PARAM_MOUTH_A` +- **AND** the semantic `mouthOpen` maps to candidates `['PARAM_MOUTH_OPEN_Y', 'PARAM_MOUTH_A', 'MOUTH_OPEN']` +- **THEN** the system resolves `mouthOpen` to `PARAM_MOUTH_A` + +#### Scenario: Parameter resolution for Cubism 2 model +- **WHEN** a Cubism 2 model is loaded with parameter `PARAM_MOUTH_OPEN_Y` +- **AND** the semantic `mouthOpen` maps to the same candidate list +- **THEN** the system resolves `mouthOpen` to `PARAM_MOUTH_OPEN_Y` + +### Requirement: Set semantic parameter values +The system SHALL provide a `setSemantic(name, value, blendMode)` method that writes the value to the resolved parameter. The blendMode SHALL be either `override` (replace) or `add` (add to existing). + +#### Scenario: Override blend mode +- **WHEN** `setSemantic('angleX', 15, 'override')` is called +- **THEN** the model's resolved `angleX` parameter is set to `15` + +#### Scenario: Add blend mode +- **WHEN** the current `angleX` parameter value is `10` +- **AND** `setSemantic('angleX', 5, 'add')` is called +- **THEN** the model's resolved `angleX` parameter becomes `15` + +### Requirement: Get semantic parameter values +The system SHALL provide a `getSemantic(name)` method that returns the current value of the resolved parameter, or `undefined` if the semantic is not mapped. + +#### Scenario: Reading a mapped parameter +- **WHEN** `getSemantic('mouthOpen')` is called on a model where `mouthOpen` is resolved +- **AND** the current parameter value is `0.3` +- **THEN** the method returns `0.3` + +#### Scenario: Reading an unmapped parameter +- **WHEN** `getSemantic('customParam')` is called and no mapping exists +- **THEN** the method returns `undefined` + +### Requirement: Register custom semantic mappings +The system SHALL allow runtime registration of custom semantic mappings via `registerSemantic(name, candidateIds)`. + +#### Scenario: Register custom mapping before model load +- **WHEN** `registerSemantic('earWiggle', ['PARAM_EAR_L', 'CUSTOM_EAR'])` is called +- **AND** a model with parameter `PARAM_EAR_L` is subsequently loaded +- **THEN** the semantic `earWiggle` resolves to `PARAM_EAR_L` + +### Requirement: Clamp values to parameter bounds +The system SHALL clamp written values to the parameter's defined minimum and maximum range. + +#### Scenario: Value clamping +- **WHEN** a parameter has range `[-30, 30]` +- **AND** `setSemantic('angleX', 50, 'override')` is called +- **THEN** the parameter is set to `30` (clamped to max) diff --git a/openspec/changes/archive/2026-05-21-semantic-parameter-layer/tasks.md b/openspec/changes/archive/2026-05-21-semantic-parameter-layer/tasks.md new file mode 100644 index 0000000..84ae25a --- /dev/null +++ b/openspec/changes/archive/2026-05-21-semantic-parameter-layer/tasks.md @@ -0,0 +1,36 @@ +## 1. Module Setup + +- [x] 1.1 Create `packages/live2d/src/runtime/semantic/` directory structure +- [x] 1.2 Define TypeScript interfaces: `SemanticMapping`, `CapabilityProfile`, `BlendMode` +- [x] 1.3 Create `DEFAULT_SEMANTIC_MAPPINGS` registry with common Cubism 2/4/5 parameter names + +## 2. Core Implementation + +- [x] 2.1 Implement `SemanticParameterLayer` class with `registerSemantic()`, `detectFromModel()` +- [x] 2.2 Implement `ModelParameterAccessor` helper to normalize Cubism 2 vs 4/5 parameter structures +- [x] 2.3 Implement `setSemantic(name, value, blendMode)` with clamping to parameter bounds +- [x] 2.4 Implement `getSemantic(name)` returning current resolved parameter value +- [x] 2.5 Implement `hasSemantic(name)` returning boolean +- [x] 2.6 Implement `CapabilityProfile` generation from detected parameters + +## 3. Model Integration + +- [x] 3.1 Add `semanticLayer` property to `Model` class +- [x] 3.2 Call `detectFromModel()` in `Model.create()` after `Live2DModel` loads +- [x] 3.3 Expose `Model.getSemanticLayer()` public accessor +- [x] 3.4 Log capability profile to console when `consoleShowStatus` is enabled + +## 4. Refactor Demonstration + +- [x] 4.1 Optionally refactor `model.ts` head hit area detection to use semantic `head` lookup +- [x] 4.2 Verify no breaking changes to existing model loading flow + +## 5. Testing + +- [x] 5.1 Unit test: `detectFromModel` resolves `mouthOpen` for Cubism 2 model +- [x] 5.2 Unit test: `detectFromModel` resolves `mouthOpen` for Cubism 4/5 model +- [x] 5.3 Unit test: `setSemantic` with `override` replaces parameter value +- [x] 5.4 Unit test: `setSemantic` with `add` adds to parameter value +- [x] 5.5 Unit test: `setSemantic` clamps out-of-bounds values +- [x] 5.6 Unit test: `registerSemantic` adds custom mapping before detection +- [x] 5.7 Integration test: `CapabilityProfile` correctly categorizes detected/missing parameters diff --git a/openspec/specs/behavior-fsm/spec.md b/openspec/specs/behavior-fsm/spec.md new file mode 100644 index 0000000..dbbd35c --- /dev/null +++ b/openspec/specs/behavior-fsm/spec.md @@ -0,0 +1,25 @@ +# behavior-fsm Specification + +## Purpose +TBD - created by archiving change behavior-fsm. Update Purpose after archive. +## Requirements +### Requirement: Register named behavior states +The system SHALL support registering named behavior states, each with an optional entry profile, exit profile, entry hook, exit hook, and transition guard. + +#### Scenario: Register idle state +- **WHEN** a state named `idle` is registered with an entry profile +- **THEN** the state is stored and can be transitioned to + +### Requirement: Transition between states +The system SHALL support transitioning from the current state to a target state via `transitionTo(target)`. + +#### Scenario: Valid transition +- **WHEN** the current state is `idle` +- **AND** `transitionTo("happy")` is called +- **THEN** the current state becomes `happy` + +#### Scenario: Same-state transition is a no-op +- **WHEN** the current state is `idle` +- **AND** `transitionTo("idle")` is called +- **THEN** no transition occurs + diff --git a/openspec/specs/behavior-profile/spec.md b/openspec/specs/behavior-profile/spec.md new file mode 100644 index 0000000..25ea949 --- /dev/null +++ b/openspec/specs/behavior-profile/spec.md @@ -0,0 +1,33 @@ +# behavior-profile Specification + +## Purpose +TBD - created by archiving change behavior-fsm. Update Purpose after archive. +## Requirements +### Requirement: BehaviorProfile maps to runtime systems +A `BehaviorProfile` SHALL define effects on motion layers, filters, and semantic parameters. + +#### Scenario: Profile with motion layer parameters +- **WHEN** a profile specifies `{ talk: { mouthOpen: 0.8 } }` +- **AND** the profile is applied +- **THEN** `motionLayerSystem.play({ layer: "talk", parameters: { mouthOpen: 0.8 } })` is called + +#### Scenario: Profile with filter preset +- **WHEN** a profile specifies `filters: ["happy-glow"]` +- **AND** the profile is applied +- **THEN** `filterPipeline.applyPreset("happy-glow")` is called + +### Requirement: Profile reversal on state exit +The system SHALL support reversing a profile's effects on state exit. + +#### Scenario: Stop motion on exit +- **WHEN** a state with a talk layer motion is exited +- **THEN** the talk layer is stopped with fade out + +### Requirement: Profile inheritance +Profiles SHALL support inheritance from a base profile with state-specific overrides. + +#### Scenario: Inherit and override +- **WHEN** a base profile sets `idle: { breath: 0.1 }` +- **AND** a state profile inherits the base and overrides `idle: { breath: 0.2 }` +- **THEN** the effective profile uses `breath: 0.2` + diff --git a/openspec/specs/blink-module/spec.md b/openspec/specs/blink-module/spec.md new file mode 100644 index 0000000..119d393 --- /dev/null +++ b/openspec/specs/blink-module/spec.md @@ -0,0 +1,34 @@ +# blink-module Specification + +## Purpose +TBD - created by archiving change procedural-animation-system. Update Purpose after archive. +## Requirements +### Requirement: Trigger random-interval blinks +The module SHALL trigger eye blinks at random intervals within a configurable range. + +#### Scenario: Random blink timing +- **WHEN** the module is configured with interval range `[2000, 6000]` ms +- **THEN** blinks occur at random times, no sooner than 2s and no later than 6s apart + +### Requirement: Animate blink with natural curve +Each blink SHALL animate the `eyeLOpen` and `eyeROpen` parameters from open (`1`) to closed (`0`) and back to open over approximately 150ms. + +#### Scenario: Blink animation curve +- **WHEN** a blink starts at time `T` +- **THEN** at `T+75ms`, both eye parameters are near `0` +- **AND** at `T+150ms`, both parameters return to `1` + +### Requirement: Disable when model has native auto-blink +If the model's native settings have auto-blink enabled, the procedural blink module SHALL disable itself to avoid conflict. + +#### Scenario: Native auto-blink detected +- **WHEN** the loaded model has `autoBlink: true` in its settings +- **THEN** the procedural blink module does not activate + +### Requirement: Configurable blink duration +The module SHALL support configuring the duration of each blink animation. + +#### Scenario: Slow blink +- **WHEN** the module is configured with `duration: 300` +- **THEN** each blink takes 300ms from open to closed to open + diff --git a/openspec/specs/breathing-module/spec.md b/openspec/specs/breathing-module/spec.md new file mode 100644 index 0000000..b4fb229 --- /dev/null +++ b/openspec/specs/breathing-module/spec.md @@ -0,0 +1,27 @@ +# breathing-module Specification + +## Purpose +TBD - created by archiving change procedural-animation-system. Update Purpose after archive. +## Requirements +### Requirement: Generate sine-wave breathing motion +The module SHALL produce a continuous sine-wave value for the `breath` semantic parameter. + +#### Scenario: Breathing cycle +- **WHEN** the module is active +- **THEN** the `breath` parameter oscillates in a smooth sine wave with a period of approximately 3 seconds + +### Requirement: Configurable breathing frequency and amplitude +The module SHALL accept configuration for breathing frequency (period in seconds) and amplitude (max parameter offset). + +#### Scenario: Custom breathing settings +- **WHEN** the module is configured with `period: 4000` and `amplitude: 0.2` +- **THEN** the breathing cycle completes every 4 seconds +- **AND** the parameter offset ranges from `0` to `0.2` + +### Requirement: Disable when breath parameter unavailable +If the model does not have a resolvable `breath` semantic, the module SHALL silently become a no-op. + +#### Scenario: Missing breath parameter +- **WHEN** `CapabilityProfile` reports `breath` as missing +- **THEN** the breathing module does not attempt to set any parameter + diff --git a/openspec/specs/cross-layer-blending/spec.md b/openspec/specs/cross-layer-blending/spec.md new file mode 100644 index 0000000..1675db8 --- /dev/null +++ b/openspec/specs/cross-layer-blending/spec.md @@ -0,0 +1,28 @@ +# cross-layer-blending Specification + +## Purpose +TBD - created by archiving change motion-layer-system. Update Purpose after archive. +## Requirements +### Requirement: Override blend mode +When a higher-priority track uses `blend: "override"` on a parameter, it SHALL replace the value from all lower-priority tracks. + +#### Scenario: Gesture override +- **WHEN** the `gesture` layer (priority 4) sets `angleX` to 20 with `override` +- **AND** the `idle` layer (priority 1) sets `angleX` to 5 +- **THEN** the final `angleX` value is 20 + +### Requirement: Add blend mode +When multiple tracks use `blend: "add"` on the same parameter, their values SHALL be summed. + +#### Scenario: Physics叠加 +- **WHEN** the `physics` layer adds `0.1` to `breath` +- **AND** another layer also adds `0.05` to `breath` +- **THEN** the final `breath` value includes `0.15` from add layers + +### Requirement: Blend weight normalization +When multiple `add` layers target the same parameter, the system SHALL normalize their weights if the sum exceeds a safe threshold. + +#### Scenario: Weight normalization +- **WHEN** three layers each add `1.0` to the same parameter +- **THEN** the system normalizes so the total add contribution does not exceed `1.0` + diff --git a/openspec/specs/emotion-registry/spec.md b/openspec/specs/emotion-registry/spec.md new file mode 100644 index 0000000..1aa316b --- /dev/null +++ b/openspec/specs/emotion-registry/spec.md @@ -0,0 +1,27 @@ +# emotion-registry Specification + +## Purpose +TBD - created by archiving change emotion-timeline. Update Purpose after archive. +## Requirements +### Requirement: Built-in emotion registry +The system SHALL ship with a default registry mapping 8 emotion names to semantic parameter target values. + +#### Scenario: Built-in emotions exist +- **WHEN** the system initializes +- **THEN** emotions `neutral`, `happy`, `sad`, `angry`, `shy`, `surprised`, `sleepy`, and `embarrassed` are registered + +### Requirement: Register custom emotions +The system SHALL support registering custom emotions at runtime. + +#### Scenario: Custom emotion +- **WHEN** `registerEmotion("excited", { eyeSmile: 0.9, mouthOpen: 0.6 })` is called +- **THEN** `transitionTo("excited")` uses the registered parameter targets + +### Requirement: Emotion maps to filter preset +Each emotion SHALL optionally specify a filter preset. + +#### Scenario: Happy emotion with warm glow +- **WHEN** `happy` emotion specifies `filterPreset: "happy-glow"` +- **AND** `transitionTo("happy")` is called +- **THEN** the filter preset is applied during the transition + diff --git a/openspec/specs/emotion-timeline/spec.md b/openspec/specs/emotion-timeline/spec.md new file mode 100644 index 0000000..7b1d6c7 --- /dev/null +++ b/openspec/specs/emotion-timeline/spec.md @@ -0,0 +1,31 @@ +# emotion-timeline Specification + +## Purpose +TBD - created by archiving change emotion-timeline. Update Purpose after archive. +## Requirements +### Requirement: Transition between named emotions +The system SHALL support `transitionTo(emotion, duration?, easing?)` to interpolate from the current emotional state to a target emotion. + +#### Scenario: Neutral to happy transition +- **WHEN** `transitionTo("happy", 800)` is called from `neutral` +- **THEN** over 800ms, all parameters smoothly interpolate from neutral values to happy values + +#### Scenario: Default duration +- **WHEN** `transitionTo("happy")` is called without specifying duration +- **THEN** the default duration (500ms) is used + +### Requirement: Enforce minimum transition duration +The system SHALL enforce a minimum transition duration (default 300ms) to prevent jitter. + +#### Scenario: Too-short duration is clamped +- **WHEN** `transitionTo("happy", 50)` is called +- **THEN** the actual transition duration is clamped to 300ms + +### Requirement: Support transition interrupt +A new `transitionTo()` call during an ongoing transition SHALL use the current interpolated values as the new starting point. + +#### Scenario: Interrupt mid-transition +- **WHEN** a transition from `neutral` to `happy` is at 50% (halfway) +- **AND** `transitionTo("sad")` is called +- **THEN** the new transition starts from the current halfway values toward `sad` + diff --git a/openspec/specs/eye-tracking-module/spec.md b/openspec/specs/eye-tracking-module/spec.md new file mode 100644 index 0000000..850d804 --- /dev/null +++ b/openspec/specs/eye-tracking-module/spec.md @@ -0,0 +1,43 @@ +# eye-tracking-module Specification + +## Purpose +TBD - created by archiving change procedural-animation-system. Update Purpose after archive. +## Requirements +### Requirement: Follow cursor position by default +The module SHALL track the mouse/touch position and drive `angleX`, `angleY`, `eyeBallX`, and `eyeBallY` semantic parameters to make the model look toward the cursor. + +#### Scenario: Cursor in upper-right +- **WHEN** the cursor is at the upper-right of the canvas +- **THEN** `angleX` and `eyeBallX` increase (look right) +- **AND** `angleY` and `eyeBallY` decrease (look up) + +### Requirement: Smooth movement with exponential interpolation +The module SHALL smooth target transitions using exponential decay to avoid jarring jumps. + +#### Scenario: Rapid cursor movement +- **WHEN** the cursor jumps from left to right instantly +- **THEN** the model's head and eyes smoothly follow over approximately 200ms + +### Requirement: Accept programmatic gaze targets +The module SHALL accept a normalized `(x, y)` target in `[-1, 1]` range from sources other than mouse input. + +#### Scenario: AI-directed gaze +- **WHEN** `setTarget(0.5, -0.3)` is called programmatically +- **THEN** the model smoothly looks toward the specified direction +- **AND** mouse input is temporarily overridden until explicitly released + +### Requirement: Disable tracking when cursor leaves canvas +When the cursor leaves the Live2D canvas area, the module SHALL gradually return the gaze to center (`0, 0`) instead of snapping. + +#### Scenario: Cursor leave +- **WHEN** the mouse moves off the canvas +- **THEN** over approximately 500ms, all tracked parameters return to their default values + +### Requirement: Configurable tracking range +The module SHALL support configuring the maximum angle and eyeball offset ranges. + +#### Scenario: Limited head movement +- **WHEN** configured with `maxAngleX: 15` and `maxEyeBallX: 1.0` +- **THEN** `angleX` ranges from `-15` to `15` +- **AND** `eyeBallX` ranges from `-1.0` to `1.0` + diff --git a/openspec/specs/fade-transitions/spec.md b/openspec/specs/fade-transitions/spec.md new file mode 100644 index 0000000..ccc0a98 --- /dev/null +++ b/openspec/specs/fade-transitions/spec.md @@ -0,0 +1,34 @@ +# fade-transitions Specification + +## Purpose +TBD - created by archiving change motion-layer-system. Update Purpose after archive. +## Requirements +### Requirement: Fade-in on motion start +When a motion begins playing, the track SHALL optionally fade in from weight `0` to weight `1` over a configurable duration. + +#### Scenario: Smooth fade in +- **WHEN** `play({ layer: "talk", fadeIn: 300 })` is called +- **THEN** over 300ms, the talk track's weight increases from `0` to `1` + +### Requirement: Fade-out on motion end +When a motion ends or is stopped, the track SHALL optionally fade out from weight `1` to weight `0`. + +#### Scenario: Smooth fade out +- **WHEN** a track with `fadeOut: 200` stops playing +- **THEN** over 200ms, the track's weight decreases from `1` to `0` + +### Requirement: Crossfade between consecutive motions +When a new motion replaces an existing motion on the same layer, the system SHALL support crossfade: the old motion fades out while the new one fades in. + +#### Scenario: Expression crossfade +- **WHEN** expression A is active +- **AND** expression B is played on the same layer with crossfade +- **THEN** expression A fades out while expression B fades in + +### Requirement: Configurable fade curve +The fade curve SHALL be configurable: `linear`, `easeIn`, `easeOut`, `easeInOut`. + +#### Scenario: Ease-in fade +- **WHEN** `play({ fadeIn: 500, fadeCurve: "easeIn" })` is called +- **THEN** the fade starts slowly and accelerates + diff --git a/openspec/specs/halo-plugin-frontend-integration/spec.md b/openspec/specs/halo-plugin-frontend-integration/spec.md new file mode 100644 index 0000000..a98b4e7 --- /dev/null +++ b/openspec/specs/halo-plugin-frontend-integration/spec.md @@ -0,0 +1,44 @@ +# halo-plugin-frontend-integration Specification + +## Purpose +TBD - created by archiving change integrate-modern-live2d-frontend. Update Purpose after archive. +## Requirements +### Requirement: Halo pages SHALL bootstrap the modern Live2D frontend bundle +The plugin SHALL initialize the widget on Halo pages through the maintained frontend bundle instead of the legacy `live2d-autoload` script. + +#### Scenario: Production pages load the packaged frontend entry +- **WHEN** a Halo page includes the Live2D plugin in a production build +- **THEN** the backend MUST emit the modern frontend bootstrap entry from the plugin's packaged static assets +- **AND** the page MUST no longer depend on `static/js/live2d-autoload.min.js` for widget startup + +#### Scenario: Frontend bundle starts the existing widget runtime +- **WHEN** the packaged frontend entry executes on a supported page +- **THEN** it MUST initialize the existing modern Live2D runtime used by `packages/live2d` +- **AND** the widget MUST continue rendering and responding through the maintained Lit/Pixi implementation + +### Requirement: Plugin builds SHALL package the frontend dist output automatically +The plugin build pipeline SHALL produce and package the frontend bundle and its emitted assets without requiring a manual copy step into source-managed resources. + +#### Scenario: Plugin build includes runtime entry and dependent chunks +- **WHEN** the plugin build runs for packaging +- **THEN** the frontend package build output MUST be generated and synchronized into the plugin's packaged static assets +- **AND** the packaged output MUST include the runtime entry file and any emitted chunks or assets required by that entry + +#### Scenario: Packaged asset path remains stable for backend injection +- **WHEN** the frontend build emits hashed chunk filenames +- **THEN** the plugin packaging flow MUST still expose a stable entry path that the backend can inject into Halo pages +- **AND** supporting chunks MUST remain resolvable beneath the same packaged static root + +### Requirement: Local development SHALL support loading the modern frontend from a dev server +The Halo integration SHALL provide a development path that loads the same runtime from a frontend dev server so developers can debug the widget in Halo pages without rebuilding the plugin for each change. + +#### Scenario: Development bootstrap targets the dev server entry +- **WHEN** Live2D frontend development mode is enabled for local debugging +- **THEN** the backend MUST inject the configured frontend dev-server module entry instead of the packaged production asset entry +- **AND** the loaded module MUST consume the same backend-provided runtime config contract as production + +#### Scenario: Production bootstrap remains the default +- **WHEN** development mode is not enabled +- **THEN** the backend MUST load the packaged production frontend entry +- **AND** the dev-server path MUST not be required for normal plugin operation + diff --git a/openspec/specs/live2d-custom-tool-actions/spec.md b/openspec/specs/live2d-custom-tool-actions/spec.md new file mode 100644 index 0000000..2725829 --- /dev/null +++ b/openspec/specs/live2d-custom-tool-actions/spec.md @@ -0,0 +1,44 @@ +# live2d-custom-tool-actions Specification + +## Purpose +TBD - created by archiving change integrate-modern-live2d-frontend. Update Purpose after archive. +## Requirements +### Requirement: Plugin users SHALL be able to declare custom tools through backend configuration +The plugin SHALL allow Halo administrators to configure additional Live2D tools through backend settings so they can extend the toolbar without editing frontend source code. + +#### Scenario: Backend publishes custom tool definitions +- **WHEN** a site administrator configures custom Live2D tools in plugin settings +- **THEN** the backend MUST expose those tools through the public runtime config payload +- **AND** each tool definition MUST include declarative metadata needed to render and order the tool in the frontend + +#### Scenario: Custom tools coexist with preset tools +- **WHEN** the frontend renders the Live2D toolbar with both preset and custom tools configured +- **THEN** it MUST mount the custom tools alongside preset tools +- **AND** custom tools MUST participate in the same ordering and visibility flow as supported preset tools + +### Requirement: Custom tools SHALL bind only to supported declarative actions +The frontend SHALL execute backend-configured custom tools through a supported action registry instead of evaluating arbitrary JavaScript delivered through configuration. + +#### Scenario: Supported action types trigger frontend-exposed capabilities +- **WHEN** a user activates a configured custom tool +- **THEN** the frontend MUST resolve the tool's configured action against a supported action registry +- **AND** it MUST execute the matching runtime capability with declarative payload data only + +#### Scenario: Arbitrary code execution is not supported +- **WHEN** a custom tool definition includes script content or an unsupported executable payload +- **THEN** the plugin MUST reject or ignore that executable content instead of evaluating it in the browser +- **AND** custom tool execution MUST remain limited to supported declarative actions + +### Requirement: The supported action registry SHALL cover core extension use cases +The frontend SHALL expose a documented set of reusable actions so plugin users can extend the toolbar for common Live2D interactions without requiring bespoke frontend patches. + +#### Scenario: Core action set is available for configuration +- **WHEN** the plugin documents or validates supported custom tool actions +- **THEN** it MUST include actions for at least sending a widget message, toggling widget visibility, toggling the chat panel, switching model or texture, capturing a screenshot, opening a configured URL, and emitting a namespaced custom event +- **AND** each action MUST define the declarative payload fields it accepts + +#### Scenario: Advanced model control can be added without a new scripting escape hatch +- **WHEN** the runtime exposes additional safe actions such as loading a specific model selection +- **THEN** those actions MUST be added through the same registry contract +- **AND** extending the registry MUST not require reintroducing raw executable tool definitions + diff --git a/openspec/specs/live2d-public-runtime-config/spec.md b/openspec/specs/live2d-public-runtime-config/spec.md new file mode 100644 index 0000000..6ffb630 --- /dev/null +++ b/openspec/specs/live2d-public-runtime-config/spec.md @@ -0,0 +1,31 @@ +# live2d-public-runtime-config Specification + +## Purpose +TBD - created by archiving change integrate-modern-live2d-frontend. Update Purpose after archive. +## Requirements +### Requirement: Backend SHALL publish a frontend-safe Live2D runtime config payload +The plugin backend SHALL publish a dedicated public config payload for the Live2D frontend instead of exposing a broad merged settings object directly to inline initialization code. + +#### Scenario: Public payload contains only frontend-safe runtime fields +- **WHEN** the backend prepares Live2D configuration for page rendering +- **THEN** it MUST map settings into a dedicated public payload that includes only fields required by the frontend runtime +- **AND** backend-only or sensitive settings MUST be excluded from that payload by construction + +#### Scenario: Public payload remains compatible with the modern runtime +- **WHEN** the frontend bootstrap reads the public config payload +- **THEN** it MUST receive the fields needed to preserve current widget behavior, including runtime toggles, tips sources, model defaults, AI-chat runtime timings, and declarative custom tool definitions when configured +- **AND** it MUST not require the backend to expose raw settings groups that are not part of the frontend contract + +### Requirement: Halo bootstrap SHALL deliver config separately from execution logic +The plugin SHALL provide the runtime config payload in a transport that is separate from the frontend execution bundle so configuration and code loading can evolve independently. + +#### Scenario: Config is embedded without rebuilding executable inline logic +- **WHEN** a Halo page is rendered with the Live2D plugin enabled +- **THEN** the backend MUST emit the public config payload in a machine-readable form that the frontend bundle can read at startup +- **AND** the backend MUST not need to serialize a broad JavaScript object directly into inline runtime initialization code + +#### Scenario: Development and production share the same config contract +- **WHEN** the frontend is loaded from packaged assets or from a dev server +- **THEN** both startup modes MUST read the same public config payload shape +- **AND** environment-specific bootstrap differences MUST not require a second config format + diff --git a/openspec/specs/live2d-widget-behavior-parity/spec.md b/openspec/specs/live2d-widget-behavior-parity/spec.md deleted file mode 100644 index 37f211a..0000000 --- a/openspec/specs/live2d-widget-behavior-parity/spec.md +++ /dev/null @@ -1,21 +0,0 @@ -## ADDED Requirements - -### Requirement: Widget dismissal SHALL preserve mounted runtime state during the current page session -The modern widget SHALL treat quit and toggle dismissal as a visibility change instead of tearing down the mounted runtime subtree, so the current model and tool state remain available when the widget is reopened on the same page. - -#### Scenario: Quit hides the mounted widget without resetting the current model -- **WHEN** a user dismisses the widget through the `quit` tool after switching to another model or texture -- **THEN** the widget MUST transition to a hidden state without recreating the Live2D runtime immediately -- **AND** reopening the widget during the same page session MUST continue from the current model state instead of reloading the default model - -#### Scenario: Reopening after dismissal does not duplicate initialization side effects -- **WHEN** the widget is hidden and then shown again on the same page -- **THEN** the runtime MUST not register duplicate tip listeners or re-run first-open initialization solely because of that visibility change - -### Requirement: Console status compatibility SHALL remain available through the legacy config flag -When `consoleShowStatus` or its legacy alias `consoleShowStatu` is enabled, the modern runtime SHALL preserve the observable console compatibility output expected from the legacy widget runtime. - -#### Scenario: Console compatibility output includes widget metadata and load status -- **WHEN** the widget initializes with console status output enabled -- **THEN** the runtime MUST emit readable console output that includes the plugin version/update metadata represented by the legacy runtime -- **AND** it MUST continue emitting model load completion status for the requested model selection diff --git a/openspec/specs/model-part-filtering/spec.md b/openspec/specs/model-part-filtering/spec.md new file mode 100644 index 0000000..7f506a1 --- /dev/null +++ b/openspec/specs/model-part-filtering/spec.md @@ -0,0 +1,29 @@ +# model-part-filtering Specification + +## Purpose +TBD - created by archiving change runtime-filter-pipeline. Update Purpose after archive. +## Requirements +### Requirement: Target filters to specific model parts +When the engine exposes drawable access, the system SHALL support applying filters to specific parts of the model by drawable name pattern. + +#### Scenario: Eye glow effect +- **WHEN** a glow filter is configured with target pattern `/eye/i` +- **AND** the model has drawables named `eyeL` and `eyeR` +- **THEN** the glow is applied only to the eye drawables + +### Requirement: Fallback to full-model filtering +If part-level targeting is not supported by the engine or no drawables match the pattern, the filter SHALL apply to the entire model. + +#### Scenario: Pattern mismatch fallback +- **WHEN** a filter targets pattern `/nonexistent/i` +- **AND** no drawables match +- **THEN** the filter applies to the entire model + +### Requirement: Part filtering does not affect other drawables +When a filter targets a specific part, other drawables SHALL render without that filter. + +#### Scenario: Isolated part effect +- **WHEN** a blush filter targets only drawable `cheekL` +- **THEN** `cheekL` renders with blush +- **AND** all other drawables render normally + diff --git a/openspec/specs/motion-layer-system/spec.md b/openspec/specs/motion-layer-system/spec.md new file mode 100644 index 0000000..66596a2 --- /dev/null +++ b/openspec/specs/motion-layer-system/spec.md @@ -0,0 +1,30 @@ +# motion-layer-system Specification + +## Purpose +TBD - created by archiving change motion-layer-system. Update Purpose after archive. +## Requirements +### Requirement: Support named motion layers +The system SHALL support at minimum these named layers: `idle`, `expression`, `talk`, `gesture`, `physics`. + +#### Scenario: Create layers on initialization +- **WHEN** the `MotionLayerSystem` is initialized +- **THEN** all 5 standard layers are created with default priorities + +### Requirement: Play motion on a specific layer +The system SHALL provide a `play()` method that accepts `layer`, `motion`, `priority`, `fadeIn`, and `blend` options. + +#### Scenario: Play motion on talk layer +- **WHEN** `play({ layer: "talk", motion: greetingMotion, priority: 3 })` is called +- **THEN** the talk layer begins playing the motion + +### Requirement: Query layer state +The system SHALL provide methods to inspect the current state of each layer. + +#### Scenario: Check if layer is active +- **WHEN** `isPlaying("talk")` is called while the talk layer has an active motion +- **THEN** it returns `true` + +#### Scenario: List active layers +- **WHEN** `getActiveLayers()` is called while idle and talk layers are active +- **THEN** it returns `["idle", "talk"]` + diff --git a/openspec/specs/motion-track/spec.md b/openspec/specs/motion-track/spec.md new file mode 100644 index 0000000..13c1724 --- /dev/null +++ b/openspec/specs/motion-track/spec.md @@ -0,0 +1,37 @@ +# motion-track Specification + +## Purpose +TBD - created by archiving change motion-layer-system. Update Purpose after archive. +## Requirements +### Requirement: Independent playback per track +Each `MotionTrack` SHALL maintain its own playback state independently of other tracks. + +#### Scenario: Concurrent playback +- **WHEN** the `idle` track is playing an idle motion +- **AND** the `talk` track begins playing a talk motion +- **THEN** both tracks continue playing simultaneously + +### Requirement: Track priority system +Each track SHALL have a priority level. Higher priority tracks override lower priority tracks on conflicting parameters. + +#### Scenario: Gesture overrides idle head movement +- **WHEN** the `idle` track is affecting `angleX` at priority 1 +- **AND** the `gesture` track begins affecting `angleX` at priority 4 +- **THEN** the `gesture` track's value takes precedence for `angleX` + +### Requirement: Interruptible flag +A track SHALL support an `interruptible` flag. If `false`, the track cannot be stopped by lower-priority incoming tracks. + +#### Scenario: Non-interruptible expression +- **WHEN** an expression track is playing with `interruptible: false` +- **AND** a lower-priority motion attempts to play on the same layer +- **THEN** the new motion is queued or rejected, not interrupting the current one + +### Requirement: Track lifecycle states +A track SHALL have states: `idle`, `fadingIn`, `active`, `fadingOut`, `stopped`. + +#### Scenario: Track state transitions +- **WHEN** a motion starts playing +- **THEN** the track transitions from `idle` → `fadingIn` → `active` +- **AND** when the motion ends or is stopped, it transitions `active` → `fadingOut` → `stopped` + diff --git a/openspec/specs/multi-cubism-live2d-rendering/spec.md b/openspec/specs/multi-cubism-live2d-rendering/spec.md index dddf699..932624e 100644 --- a/openspec/specs/multi-cubism-live2d-rendering/spec.md +++ b/openspec/specs/multi-cubism-live2d-rendering/spec.md @@ -1,4 +1,8 @@ -## ADDED Requirements +## Purpose + +Define the Live2D rendering capabilities for the Halo plugin, ensuring support for Cubism 2/3/4/5 models through a maintained renderer while preserving existing interaction flows. + +## Requirements ### Requirement: Plugin SHALL render supported Live2D models through the maintained renderer The plugin SHALL replace `pixi-live2d-display` with a maintained renderer integration that can load the plugin's configured Live2D assets for Cubism 2 / 3 / 4 / 5 models. diff --git a/openspec/specs/parameter-capability-detection/spec.md b/openspec/specs/parameter-capability-detection/spec.md new file mode 100644 index 0000000..696595d --- /dev/null +++ b/openspec/specs/parameter-capability-detection/spec.md @@ -0,0 +1,37 @@ +# parameter-capability-detection Specification + +## Purpose +TBD - created by archiving change semantic-parameter-layer. Update Purpose after archive. +## Requirements +### Requirement: Detect available semantic parameters from model +After model loading, the system SHALL scan the model's parameters and produce a `CapabilityProfile` listing which semantic parameters are available. + +#### Scenario: Full capability detection +- **WHEN** a model with parameters `['PARAM_ANGLE_X', 'PARAM_MOUTH_A', 'PARAM_EYE_L_OPEN']` is loaded +- **AND** the semantic registry includes `angleX`, `mouthOpen`, `eyeLOpen`, `breath` +- **THEN** the capability profile reports `angleX`, `mouthOpen`, `eyeLOpen` as detected +- **AND** reports `breath` as missing + +### Requirement: Categorize parameter availability +The capability profile SHALL categorize each known semantic as `detected`, `missing`, or `not-applicable`. + +#### Scenario: Not-applicable semantics +- **WHEN** a Cubism 2 model is loaded +- **AND** a Cubism-5-only semantic (e.g., `paramRepeat`) is in the registry +- **THEN** that semantic is categorized as `not-applicable` + +### Requirement: Expose missing parameters for diagnostics +The capability profile SHALL expose a list of missing semantics so higher-level systems can adapt their behavior or warn users. + +#### Scenario: Missing mouth parameter disables lip sync +- **WHEN** the `mouthOpen` semantic is missing from the capability profile +- **AND** the text lip sync system checks capability before activation +- **THEN** lip sync is gracefully disabled + +### Requirement: O(1) parameter access after detection +After initial detection, semantic parameter read/write operations SHALL be O(1) via cached direct references. + +#### Scenario: Performance guarantee +- **WHEN** 50 semantic `set` operations are performed in a single frame +- **THEN** total time is under 1ms on mid-tier hardware + diff --git a/openspec/specs/procedural-animation-system/spec.md b/openspec/specs/procedural-animation-system/spec.md new file mode 100644 index 0000000..2dd0a4f --- /dev/null +++ b/openspec/specs/procedural-animation-system/spec.md @@ -0,0 +1,45 @@ +# procedural-animation-system Specification + +## Purpose +TBD - created by archiving change procedural-animation-system. Update Purpose after archive. +## Requirements +### Requirement: Register and update procedural modules +The system SHALL support registering `ProceduralModule` instances that receive per-frame updates via the Pixi ticker. + +#### Scenario: Register breathing module +- **WHEN** a `BreathingModule` is registered with the system +- **AND** the Pixi ticker advances by 16ms +- **THEN** the module's `update(dt, parameterSet)` method is called with `dt = 16` + +### Requirement: Accumulate parameter changes from all modules +After all modules update, the system SHALL resolve and apply accumulated semantic parameter changes to the model. + +#### Scenario: Multiple modules target same parameter +- **WHEN** `BreathingModule` writes `angleX: 2` with `add` blend mode +- **AND** `EyeTrackingModule` writes `angleX: 15` with `override` blend mode +- **THEN** the final `angleX` value is `15` (override wins) + +### Requirement: Support one-shot procedural animations +The system SHALL support registering temporary one-shot animations that auto-unregister on completion. + +#### Scenario: Head nod animation +- **WHEN** `animate({ target: 'angleX', to: 10, duration: 300, easing: 'easeOut' })` is called +- **THEN** over 300ms, `angleX` smoothly animates from current value to `10` +- **AND** the animation auto-unregisters after completion + +### Requirement: Cap delta time to prevent large jumps +The system SHALL cap `dt` passed to modules at 100ms to prevent visual jumps when the tab is backgrounded or frame rate drops. + +#### Scenario: Tab backgrounded +- **WHEN** the user switches tabs for 5 seconds +- **AND** returns to the tab +- **THEN** the next update uses `dt = 100` (capped), not `5000` + +### Requirement: Attach and detach with model lifecycle +The system SHALL attach to the Pixi ticker when the model is ready and detach when the model is destroyed. + +#### Scenario: Model destroy cleanup +- **WHEN** `model.destroy()` is called +- **THEN** the procedural system's ticker callback is removed +- **AND** all modules are cleared + diff --git a/openspec/specs/runtime-conflict-resolution/spec.md b/openspec/specs/runtime-conflict-resolution/spec.md new file mode 100644 index 0000000..32f0e1f --- /dev/null +++ b/openspec/specs/runtime-conflict-resolution/spec.md @@ -0,0 +1,37 @@ +# runtime-conflict-resolution Specification + +## Purpose +TBD - created by archiving change runtime-devtools. Update Purpose after archive. +## Requirements +### Requirement: Detect cross-system parameter conflicts +The system SHALL detect when multiple runtime systems attempt to write to the same semantic parameter within the same frame. + +#### Scenario: FSM and Timeline target same parameter +- **WHEN** the FSM sets `mouthSmile: 0.5` and the EmotionTimeline sets `mouthSmile: 0.8` in the same frame +- **THEN** the controller detects a conflict on `mouthSmile` + +### Requirement: Resolve conflicts by priority +The system SHALL resolve conflicts using a defined priority order. + +#### Scenario: Higher priority wins +- **WHEN** ProceduralAnimation (priority 5) writes `breath: 0.1` +- **AND** EmotionTimeline (priority 3) writes `breath: 0.3` +- **THEN** the effective value is `breath: 0.3` (EmotionTimeline wins) + +#### Scenario: Manual override has highest priority +- **WHEN** a dev tools slider manually sets `eyeLOpen: 0.5` +- **AND** any other system writes a different value to `eyeLOpen` +- **THEN** the manual value `0.5` is applied + +### Requirement: Log suppressed writes for debugging +The system SHALL record which writes were suppressed and why. + +#### Scenario: Conflict log entry +- **WHEN** a conflict occurs and the EmotionTimeline wins +- **THEN** a log entry is created showing: parameter name, losing system, winning system, both values + +#### Scenario: DevTools displays conflict log +- **WHEN** the DevTools panel is open +- **AND** conflicts have occurred +- **THEN** a "Conflict Log" section displays all recent conflicts with timestamps + diff --git a/openspec/specs/runtime-controller/spec.md b/openspec/specs/runtime-controller/spec.md new file mode 100644 index 0000000..c25254e --- /dev/null +++ b/openspec/specs/runtime-controller/spec.md @@ -0,0 +1,35 @@ +# runtime-controller Specification + +## Purpose +TBD - created by archiving change runtime-devtools. Update Purpose after archive. +## Requirements +### Requirement: Controller owns subsystem lifecycle +The system SHALL provide a `Live2dRuntimeController` class that instantiates and manages all runtime subsystems. + +#### Scenario: Controller initializes after model load +- **WHEN** a Live2D model is loaded +- **AND** `Live2dRuntimeController` is instantiated with the loaded model +- **THEN** all runtime subsystems (BehaviorFSM, EmotionTimeline, MotionLayerSystem, FilterPipeline, ProceduralAnimationSystem, SemanticParameterLayer) are created and initialized + +#### Scenario: Controller exposes subsystem access +- **WHEN** external code calls `controller.getBehaviorFSM()` +- **THEN** the BehaviorFSM instance is returned +- **AND** calling `controller.getEmotionTimeline()` returns the EmotionTimeline instance + +### Requirement: Controller provides unified transition API +The system SHALL expose a unified `transitionTo({ fsm?, emotion?, filter? })` method that coordinates cross-system transitions. + +#### Scenario: Coordinated state and emotion transition +- **WHEN** `controller.transitionTo({ fsm: 'talking', emotion: 'happy' })` is called +- **THEN** the FSM transitions to `talking` +- **AND** the EmotionTimeline transitions to `happy` +- **AND** parameter conflicts are resolved by the controller + +### Requirement: Controller destroys all subsystems +The system SHALL provide a `destroy()` method that cleans up all owned subsystems. + +#### Scenario: Model destruction propagates to controller +- **WHEN** `controller.destroy()` is called +- **THEN** all subsystems are destroyed in dependency order +- **AND** no memory leaks or dangling timers remain + diff --git a/openspec/specs/runtime-devtools/spec.md b/openspec/specs/runtime-devtools/spec.md new file mode 100644 index 0000000..4b1496d --- /dev/null +++ b/openspec/specs/runtime-devtools/spec.md @@ -0,0 +1,71 @@ +# runtime-devtools Specification + +## Purpose +TBD - created by archiving change runtime-devtools. Update Purpose after archive. +## Requirements +### Requirement: DevTools panel displays runtime status +The system SHALL provide a floating panel that displays the real-time status of all runtime subsystems. + +#### Scenario: Panel shows FSM state +- **WHEN** the DevTools panel is open +- **AND** the current FSM state is `idle` +- **THEN** the panel displays "idle" as the current state + +#### Scenario: Panel shows emotion transition progress +- **WHEN** the DevTools panel is open +- **AND** a transition from `neutral` to `happy` is 50% complete +- **THEN** the panel shows a progress bar at 50% with "neutral → happy" + +#### Scenario: Panel shows motion layer statuses +- **WHEN** the DevTools panel is open +- **AND** the `talk` layer is active with weight 0.8 +- **THEN** the panel displays the talk layer as active with weight 0.8 + +#### Scenario: Panel shows active filters +- **WHEN** the DevTools panel is open +- **AND** a `happy-glow` filter is active +- **THEN** the panel lists "happy-glow" with its intensity + +#### Scenario: Panel shows semantic parameter values +- **WHEN** the DevTools panel is open +- **AND** the `mouthSmile` parameter has value 0.6 +- **THEN** the panel displays `mouthSmile: 0.6` + +### Requirement: DevTools provides manual trigger buttons +The system SHALL provide buttons to manually trigger FSM states, emotions, and filter presets. + +#### Scenario: Trigger FSM state from panel +- **WHEN** the user clicks the "happy" state button in the FSM section +- **THEN** `fsm.transitionTo('happy')` is called + +#### Scenario: Trigger emotion from panel +- **WHEN** the user clicks the "angry" emotion button +- **THEN** `emotionTimeline.transitionTo('angry')` is called + +#### Scenario: Apply filter from panel +- **WHEN** the user clicks the "shy-blush" filter button +- **THEN** `filterPipeline.applyPreset('shy-blush')` is called + +#### Scenario: Toggle procedural module from panel +- **WHEN** the user unchecks the "Blink" toggle +- **THEN** the Blink module is disabled in the procedural system + +### Requirement: DevTools is dev-only +The system SHALL ensure the DevTools component is not included in production builds. + +#### Scenario: Production build excludes DevTools +- **WHEN** the application is built for production +- **THEN** the DevTools component code is not included in the bundle + +### Requirement: DevTools toggle mechanism +The system SHALL support toggling the panel via keyboard shortcut and a corner indicator. + +#### Scenario: Keyboard shortcut toggles panel +- **WHEN** the user presses `Ctrl+Shift+D` +- **THEN** the DevTools panel visibility toggles + +#### Scenario: Corner indicator shows availability +- **WHEN** the DevTools panel is hidden +- **THEN** a small indicator icon is visible in the bottom-right corner +- **AND** clicking the indicator opens the panel + diff --git a/openspec/specs/runtime-filter-pipeline/spec.md b/openspec/specs/runtime-filter-pipeline/spec.md new file mode 100644 index 0000000..52a0d8f --- /dev/null +++ b/openspec/specs/runtime-filter-pipeline/spec.md @@ -0,0 +1,48 @@ +# runtime-filter-pipeline Specification + +## Purpose +TBD - created by archiving change runtime-filter-pipeline. Update Purpose after archive. +## Requirements +### Requirement: Add runtime filter effects to Live2D model +The system SHALL provide a `FilterPipeline` that can attach PixiJS v8 filters to a `Live2DModel` instance at runtime. + +#### Scenario: Add mood lighting effect +- **WHEN** `filterPipeline.add(new MoodLightingEffect({ color: 'warm', intensity: 0.3 }))` is called +- **THEN** the model renders with a warm color tint + +#### Scenario: Remove effect by handle +- **WHEN** a filter is added and returns handle `{ id: 'abc-123' }` +- **AND** `filterPipeline.remove('abc-123')` is called +- **THEN** the effect is removed and the model returns to normal rendering + +### Requirement: Support multiple concurrent effects +The system SHALL support multiple active effects simultaneously, compositing them in the order they were added. + +#### Scenario: Stacked effects +- **WHEN** a blur effect and a color matrix effect are both active +- **THEN** the model renders with both effects applied in sequence + +### Requirement: Adjust effect intensity at runtime +Active effects SHALL support dynamic intensity adjustment. + +#### Scenario: Fade in blush effect +- **WHEN** a blush effect is added with intensity `0` +- **AND** its intensity is gradually increased to `0.5` over 500ms +- **THEN** the model's blush rendering smoothly fades in + +### Requirement: Clear all effects +The system SHALL provide a `clear()` method that removes all active effects. + +#### Scenario: Clear pipeline +- **WHEN** three effects are active +- **AND** `filterPipeline.clear()` is called +- **THEN** no filters remain on the model + +### Requirement: Inherit renderer resolution +All filters SHALL inherit the Pixi renderer's resolution to prevent visual degradation from downsampling. + +#### Scenario: High-DPI display +- **WHEN** the device pixel ratio is `2` +- **AND** a blur filter is applied +- **THEN** the blur appears at correct visual strength (not overly blurred) + diff --git a/openspec/specs/semantic-parameter-layer/spec.md b/openspec/specs/semantic-parameter-layer/spec.md new file mode 100644 index 0000000..fcc7349 --- /dev/null +++ b/openspec/specs/semantic-parameter-layer/spec.md @@ -0,0 +1,58 @@ +# semantic-parameter-layer Specification + +## Purpose +TBD - created by archiving change semantic-parameter-layer. Update Purpose after archive. +## Requirements +### Requirement: Map semantic names to model parameters +The system SHALL maintain a registry that maps semantic names (e.g., `mouthOpen`) to arrays of candidate parameter IDs. When a model is loaded, the system SHALL resolve each semantic to the first available candidate parameter on that model. + +#### Scenario: Parameter resolution for Cubism 4 model +- **WHEN** a Cubism 4 model is loaded with parameter `PARAM_MOUTH_A` +- **AND** the semantic `mouthOpen` maps to candidates `['PARAM_MOUTH_OPEN_Y', 'PARAM_MOUTH_A', 'MOUTH_OPEN']` +- **THEN** the system resolves `mouthOpen` to `PARAM_MOUTH_A` + +#### Scenario: Parameter resolution for Cubism 2 model +- **WHEN** a Cubism 2 model is loaded with parameter `PARAM_MOUTH_OPEN_Y` +- **AND** the semantic `mouthOpen` maps to the same candidate list +- **THEN** the system resolves `mouthOpen` to `PARAM_MOUTH_OPEN_Y` + +### Requirement: Set semantic parameter values +The system SHALL provide a `setSemantic(name, value, blendMode)` method that writes the value to the resolved parameter. The blendMode SHALL be either `override` (replace) or `add` (add to existing). + +#### Scenario: Override blend mode +- **WHEN** `setSemantic('angleX', 15, 'override')` is called +- **THEN** the model's resolved `angleX` parameter is set to `15` + +#### Scenario: Add blend mode +- **WHEN** the current `angleX` parameter value is `10` +- **AND** `setSemantic('angleX', 5, 'add')` is called +- **THEN** the model's resolved `angleX` parameter becomes `15` + +### Requirement: Get semantic parameter values +The system SHALL provide a `getSemantic(name)` method that returns the current value of the resolved parameter, or `undefined` if the semantic is not mapped. + +#### Scenario: Reading a mapped parameter +- **WHEN** `getSemantic('mouthOpen')` is called on a model where `mouthOpen` is resolved +- **AND** the current parameter value is `0.3` +- **THEN** the method returns `0.3` + +#### Scenario: Reading an unmapped parameter +- **WHEN** `getSemantic('customParam')` is called and no mapping exists +- **THEN** the method returns `undefined` + +### Requirement: Register custom semantic mappings +The system SHALL allow runtime registration of custom semantic mappings via `registerSemantic(name, candidateIds)`. + +#### Scenario: Register custom mapping before model load +- **WHEN** `registerSemantic('earWiggle', ['PARAM_EAR_L', 'CUSTOM_EAR'])` is called +- **AND** a model with parameter `PARAM_EAR_L` is subsequently loaded +- **THEN** the semantic `earWiggle` resolves to `PARAM_EAR_L` + +### Requirement: Clamp values to parameter bounds +The system SHALL clamp written values to the parameter's defined minimum and maximum range. + +#### Scenario: Value clamping +- **WHEN** a parameter has range `[-30, 30]` +- **AND** `setSemantic('angleX', 50, 'override')` is called +- **THEN** the parameter is set to `30` (clamped to max) + diff --git a/openspec/specs/state-transition-guards/spec.md b/openspec/specs/state-transition-guards/spec.md new file mode 100644 index 0000000..5eb4c04 --- /dev/null +++ b/openspec/specs/state-transition-guards/spec.md @@ -0,0 +1,24 @@ +# state-transition-guards Specification + +## Purpose +TBD - created by archiving change behavior-fsm. Update Purpose after archive. +## Requirements +### Requirement: Guard functions prevent invalid transitions +The system SHALL support guard functions that return `boolean` to allow or block a transition. + +#### Scenario: Guard blocks transition +- **WHEN** a transition from `talking` to `sleepy` has a guard requiring `!isSpeaking` +- **AND** `isSpeaking` is `true` +- **THEN** `transitionTo("sleepy")` returns `false` and no transition occurs + +#### Scenario: Guard allows transition +- **WHEN** a transition guard returns `true` +- **THEN** `transitionTo()` returns `true` and the transition succeeds + +### Requirement: Query if transition is possible +The system SHALL provide `canTransitionTo(target)` to check if a transition would succeed without executing it. + +#### Scenario: Check transition validity +- **WHEN** `canTransitionTo("sleepy")` is called while speaking +- **THEN** it returns `false` + diff --git a/openspec/specs/transition-scheduler/spec.md b/openspec/specs/transition-scheduler/spec.md new file mode 100644 index 0000000..bd8eced --- /dev/null +++ b/openspec/specs/transition-scheduler/spec.md @@ -0,0 +1,33 @@ +# transition-scheduler Specification + +## Purpose +TBD - created by archiving change emotion-timeline. Update Purpose after archive. +## Requirements +### Requirement: Schedule and execute transitions +The system SHALL manage a transition queue and execute the active transition each frame. + +#### Scenario: Active transition updates +- **WHEN** a transition is active with duration 500ms +- **AND** 250ms have elapsed +- **THEN** all affected parameters are at approximately 50% between start and target + +### Requirement: Auto-return to neutral after idle +The system SHALL automatically transition back to `neutral` after a configurable idle timeout. + +#### Scenario: Auto-return +- **WHEN** `transitionTo("happy")` is called with the happy profile specifying `idleTimeout: 2000` +- **AND** no new transition occurs for 2000ms +- **THEN** the system automatically transitions back to `neutral` + +#### Scenario: Disable auto-return +- **WHEN** an emotion profile specifies `idleTimeout: null` +- **THEN** the system does not auto-return to neutral + +### Requirement: Transition completion callback +The system SHALL support an optional callback invoked when a transition completes. + +#### Scenario: Callback on completion +- **WHEN** `transitionTo("happy", 500, undefined, onComplete)` is called +- **AND** the transition completes +- **THEN** `onComplete` is invoked + diff --git a/packages/live2d/openspec/changes/runtime-devtools/.openspec.yaml b/packages/live2d/openspec/changes/runtime-devtools/.openspec.yaml new file mode 100644 index 0000000..8b76914 --- /dev/null +++ b/packages/live2d/openspec/changes/runtime-devtools/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-20 diff --git a/packages/live2d/openspec/changes/runtime-devtools/design.md b/packages/live2d/openspec/changes/runtime-devtools/design.md new file mode 100644 index 0000000..a042cfc --- /dev/null +++ b/packages/live2d/openspec/changes/runtime-devtools/design.md @@ -0,0 +1,94 @@ +## Context + +plugin-live2d now has five independently-operating runtime subsystems: +- `BehaviorFSM` — high-level behavioral states (idle, talking, happy...) +- `EmotionTimeline` — smooth parameter interpolation between emotions +- `MotionLayerSystem` — parallel animation tracks on named layers +- `FilterPipeline` — per-model visual effects (blush, glow, color grading) +- `ProceduralAnimationSystem` — continuous animations (breathing, blinking, eye tracking) +- `SemanticParameterLayer` — unified semantic parameter access + +Each is initialized separately in `Model.loadModel()`, owns its own resources, and writes to the semantic layer independently. There is no coordination layer. A developer running `pnpm dev` sees the Live2D model with breathing/blinking (procedural system), but has no way to observe or trigger the other four systems. + +## Goals / Non-Goals + +**Goals:** +- Create a single `Live2dRuntimeController` that owns and coordinates all runtime subsystems +- Provide a unified public API for external consumers (AI hooks, chat reactions, manual triggers) +- Build a developer-only floating panel showing real-time system status +- Enable manual triggering of states, emotions, filters, and motions from the panel +- Detect and resolve cross-system parameter conflicts + +**Non-Goals:** +- Not a production-facing UI (dev-only, tree-shaken in prod builds) +- Not replacing individual system APIs (controller delegates, doesn't wrap) +- Not a full animation sequencer with keyframes +- Not persisting debug state across page reloads + +## Decisions + +### Controller owns subsystem lifecycle + +`Live2dRuntimeController` is instantiated in `Model.loadModel()` after the Pixi app is ready. It creates all subsystems internally and exposes them via getters. `Model` delegates to the controller instead of directly owning `#behaviorFSM`, `#emotionTimeline`, etc. + +**Alternative**: Keep direct Model ownership and add controller as a thin wrapper. **Rejected** because Model is already cluttered with 6 private fields for runtime systems. Centralizing ownership simplifies Model and makes the relationship explicit. + +### DevTools is a standalone React/Lit component + +The panel is a web component (``) rendered as a sibling to ``. It consumes the controller via a context or direct reference. It only renders when `import.meta.env.DEV` is true. + +**Alternative**: Build into Live2dTools (the existing user-facing toolbar). **Rejected** because the existing toolbar is user-facing (screenshot, switch model, etc.) and should not expose runtime internals. + +### Conflict resolution: priority-based last-write-wins + +When two systems target the same semantic parameter, the controller resolves conflicts by priority: +1. Manual override (dev tools slider) — highest +2. Behavior FSM state profiles +3. Emotion Timeline transitions +4. Motion Layer System (expression layer) +5. Procedural Animation System — lowest + +Each frame, the controller collects all pending parameter writes, sorts by priority, and applies the highest-priority value. + +**Alternative**: Blend all contributions mathematically. **Rejected** because different systems use different blend semantics (override vs add) and blending them all would produce unpredictable results. Priority is simpler and debuggable. + +### DevTools panel layout: accordion sections + +The panel is divided into collapsible sections, one per subsystem: +- **Behavior FSM**: Current state, transition history, state buttons +- **Emotion Timeline**: Current emotion, transition progress bar, emotion buttons +- **Motion Layers**: Layer status table (active/stopped, weight, priority) +- **Filter Pipeline**: Active effects list with intensity sliders +- **Semantic Parameters**: Live parameter value grid +- **Procedural**: Module toggle switches (breathing, blink, eye tracking) + +**Alternative**: Tabbed layout. **Rejected** because accordion allows seeing multiple systems at once, which is useful for understanding interactions. + +### DevTools toggle: keyboard shortcut + corner indicator + +- `Ctrl+Shift+D` toggles panel visibility +- A small `🔧` indicator in the bottom-right corner shows panel is available +- Panel position is draggable and remembers position in `localStorage` + +## Risks / Trade-offs + +- **[Risk]** Controller adds abstraction overhead between Model and subsystems. → **Mitigation**: Controller is a thin coordinator; subsystems retain their full APIs. No performance-critical path goes through the controller. +- **[Risk]** DevTools bundle size in production even if tree-shaken. → **Mitigation**: DevTools is in a separate entry file (`Demo.tsx` imports it, production entry `index.ts` does not). Verified by analyzing build output. +- **[Risk]** Conflict resolution hides bugs by silently suppressing parameter writes. → **Mitigation**: DevTools shows suppressed writes in a "conflict log" section with reason (which system won and why). +- **[Risk]** Developers may accidentally ship with DevTools enabled. → **Mitigation**: Guard every render with `import.meta.env.DEV`. Add an eslint rule forbidding `Live2dDevTools` import outside dev entry. + +## Migration Plan + +1. Create `Live2dRuntimeController` and move subsystem initialization from Model +2. Refactor Model to use controller getters +3. Create DevTools component with basic status display +4. Add manual trigger buttons +5. Add conflict resolution logging +6. Verify DevTools does not appear in production build + +Rollback: Revert Model to direct subsystem ownership. Controller and DevTools are purely additive. + +## Open Questions + +- Should the controller expose an event bus for cross-system communication (e.g. FSM entering "talking" automatically triggers a "speaking" emotion)? +- Should DevTools support recording/replaying interaction sequences for regression testing? diff --git a/packages/live2d/openspec/changes/runtime-devtools/proposal.md b/packages/live2d/openspec/changes/runtime-devtools/proposal.md new file mode 100644 index 0000000..a72da4f --- /dev/null +++ b/packages/live2d/openspec/changes/runtime-devtools/proposal.md @@ -0,0 +1,35 @@ +## Why + +plugin-live2d has built five runtime subsystems (Behavior FSM, Emotion Timeline, Motion Layer System, Filter Pipeline, Procedural Animation, Semantic Parameter Layer), but they operate in isolation. Developers cannot see what each system is doing, cannot manually trigger state changes for testing, and have no single point of control. Without visibility and manual triggers, these systems might as well not exist from a development or debugging perspective. + +## What Changes + +- Introduce `Live2dRuntimeController` — a centralized coordinator that owns all runtime subsystems and exposes a unified control API +- Create a developer-only `RuntimeDevTools` floating panel (React/Lit component) visible only in `import.meta.env.DEV` +- Panel displays real-time status of all runtime systems (current FSM state, active emotion, motion layers, filters, semantic params) +- Panel provides manual trigger buttons for FSM states, emotions, filter presets, and motion layers +- Panel shows live parameter value readouts with progress bars for transitions +- Runtime controller prevents cross-system conflicts (e.g. FSM and Timeline fighting for the same parameter) +- DevTools toggles via `Ctrl+Shift+D` or a small corner indicator + +## Capabilities + +### New Capabilities +- `runtime-controller`: Centralized ownership and coordination of all runtime subsystems +- `runtime-devtools`: Developer-only floating panel for state visibility and manual triggering +- `runtime-conflict-resolution`: Cross-system parameter conflict detection and resolution rules + +### Modified Capabilities +- `behavior-fsm`: Will register with controller instead of direct Model ownership +- `emotion-timeline`: Will register with controller instead of direct Model ownership +- `runtime-filter-pipeline`: Will register with controller instead of direct Model ownership +- `motion-layer-system`: Will register with controller instead of direct Model ownership +- `procedural-animation-system`: Will register with controller instead of direct Model ownership + +## Impact + +- **Frontend runtime**: New `packages/live2d/src/runtime/controller/` directory +- **Dev tools UI**: New `packages/live2d/src/components/Live2dDevTools/` directory +- **Model class**: Refactored to delegate runtime initialization to controller instead of direct ownership +- **Build**: DevTools component is tree-shaken in production builds (dev-only import) +- **Config**: `Live2dConfig` gains `devTools` option for panel positioning and feature toggles diff --git a/packages/live2d/openspec/changes/runtime-devtools/specs/runtime-conflict-resolution/spec.md b/packages/live2d/openspec/changes/runtime-devtools/specs/runtime-conflict-resolution/spec.md new file mode 100644 index 0000000..fa76c47 --- /dev/null +++ b/packages/live2d/openspec/changes/runtime-devtools/specs/runtime-conflict-resolution/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Detect cross-system parameter conflicts +The system SHALL detect when multiple runtime systems attempt to write to the same semantic parameter within the same frame. + +#### Scenario: FSM and Timeline target same parameter +- **WHEN** the FSM sets `mouthSmile: 0.5` and the EmotionTimeline sets `mouthSmile: 0.8` in the same frame +- **THEN** the controller detects a conflict on `mouthSmile` + +### Requirement: Resolve conflicts by priority +The system SHALL resolve conflicts using a defined priority order. + +#### Scenario: Higher priority wins +- **WHEN** ProceduralAnimation (priority 5) writes `breath: 0.1` +- **AND** EmotionTimeline (priority 3) writes `breath: 0.3` +- **THEN** the effective value is `breath: 0.3` (EmotionTimeline wins) + +#### Scenario: Manual override has highest priority +- **WHEN** a dev tools slider manually sets `eyeLOpen: 0.5` +- **AND** any other system writes a different value to `eyeLOpen` +- **THEN** the manual value `0.5` is applied + +### Requirement: Log suppressed writes for debugging +The system SHALL record which writes were suppressed and why. + +#### Scenario: Conflict log entry +- **WHEN** a conflict occurs and the EmotionTimeline wins +- **THEN** a log entry is created showing: parameter name, losing system, winning system, both values + +#### Scenario: DevTools displays conflict log +- **WHEN** the DevTools panel is open +- **AND** conflicts have occurred +- **THEN** a "Conflict Log" section displays all recent conflicts with timestamps diff --git a/packages/live2d/openspec/changes/runtime-devtools/specs/runtime-controller/spec.md b/packages/live2d/openspec/changes/runtime-devtools/specs/runtime-controller/spec.md new file mode 100644 index 0000000..4c902be --- /dev/null +++ b/packages/live2d/openspec/changes/runtime-devtools/specs/runtime-controller/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: Controller owns subsystem lifecycle +The system SHALL provide a `Live2dRuntimeController` class that instantiates and manages all runtime subsystems. + +#### Scenario: Controller initializes after model load +- **WHEN** a Live2D model is loaded +- **AND** `Live2dRuntimeController` is instantiated with the loaded model +- **THEN** all runtime subsystems (BehaviorFSM, EmotionTimeline, MotionLayerSystem, FilterPipeline, ProceduralAnimationSystem, SemanticParameterLayer) are created and initialized + +#### Scenario: Controller exposes subsystem access +- **WHEN** external code calls `controller.getBehaviorFSM()` +- **THEN** the BehaviorFSM instance is returned +- **AND** calling `controller.getEmotionTimeline()` returns the EmotionTimeline instance + +### Requirement: Controller provides unified transition API +The system SHALL expose a unified `transitionTo({ fsm?, emotion?, filter? })` method that coordinates cross-system transitions. + +#### Scenario: Coordinated state and emotion transition +- **WHEN** `controller.transitionTo({ fsm: 'talking', emotion: 'happy' })` is called +- **THEN** the FSM transitions to `talking` +- **AND** the EmotionTimeline transitions to `happy` +- **AND** parameter conflicts are resolved by the controller + +### Requirement: Controller destroys all subsystems +The system SHALL provide a `destroy()` method that cleans up all owned subsystems. + +#### Scenario: Model destruction propagates to controller +- **WHEN** `controller.destroy()` is called +- **THEN** all subsystems are destroyed in dependency order +- **AND** no memory leaks or dangling timers remain diff --git a/packages/live2d/openspec/changes/runtime-devtools/specs/runtime-devtools/spec.md b/packages/live2d/openspec/changes/runtime-devtools/specs/runtime-devtools/spec.md new file mode 100644 index 0000000..0457be7 --- /dev/null +++ b/packages/live2d/openspec/changes/runtime-devtools/specs/runtime-devtools/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: DevTools panel displays runtime status +The system SHALL provide a floating panel that displays the real-time status of all runtime subsystems. + +#### Scenario: Panel shows FSM state +- **WHEN** the DevTools panel is open +- **AND** the current FSM state is `idle` +- **THEN** the panel displays "idle" as the current state + +#### Scenario: Panel shows emotion transition progress +- **WHEN** the DevTools panel is open +- **AND** a transition from `neutral` to `happy` is 50% complete +- **THEN** the panel shows a progress bar at 50% with "neutral → happy" + +#### Scenario: Panel shows motion layer statuses +- **WHEN** the DevTools panel is open +- **AND** the `talk` layer is active with weight 0.8 +- **THEN** the panel displays the talk layer as active with weight 0.8 + +#### Scenario: Panel shows active filters +- **WHEN** the DevTools panel is open +- **AND** a `happy-glow` filter is active +- **THEN** the panel lists "happy-glow" with its intensity + +#### Scenario: Panel shows semantic parameter values +- **WHEN** the DevTools panel is open +- **AND** the `mouthSmile` parameter has value 0.6 +- **THEN** the panel displays `mouthSmile: 0.6` + +### Requirement: DevTools provides manual trigger buttons +The system SHALL provide buttons to manually trigger FSM states, emotions, and filter presets. + +#### Scenario: Trigger FSM state from panel +- **WHEN** the user clicks the "happy" state button in the FSM section +- **THEN** `fsm.transitionTo('happy')` is called + +#### Scenario: Trigger emotion from panel +- **WHEN** the user clicks the "angry" emotion button +- **THEN** `emotionTimeline.transitionTo('angry')` is called + +#### Scenario: Apply filter from panel +- **WHEN** the user clicks the "shy-blush" filter button +- **THEN** `filterPipeline.applyPreset('shy-blush')` is called + +#### Scenario: Toggle procedural module from panel +- **WHEN** the user unchecks the "Blink" toggle +- **THEN** the Blink module is disabled in the procedural system + +### Requirement: DevTools is dev-only +The system SHALL ensure the DevTools component is not included in production builds. + +#### Scenario: Production build excludes DevTools +- **WHEN** the application is built for production +- **THEN** the DevTools component code is not included in the bundle + +### Requirement: DevTools toggle mechanism +The system SHALL support toggling the panel via keyboard shortcut and a corner indicator. + +#### Scenario: Keyboard shortcut toggles panel +- **WHEN** the user presses `Ctrl+Shift+D` +- **THEN** the DevTools panel visibility toggles + +#### Scenario: Corner indicator shows availability +- **WHEN** the DevTools panel is hidden +- **THEN** a small indicator icon is visible in the bottom-right corner +- **AND** clicking the indicator opens the panel diff --git a/packages/live2d/openspec/changes/runtime-devtools/tasks.md b/packages/live2d/openspec/changes/runtime-devtools/tasks.md new file mode 100644 index 0000000..a540ea7 --- /dev/null +++ b/packages/live2d/openspec/changes/runtime-devtools/tasks.md @@ -0,0 +1,99 @@ +## 1. Runtime Controller Core + +- [ ] 1.1 Create `packages/live2d/src/runtime/controller/` directory +- [ ] 1.2 Define `Live2dRuntimeController` class with subsystem ownership +- [ ] 1.3 Move BehaviorFSM initialization from Model to Controller +- [ ] 1.4 Move EmotionTimeline initialization from Model to Controller +- [ ] 1.5 Move MotionLayerSystem initialization from Model to Controller +- [ ] 1.6 Move FilterPipeline initialization from Model to Controller +- [ ] 1.7 Move ProceduralAnimationSystem initialization from Model to Controller +- [ ] 1.8 Implement `controller.get*()` accessors for all subsystems +- [ ] 1.9 Implement `controller.destroy()` with proper cleanup order +- [ ] 1.10 Refactor Model class to delegate to Controller + +## 2. Conflict Resolution + +- [ ] 2.1 Define `SystemPriority` enum (manual=1, fsm=2, emotion=3, motion=4, procedural=5) +- [ ] 2.2 Implement `ParameterWriteQueue` to collect writes per frame +- [ ] 2.3 Implement conflict detection (same param, same frame, different sources) +- [ ] 2.4 Implement priority-based resolution (highest wins) +- [ ] 2.5 Implement conflict log (parameter, losing system, winning system, values) +- [ ] 2.6 Integrate resolution into controller's update cycle + +## 3. Unified Transition API + +- [ ] 3.1 Implement `controller.transitionTo({ fsm?, emotion?, filter? })` +- [ ] 3.2 Ensure cross-system transitions are atomic +- [ ] 3.3 Add `controller.getCurrentState()` returning composite state +- [ ] 3.4 Unit test: FSM + emotion coordinated transition +- [ ] 3.5 Unit test: Filter application during state transition + +## 4. DevTools Panel Component + +- [ ] 4.1 Create `packages/live2d/src/components/Live2dDevTools/` directory +- [ ] 4.2 Create `Live2dDevTools` Lit/React component scaffold +- [ ] 4.3 Implement panel container with drag handle +- [ ] 4.4 Implement accordion section layout +- [ ] 4.5 Implement `Ctrl+Shift+D` keyboard shortcut toggle +- [ ] 4.6 Implement corner indicator (`🔧`) when panel is hidden +- [ ] 4.7 Store panel position in localStorage +- [ ] 4.8 Guard rendering with `import.meta.env.DEV` + +## 5. DevTools FSM Section + +- [ ] 5.1 Display current FSM state name +- [ ] 5.2 Display transition history (last 5 transitions) +- [ ] 5.3 Add state trigger buttons (idle, happy, thinking, talking, etc.) +- [ ] 5.4 Show guard status (can/cannot transition to each state) +- [ ] 5.5 Unit test: Clicking state button triggers transition + +## 6. DevTools Emotion Section + +- [ ] 6.1 Display current emotion name +- [ ] 6.2 Display transition progress bar (when transitioning) +- [ ] 6.3 Add emotion trigger buttons (neutral, happy, sad, angry, etc.) +- [ ] 6.4 Show current interpolated parameter values +- [ ] 6.5 Unit test: Clicking emotion button triggers transition + +## 7. DevTools Motion Layer Section + +- [ ] 7.1 Display layer status table (name, state, weight, priority) +- [ ] 7.2 Show active parameters per layer +- [ ] 7.3 Add layer trigger buttons (play/stop for each layer) +- [ ] 7.4 Unit test: Layer status updates in real-time + +## 8. DevTools Filter Section + +- [ ] 8.1 Display active effects list +- [ ] 8.2 Add intensity sliders for each active effect +- [ ] 8.3 Add preset trigger buttons (happy-glow, shy-blush, angry-red, etc.) +- [ ] 8.4 Add "Clear All" button +- [ ] 8.5 Unit test: Clicking preset button applies effect + +## 9. DevTools Semantic Parameter Section + +- [ ] 9.1 Display live parameter value grid (name + value) +- [ ] 9.2 Add manual parameter sliders +- [ ] 9.3 Highlight parameters currently being written by any system +- [ ] 9.4 Unit test: Slider changes parameter value + +## 10. DevTools Procedural Section + +- [ ] 10.1 Display module toggle switches (breathing, blink, eye tracking) +- [ ] 10.2 Show module configuration (period, amplitude, etc.) +- [ ] 10.3 Unit test: Toggling module enables/disables it + +## 11. DevTools Conflict Log Section + +- [ ] 11.1 Display recent conflicts in a scrollable list +- [ ] 11.2 Show timestamp, parameter, losing system, winning system +- [ ] 11.3 Add "Clear Log" button +- [ ] 11.4 Unit test: Conflict appears in log when detected + +## 12. Integration & Production Safety + +- [ ] 12.1 Integrate DevTools into `Demo.tsx` (dev-only) +- [ ] 12.2 Ensure DevTools is NOT imported in `index.ts` (production entry) +- [ ] 12.3 Add `devTools` config to `Live2dConfig` +- [ ] 12.4 Verify production build excludes DevTools code +- [ ] 12.5 Integration test: Full workflow — trigger state → observe emotion → apply filter diff --git a/packages/live2d/package.json b/packages/live2d/package.json index 08c3daf..b6aa04e 100644 --- a/packages/live2d/package.json +++ b/packages/live2d/package.json @@ -3,7 +3,9 @@ "private": true, "version": "1.0.0", "type": "module", - "files": ["dist"], + "files": [ + "dist" + ], "main": "./dist/lib/live2d.umd.cjs", "module": "./dist/lib/live2d.js", "exports": { @@ -35,8 +37,10 @@ "@types/react-dom": "^19.2.3", "@unocss/postcss": "^66.6.8", "@vitejs/plugin-react": "^6.0.2", + "jsdom": "^29.1.1", "ts-lit-plugin": "^2.0.2", "unocss": "^66.6.8", - "vite": "^8.0.13" + "vite": "^8.0.13", + "vitest": "^4.1.6" } } diff --git a/packages/live2d/src/Demo.tsx b/packages/live2d/src/Demo.tsx index f34e39e..30a167a 100644 --- a/packages/live2d/src/Demo.tsx +++ b/packages/live2d/src/Demo.tsx @@ -1,6 +1,8 @@ import React from "react"; import ReactDOM from "react-dom/client"; import "./components/Live2dContext"; +import "./components/Live2dDevTools"; +import { MODEL_READY_EVENT_NAME } from "./events/model-ready"; const rootEl = document.getElementById("root"); if (rootEl) { @@ -11,3 +13,22 @@ if (rootEl) { , ); } + +// Initialize DevTools in dev mode +if (import.meta.env.DEV) { + window.addEventListener(MODEL_READY_EVENT_NAME, (e) => { + const model = (e as CustomEvent).detail.model; + const controller = model.getController(); + + // Find or create devtools element + let devtools = document.querySelector("live2d-dev-tools") as + | HTMLElement + | null; + if (!devtools) { + devtools = document.createElement("live2d-dev-tools"); + document.body.appendChild(devtools); + } + (devtools as HTMLElement & { setController?: (c: unknown) => void }) + .setController?.(controller); + }); +} diff --git a/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts b/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts new file mode 100644 index 0000000..f504511 --- /dev/null +++ b/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts @@ -0,0 +1,1573 @@ +import { UnoLitElement } from "@/live2d/common/UnoLitElement"; +import { html, type TemplateResult, unsafeCSS } from "lit"; +import { unsafeSVG } from "lit/directives/unsafe-svg.js"; +import { state } from "lit/decorators.js"; +import type { Live2dRuntimeController } from "@/live2d/runtime/controller"; +import type { ControllerState, ConflictEntry } from "@/live2d/runtime/controller/types"; +import type { EffectPreset } from "@/live2d/runtime/filters/types"; + +interface Section { + id: string; + title: string; + icon: string; + expanded: boolean; +} + +const FSM_STATE_LABELS: Record = { + idle: "空闲", + happy: "开心", + thinking: "思考", + talking: "说话", + embarrassed: "害羞", + angry: "生气", + sleepy: "困倦", + sad: "难过", +}; + +const EMOTION_LABELS: Record = { + neutral: "平静", + happy: "开心", + sad: "难过", + angry: "生气", + embarrassed: "害羞", + surprised: "惊讶", + sleepy: "困倦", + thinking: "思考", +}; + +const FILTER_LABELS: Record = { + "evening-warm": "暖色黄昏", + "morning-cool": "清凉晨光", + neutral: "中性", + "happy-glow": "快乐光晕", + "shy-blush": "害羞红晕", + "angry-red": "愤怒赤红", +}; + +export class Live2dDevTools extends UnoLitElement { + @state() + private _visible = false; + + @state() + private _state: ControllerState = { + fsmState: null, + emotion: null, + isTransitioning: false, + transitionProgress: 0, + activeFilters: [], + motionLayers: [], + proceduralModules: [], + }; + + @state() + private _conflicts: ConflictEntry[] = []; + + @state() + private _sections: Section[] = [ + { id: "perf", title: "性能监控", icon: "📈", expanded: true }, + { id: "fsm", title: "行为状态机", icon: "🎭", expanded: false }, + { id: "emotion", title: "情感时间线", icon: "💫", expanded: false }, + { id: "motion", title: "动作层级", icon: "🎬", expanded: false }, + { id: "filter", title: "滤镜效果", icon: "🎨", expanded: false }, + { id: "params", title: "语义参数", icon: "📊", expanded: false }, + { id: "procedural", title: "程序化动画", icon: "✨", expanded: false }, + { id: "conflicts", title: "冲突日志", icon: "⚡", expanded: false }, + ]; + + // ── Performance metrics ── + private _perfSamples: Array<{ fps: number; frameTime: number; timestamp: number }> = []; + private readonly _MAX_PERF_SAMPLES = 120; + private _lastRafTime = 0; + private _rafId = 0; + private _modelStats = { + parameterCount: 0, + drawableCount: 0, + partCount: 0, + textureCount: 0, + }; + + private _controller?: Live2dRuntimeController; + private _updateInterval?: ReturnType; + private _dragging = false; + private _dragOffset = { x: 0, y: 0 }; + private _panelX = 16; + private _panelY = 16; + + setController(controller: Live2dRuntimeController): void { + this._controller = controller; + } + + connectedCallback(): void { + super.connectedCallback(); + document.addEventListener("keydown", this._handleKeyDown); + const saved = localStorage.getItem("live2d-devtools-position"); + if (saved) { + try { + const pos = JSON.parse(saved); + this._panelX = pos.x ?? 16; + this._panelY = pos.y ?? 16; + } catch { /* ignore */ } + } + this._updateInterval = setInterval(() => this._pollState(), 80); + this._rafId = requestAnimationFrame(this._collectPerf); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener("keydown", this._handleKeyDown); + if (this._updateInterval) clearInterval(this._updateInterval); + if (this._rafId) cancelAnimationFrame(this._rafId); + } + + private _handleKeyDown = (e: KeyboardEvent): void => { + if (e.ctrlKey && e.shiftKey && e.key === "D") { + e.preventDefault(); + this._toggleVisible(); + } + }; + + private _toggleVisible(): void { + this._visible = !this._visible; + } + + private _pollState(): void { + if (!this._controller) return; + this._state = this._controller.getState(); + this._conflicts = this._controller.getConflictLog(); + this._updateModelStats(); + } + + private _collectPerf = (timestamp: number): void => { + if (this._lastRafTime > 0) { + const frameTime = timestamp - this._lastRafTime; + const fps = frameTime > 0 ? 1000 / frameTime : 60; + this._perfSamples.push({ fps: Math.min(fps, 120), frameTime, timestamp }); + if (this._perfSamples.length > this._MAX_PERF_SAMPLES) { + this._perfSamples.shift(); + } + // Request re-render for live chart when perf section is expanded + const perfExpanded = this._sections.find((s) => s.id === "perf")?.expanded; + if (perfExpanded && this._visible) { + this.requestUpdate(); + } + } + this._lastRafTime = timestamp; + this._rafId = requestAnimationFrame(this._collectPerf); + }; + + private _updateModelStats(): void { + const semanticLayer = this._controller?.getSemanticLayer(); + if (!semanticLayer) return; + + // Parameter count from detected semantic profile + const profile = semanticLayer.getCapabilityProfile(); + const paramCount = profile.detected.size; + + // Try to get detailed model stats from internal model + let drawableCount = 0; + let partCount = 0; + let textureCount = 0; + + try { + type ModelWithInternal = { + internalModel?: { + coreModel?: { + getDrawableCount?(): number; + getPartCount?(): number; + _drawableCount?: number; + drawableCount?: number; + _partCount?: number; + partCount?: number; + }; + parts?: unknown[]; + drawables?: unknown[]; + drawDataList?: unknown[]; + textures?: unknown[]; + }; + }; + + const model = (semanticLayer as unknown as Record)["sourceModel"] as + | ModelWithInternal + | undefined; + if (model) { + const internal = model.internalModel; + if (internal) { + // Try various property names used by different Cubism versions + const core = internal.coreModel; + if (core) { + // Cubism 4/5 style + drawableCount = + (typeof core.getDrawableCount === "function" ? core.getDrawableCount() : undefined) ?? + core._drawableCount ?? + core.drawableCount ?? + 0; + partCount = + (typeof core.getPartCount === "function" ? core.getPartCount() : undefined) ?? + core._partCount ?? + core.partCount ?? + 0; + } else { + // Cubism 2.1 style: internalModel itself may have the data + if (Array.isArray(internal.parts)) { + partCount = internal.parts.length; + } + const drawables = internal.drawables ?? internal.drawDataList; + if (Array.isArray(drawables)) { + drawableCount = drawables.length; + } + } + textureCount = Array.isArray(internal.textures) ? internal.textures.length : 0; + } + } + } catch { + // Silently ignore reflection errors + } + + this._modelStats = { + parameterCount: paramCount, + drawableCount, + partCount, + textureCount, + }; + } + + private _toggleSection(id: string): void { + this._sections = this._sections.map((s) => + s.id === id ? { ...s, expanded: !s.expanded } : s, + ); + } + + private _startDrag(e: MouseEvent): void { + this._dragging = true; + const rect = this.shadowRoot?.querySelector(".l2d-panel")?.getBoundingClientRect(); + if (rect) { + this._dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + document.addEventListener("mousemove", this._onDrag); + document.addEventListener("mouseup", this._stopDrag); + } + + private _onDrag = (e: MouseEvent): void => { + if (!this._dragging) return; + this._panelX = e.clientX - this._dragOffset.x; + this._panelY = e.clientY - this._dragOffset.y; + this.requestUpdate(); + }; + + private _stopDrag = (): void => { + this._dragging = false; + document.removeEventListener("mousemove", this._onDrag); + document.removeEventListener("mouseup", this._stopDrag); + localStorage.setItem("live2d-devtools-position", JSON.stringify({ x: this._panelX, y: this._panelY })); + }; + + private _transitionFSM(state: string): void { + this._controller?.transitionTo({ fsm: state }); + } + + private _transitionEmotion(emotion: string): void { + this._controller?.transitionTo({ emotion }); + } + + private _applyFilter(preset: EffectPreset): void { + this._controller?.getFilterPipeline().applyPreset(preset); + } + + private _clearFilters(): void { + this._controller?.getFilterPipeline().clear(); + } + + private _setFilterIntensity(id: string, value: number): void { + this._controller?.getFilterPipeline().setIntensity(id, value); + } + + private _setParamValue(param: string, value: number): void { + this._controller?.getSemanticLayer().setSemantic(param, value, "override", "manual", 1); + } + + render(): TemplateResult { + if (!this._visible) { + return html` +
this._toggleVisible()} title="Live2D 调试面板 (Ctrl+Shift+D)"> +
🎛️
+
+
+ `; + } + + return html` +
+
+
+ 🎛️ + Live2D 实时调试台 + DEV +
+ +
+ +
+ ${this._renderPerfSection()} + ${this._renderStatusBar()} + ${this._renderFSMSection()} + ${this._renderEmotionSection()} + ${this._renderMotionSection()} + ${this._renderFilterSection()} + ${this._renderParamSection()} + ${this._renderProceduralSection()} + ${this._renderConflictSection()} +
+
+ `; + } + + private _renderPerfSection(): TemplateResult { + const section = this._sections.find((s) => s.id === "perf"); + if (!section) return html``; + + const samples = this._perfSamples; + const recent = samples.slice(-60); + const avgFps = recent.length > 0 + ? recent.reduce((s, d) => s + d.fps, 0) / recent.length + : 0; + const avgFrameTime = recent.length > 0 + ? recent.reduce((s, d) => s + d.frameTime, 0) / recent.length + : 0; + const maxFps = recent.length > 0 ? Math.max(...recent.map((d) => d.fps)) : 60; + const fpsMin = 0; + const fpsMax = Math.max(66, maxFps * 1.1); + const w = 320; + const h = 80; + const pad = 4; + + // Build SVG area path for FPS (filled area under the line) + const fpsLineParts: string[] = []; + const fpsAreaParts: string[] = []; + const step = recent.length > 1 ? (w - pad * 2) / (recent.length - 1) : 0; + for (let i = 0; i < recent.length; i++) { + const x = pad + i * step; + const y = pad + (1 - (recent[i].fps - fpsMin) / (fpsMax - fpsMin)) * (h - pad * 2); + const cmd = i === 0 ? "M" : "L"; + const point = `${cmd}${x.toFixed(1)},${y.toFixed(1)}`; + fpsLineParts.push(point); + fpsAreaParts.push(point); + } + // Close the area path down to bottom + if (recent.length > 0) { + const lastX = pad + (recent.length - 1) * step; + fpsAreaParts.push(`L${lastX.toFixed(1)},${h - pad}L${pad},${h - pad}Z`); + } + const fpsLinePath = fpsLineParts.join(""); + const fpsAreaPath = fpsAreaParts.join(""); + + // Build SVG area path for frame time + const ftMax = Math.max(33, ...recent.map((d) => d.frameTime)); + const ftAreaParts: string[] = []; + for (let i = 0; i < recent.length; i++) { + const x = pad + i * step; + const y = pad + (1 - recent[i].frameTime / ftMax) * (h - pad * 2); + const cmd = i === 0 ? "M" : "L"; + ftAreaParts.push(`${cmd}${x.toFixed(1)},${y.toFixed(1)}`); + } + if (recent.length > 0) { + const lastX = pad + (recent.length - 1) * step; + ftAreaParts.push(`L${lastX.toFixed(1)},${h - pad}L${pad},${h - pad}Z`); + } + const ftAreaPath = ftAreaParts.join(""); + + // Build SVG content as a string (using unsafeSVG to preserve SVG namespace) + let svgContent = ``; + + // Grid lines + for (let g = 0; g <= 4; g++) { + const gy = pad + (g / 4) * (h - pad * 2); + svgContent += ``; + } + + svgContent += ` + + + + + + + + + + + `; + + if (ftAreaPath) { + svgContent += ``; + } + + if (fpsAreaPath) { + svgContent += ``; + svgContent += ``; + } + + if (fpsMax >= 60 && recent.length > 0) { + const targetY = pad + (1 - 60 / fpsMax) * (h - pad * 2); + svgContent += ``; + } + + svgContent += ``; + + // Subsystem active flags + const hasFSM = this._state.fsmState !== null; + const hasEmotion = this._state.emotion !== null && this._state.emotion !== "neutral"; + const hasFilters = this._state.activeFilters.length > 0; + const activeLayers = this._state.motionLayers.filter((l) => l.state !== "idle").length; + const activeModules = this._state.proceduralModules.filter((m) => m.enabled).length; + + return html` +
+
this._toggleSection("perf")}> + ${section.icon} + ${section.title} + = 30 ? "thinking" : "angry"}"> + ${avgFps.toFixed(1)} FPS + + ${section.expanded ? "▼" : "▶"} +
+ ${section.expanded ? html` +
+ +
+ ${unsafeSVG(svgContent)} +
+ ${fpsMax.toFixed(0)} + ${(fpsMax / 2).toFixed(0)} + 0 +
+
+ + +
+
+ ${avgFrameTime.toFixed(1)} + ms + 帧时间 +
+
+ ${this._modelStats.parameterCount} + + 参数 +
+
+ ${this._modelStats.drawableCount} + + Drawables +
+
+ ${this._modelStats.partCount} + + 部件 +
+
+ + +
+
+ 行为状态机 +
+
+
+ ${hasFSM ? "运行" : "待机"} +
+
+ 情感时间线 +
+
+
+ ${hasEmotion ? "运行" : "待机"} +
+
+ 动作层级 +
+
+
+ ${activeLayers}/${this._state.motionLayers.length || 0} +
+
+ 滤镜管线 +
+
+
+ ${this._state.activeFilters.length} 个 +
+
+ 程序化动画 +
+
+
+ ${activeModules}/${this._state.proceduralModules.length || 0} +
+
+
+ ` : ""} +
+ `; + } + + private _renderStatusBar(): TemplateResult { + const fsmActive = this._state.fsmState !== null; + const emotionActive = this._state.emotion !== null && this._state.emotion !== "neutral"; + const hasFilters = this._state.activeFilters.length > 0; + + return html` +
+
+
+ 状态机 + ${fsmActive ? FSM_STATE_LABELS[this._state.fsmState!] ?? this._state.fsmState : "未激活"} +
+
+
+ 情感 + ${this._state.emotion ? EMOTION_LABELS[this._state.emotion] ?? this._state.emotion : "平静"} +
+
+
+ 滤镜 + ${hasFilters ? `${this._state.activeFilters.length} 个` : "无"} +
+
+ `; + } + + private _renderFSMSection(): TemplateResult { + const section = this._sections.find((s) => s.id === "fsm"); + if (!section) return html``; + const states = ["idle", "happy", "thinking", "talking", "embarrassed", "angry", "sleepy", "sad"]; + const fsm = this._controller?.getBehaviorFSM(); + + return html` +
+
this._toggleSection("fsm")}> + ${section.icon} + ${section.title} + + ${this._state.fsmState ? (FSM_STATE_LABELS[this._state.fsmState] ?? this._state.fsmState) : "—"} + + ${section.expanded ? "▼" : "▶"} +
+ ${section.expanded ? html` +
+
+ ${states.map((s) => html` + + `)} +
+
+ ` : ""} +
+ `; + } + + private _renderEmotionSection(): TemplateResult { + const section = this._sections.find((s) => s.id === "emotion"); + if (!section) return html``; + const emotions = ["neutral", "happy", "sad", "angry", "embarrassed", "surprised", "sleepy", "thinking"]; + + return html` +
+
this._toggleSection("emotion")}> + ${section.icon} + ${section.title} + + ${this._state.emotion ? (EMOTION_LABELS[this._state.emotion] ?? this._state.emotion) : "—"} + + ${section.expanded ? "▼" : "▶"} +
+ ${section.expanded ? html` +
+ ${this._state.isTransitioning ? html` +
+
+ 过渡中 + ${Math.round(this._state.transitionProgress * 100)}% +
+
+
+
+
+ ` : ""} +
+ ${emotions.map((e) => html` + + `)} +
+
+ ` : ""} +
+ `; + } + + private _renderMotionSection(): TemplateResult { + const section = this._sections.find((s) => s.id === "motion"); + if (!section) return html``; + const layerNames: Record = { + physics: "物理", idle: "待机", expression: "表情", talk: "说话", gesture: "手势", + }; + + return html` +
+
this._toggleSection("motion")}> + ${section.icon} + ${section.title} + ${this._state.motionLayers.filter((l) => l.state !== "idle").length} / ${this._state.motionLayers.length} + ${section.expanded ? "▼" : "▶"} +
+ ${section.expanded ? html` +
+ ${this._state.motionLayers.length === 0 ? html` +
暂无动作层数据
+ ` : html` +
+ ${this._state.motionLayers.map((l) => html` +
+
+ ${layerNames[l.name] ?? l.name} + ${this._layerStateLabel(l.state)} +
+
+
+
+
+ ${l.weight.toFixed(2)} +
+
+ `)} +
+ `} +
+ ` : ""} +
+ `; + } + + private _layerStateLabel(state: string): string { + const labels: Record = { + idle: "空闲", fadingIn: "淡入", active: "活跃", fadingOut: "淡出", stopped: "停止", + }; + return labels[state] ?? state; + } + + private _renderFilterSection(): TemplateResult { + const section = this._sections.find((s) => s.id === "filter"); + if (!section) return html``; + const presets: EffectPreset[] = ["evening-warm", "morning-cool", "neutral", "happy-glow", "shy-blush", "angry-red"]; + + return html` +
+
this._toggleSection("filter")}> + ${section.icon} + ${section.title} + ${this._state.activeFilters.length} + ${section.expanded ? "▼" : "▶"} +
+ ${section.expanded ? html` +
+ + ${this._state.activeFilters.length > 0 ? html` +
+ ${this._state.activeFilters.map((fx) => html` +
+ ${fx.name} + this._setFilterIntensity(fx.id, Number((e.target as HTMLInputElement).value))} + /> + ${(fx.intensity * 100).toFixed(0)}% +
+ `)} +
+ ` : ""} +
+ ${presets.map((p) => html` + + `)} +
+ +
+ ` : ""} +
+ `; + } + + private _renderParamSection(): TemplateResult { + const section = this._sections.find((s) => s.id === "params"); + if (!section) return html``; + const params = this._controller?.getSemanticParameters() ?? []; + + return html` +
+
this._toggleSection("params")}> + ${section.icon} + ${section.title} + ${params.length} + ${section.expanded ? "▼" : "▶"} +
+ ${section.expanded ? html` +
+
+ ${params.slice(0, 20).map((p) => html` +
+ ${p.name} + this._setParamValue(p.name, Number((e.target as HTMLInputElement).value))} + /> + ${(p.value ?? 0).toFixed(2)} +
+ `)} +
+
+ ` : ""} +
+ `; + } + + private _valueColor(v: number): string { + if (v > 5) return "positive"; + if (v < -5) return "negative"; + return "neutral"; + } + + private _renderProceduralSection(): TemplateResult { + const section = this._sections.find((s) => s.id === "procedural"); + if (!section) return html``; + const moduleLabels: Record = { + Breathing: "呼吸动画", Blink: "眨眼动画", EyeTracking: "视线追踪", + }; + + return html` +
+
this._toggleSection("procedural")}> + ${section.icon} + ${section.title} + ${section.expanded ? "▼" : "▶"} +
+ ${section.expanded ? html` +
+
+ ${this._state.proceduralModules.map((m) => html` +
+
+
+ ${moduleLabels[m.name] ?? m.name} +
+ ${m.enabled ? "运行中" : "已停止"} +
+ `)} +
+
+ ` : ""} +
+ `; + } + + private _renderConflictSection(): TemplateResult { + const section = this._sections.find((s) => s.id === "conflicts"); + if (!section) return html``; + + return html` +
+
this._toggleSection("conflicts")}> + ${section.icon} + ${section.title} + ${this._conflicts.length} + ${section.expanded ? "▼" : "▶"} +
+ ${section.expanded ? html` +
+ ${this._conflicts.length === 0 ? html` +
暂无系统间冲突
+ ` : html` +
+ ${this._conflicts.slice(-10).reverse().map((c) => html` +
+
+ ${c.parameter} + ${new Date(c.timestamp).toLocaleTimeString("zh-CN")} +
+
+ ${c.losingSystem} ${c.losingValue.toFixed(2)} + + ${c.winningSystem} ${c.winningValue.toFixed(2)} +
+
+ `)} +
+ + `} +
+ ` : ""} +
+ `; + } + + static styles = unsafeCSS(` + :host { + position: fixed; + z-index: 9999; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; + font-size: 13px; + line-height: 1.5; + } + + /* ── Indicator ── */ + .l2d-indicator { + position: fixed; + bottom: 16px; + right: 16px; + width: 44px; + height: 44px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + .l2d-indicator-icon { + width: 40px; + height: 40px; + background: rgba(30, 30, 40, 0.9); + backdrop-filter: blur(12px); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + box-shadow: 0 4px 20px rgba(0,0,0,0.3); + transition: transform 0.2s, box-shadow 0.2s; + position: relative; + z-index: 2; + } + .l2d-indicator:hover .l2d-indicator-icon { + transform: scale(1.08); + box-shadow: 0 6px 24px rgba(0,0,0,0.4); + } + .l2d-indicator-pulse { + position: absolute; + width: 40px; + height: 40px; + border-radius: 12px; + background: rgba(13, 115, 119, 0.3); + animation: l2d-pulse 2s ease-out infinite; + z-index: 1; + } + @keyframes l2d-pulse { + 0% { transform: scale(1); opacity: 0.6; } + 100% { transform: scale(1.6); opacity: 0; } + } + + /* ── Panel ── */ + .l2d-panel { + position: fixed; + width: 360px; + max-height: 85vh; + background: rgba(22, 22, 30, 0.95); + backdrop-filter: blur(20px); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 16px; + box-shadow: 0 24px 64px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.03); + overflow: hidden; + display: flex; + flex-direction: column; + color: #e2e2e8; + } + + /* ── Header ── */ + .l2d-header { + padding: 14px 18px; + background: rgba(255,255,255,0.03); + border-bottom: 1px solid rgba(255,255,255,0.05); + display: flex; + justify-content: space-between; + align-items: center; + cursor: grab; + user-select: none; + } + .l2d-header:active { cursor: grabbing; } + .l2d-header-left { + display: flex; + align-items: center; + gap: 10px; + } + .l2d-header-icon { font-size: 18px; } + .l2d-header-title { + font-weight: 600; + font-size: 14px; + letter-spacing: 0.3px; + } + .l2d-header-badge { + font-size: 10px; + font-weight: 700; + padding: 2px 7px; + background: rgba(13, 115, 119, 0.3); + color: #5eead4; + border-radius: 6px; + border: 1px solid rgba(94, 234, 212, 0.15); + } + .l2d-header-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255,255,255,0.05); + border: none; + border-radius: 8px; + color: #888; + cursor: pointer; + transition: all 0.15s; + } + .l2d-header-close:hover { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + } + + /* ── Status Bar ── */ + .l2d-status-bar { + display: flex; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid rgba(255,255,255,0.04); + } + .l2d-status-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + padding: 8px 4px; + border-radius: 10px; + background: rgba(255,255,255,0.02); + transition: background 0.2s; + } + .l2d-status-item.active { background: rgba(13, 115, 119, 0.08); } + .l2d-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #444; + box-shadow: 0 0 0 2px rgba(68,68,68,0.2); + transition: all 0.3s; + } + .l2d-status-dot.on { + background: #10b981; + box-shadow: 0 0 6px rgba(16,185,129,0.4), 0 0 0 2px rgba(16,185,129,0.15); + } + .l2d-status-label { + font-size: 11px; + color: #777; + } + .l2d-status-value { + font-size: 12px; + font-weight: 600; + color: #bbb; + } + .l2d-status-item.active .l2d-status-value { color: #5eead4; } + + /* ── Body ── */ + .l2d-body { + overflow-y: auto; + padding: 4px; + } + + /* ── Section ── */ + .l2d-section { + margin: 4px 6px; + border-radius: 12px; + overflow: hidden; + background: rgba(255,255,255,0.015); + border: 1px solid rgba(255,255,255,0.03); + } + .l2d-section-header { + padding: 10px 14px; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; + transition: background 0.15s; + } + .l2d-section-header:hover { background: rgba(255,255,255,0.03); } + .l2d-section-icon { font-size: 15px; opacity: 0.85; } + .l2d-section-title { + font-weight: 500; + font-size: 13px; + color: #c4c4ce; + } + .l2d-section-current { + margin-left: auto; + font-size: 12px; + font-weight: 600; + padding: 2px 10px; + border-radius: 8px; + background: rgba(255,255,255,0.06); + color: #aaa; + } + .l2d-section-current.idle, .l2d-section-current.neutral { color: #94a3b8; } + .l2d-section-current.happy { color: #fbbf24; background: rgba(251,191,36,0.1); } + .l2d-section-current.sad { color: #60a5fa; background: rgba(96,165,250,0.1); } + .l2d-section-current.angry { color: #f87171; background: rgba(248,113,113,0.1); } + .l2d-section-current.embarrassed { color: #f472b6; background: rgba(244,114,182,0.1); } + .l2d-section-current.thinking { color: #a78bfa; background: rgba(167,139,250,0.1); } + .l2d-section-current.talking { color: #34d399; background: rgba(52,211,153,0.1); } + .l2d-section-current.sleepy { color: #818cf8; background: rgba(129,140,248,0.1); } + .l2d-section-current.surprised { color: #fb923c; background: rgba(251,146,60,0.1); } + .l2d-section-badge { + margin-left: auto; + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 8px; + background: rgba(255,255,255,0.06); + color: #888; + } + .l2d-section-badge.warn { + background: rgba(234,179,8,0.12); + color: #eab308; + } + .l2d-section-arrow { + font-size: 10px; + color: #555; + margin-left: 6px; + } + .l2d-section-body { + padding: 0 14px 14px; + } + + /* ── Chips ── */ + .l2d-chip-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + } + .l2d-chip { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 4px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 10px; + color: #b0b0bc; + cursor: pointer; + font-size: 12px; + transition: all 0.15s; + } + .l2d-chip:hover:not(:disabled) { + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.12); + transform: translateY(-1px); + } + .l2d-chip:active:not(:disabled) { transform: translateY(0); } + .l2d-chip.active { + background: rgba(13, 115, 119, 0.2); + border-color: rgba(94, 234, 212, 0.3); + color: #5eead4; + box-shadow: 0 0 12px rgba(94,234,212,0.08); + } + .l2d-chip:disabled { + opacity: 0.35; + cursor: not-allowed; + } + .l2d-chip-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #555; + } + .l2d-chip.active .l2d-chip-dot { background: #5eead4; } + .l2d-chip-dot.idle, .l2d-chip-dot.neutral { background: #64748b; } + .l2d-chip-dot.happy { background: #fbbf24; } + .l2d-chip-dot.sad { background: #60a5fa; } + .l2d-chip-dot.angry { background: #f87171; } + .l2d-chip-dot.embarrassed { background: #f472b6; } + .l2d-chip-dot.thinking { background: #a78bfa; } + .l2d-chip-dot.talking { background: #34d399; } + .l2d-chip-dot.sleepy { background: #818cf8; } + .l2d-chip-dot.surprised { background: #fb923c; } + + /* ── Progress ── */ + .l2d-progress-wrap { + margin-bottom: 12px; + } + .l2d-progress-label { + display: flex; + justify-content: space-between; + font-size: 11px; + color: #888; + margin-bottom: 6px; + } + .l2d-progress-track { + height: 6px; + background: rgba(255,255,255,0.06); + border-radius: 3px; + overflow: hidden; + } + .l2d-progress-fill { + height: 100%; + background: rgba(94, 234, 212, 0.6); + border-radius: 3px; + transition: width 0.1s linear; + box-shadow: 0 0 8px rgba(94,234,212,0.2); + } + + /* ── Layers ── */ + .l2d-layer-list { display: flex; flex-direction: column; gap: 8px; } + .l2d-layer-item { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + background: rgba(255,255,255,0.02); + border-radius: 10px; + border: 1px solid rgba(255,255,255,0.03); + } + .l2d-layer-info { + display: flex; + justify-content: space-between; + align-items: center; + } + .l2d-layer-name { font-weight: 500; font-size: 12px; color: #ccc; } + .l2d-layer-state { + font-size: 11px; + padding: 2px 8px; + border-radius: 6px; + background: rgba(255,255,255,0.05); + color: #777; + } + .l2d-layer-state.active { + background: rgba(16,185,129,0.1); + color: #34d399; + } + .l2d-layer-state.fadingIn, .l2d-layer-state.fadingOut { + background: rgba(251,191,36,0.1); + color: #fbbf24; + } + .l2d-layer-metrics { + display: flex; + align-items: center; + gap: 10px; + } + .l2d-layer-bar-wrap { + flex: 1; + height: 4px; + background: rgba(255,255,255,0.05); + border-radius: 2px; + overflow: hidden; + } + .l2d-layer-bar { + height: 100%; + background: rgba(94,234,212,0.5); + border-radius: 2px; + transition: width 0.3s; + } + .l2d-layer-value { + font-size: 11px; + font-family: 'SF Mono', monospace; + color: #5eead4; + min-width: 36px; + text-align: right; + } + + /* ── Active Filters ── */ + .l2d-active-filters { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; + } + .l2d-active-filter-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(255,255,255,0.02); + border: 1px solid rgba(255,255,255,0.04); + } + .l2d-active-filter-name { + width: 70px; + font-size: 11px; + color: #bbb; + font-weight: 500; + } + + /* ── Filters ── */ + .l2d-filter-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + } + .l2d-filter-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 10px 4px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.05); + border-radius: 10px; + cursor: pointer; + transition: all 0.15s; + } + .l2d-filter-card:hover { + background: rgba(255,255,255,0.06); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + } + .l2d-filter-preview { + width: 32px; + height: 32px; + border-radius: 50%; + border: 2px solid rgba(255,255,255,0.1); + } + .l2d-filter-preview.evening-warm { background: linear-gradient(135deg, #d97706, #92400e); } + .l2d-filter-preview.morning-cool { background: linear-gradient(135deg, #60a5fa, #1e40af); } + .l2d-filter-preview.neutral { background: linear-gradient(135deg, #9ca3af, #4b5563); } + .l2d-filter-preview.happy-glow { background: linear-gradient(135deg, #fbbf24, #f59e0b); } + .l2d-filter-preview.shy-blush { background: linear-gradient(135deg, #f472b6, #db2777); } + .l2d-filter-preview.angry-red { background: linear-gradient(135deg, #f87171, #dc2626); } + .l2d-filter-name { font-size: 11px; color: #aaa; } + + /* ── Params ── */ + .l2d-param-list { display: flex; flex-direction: column; gap: 6px; } + .l2d-param-row { + display: flex; + align-items: center; + gap: 10px; + padding: 5px 8px; + border-radius: 8px; + background: rgba(255,255,255,0.015); + } + .l2d-param-name { + width: 80px; + font-size: 11px; + color: #999; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: 'SF Mono', monospace; + } + .l2d-param-slider { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: rgba(255,255,255,0.06); + border-radius: 2px; + outline: none; + } + .l2d-param-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: #5eead4; + cursor: pointer; + box-shadow: 0 0 8px rgba(94,234,212,0.3); + border: 2px solid rgba(22,22,30,0.8); + } + .l2d-param-value { + width: 44px; + text-align: right; + font-family: 'SF Mono', monospace; + font-size: 11px; + font-weight: 600; + } + .l2d-param-value.positive { color: #34d399; } + .l2d-param-value.negative { color: #f87171; } + .l2d-param-value.neutral { color: #aaa; } + + /* ── Modules ── */ + .l2d-module-list { display: flex; flex-direction: column; gap: 8px; } + .l2d-module-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + background: rgba(255,255,255,0.02); + border-radius: 10px; + } + .l2d-module-left { + display: flex; + align-items: center; + gap: 10px; + } + .l2d-module-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #444; + transition: all 0.3s; + } + .l2d-module-dot.on { + background: #10b981; + box-shadow: 0 0 8px rgba(16,185,129,0.4); + } + .l2d-module-dot.off { background: #444; } + .l2d-module-name { font-size: 12px; color: #bbb; } + .l2d-module-status { + font-size: 11px; + padding: 3px 10px; + border-radius: 6px; + font-weight: 500; + } + .l2d-module-status.on { + background: rgba(16,185,129,0.1); + color: #34d399; + } + .l2d-module-status.off { + background: rgba(255,255,255,0.04); + color: #666; + } + + /* ── Conflicts ── */ + .l2d-conflict-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 160px; + overflow-y: auto; + } + .l2d-conflict-item { + padding: 10px 12px; + background: rgba(255,255,255,0.02); + border-radius: 10px; + border-left: 3px solid rgba(234,179,8,0.5); + } + .l2d-conflict-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + } + .l2d-conflict-param { + font-weight: 600; + font-size: 12px; + color: #e8a87c; + } + .l2d-conflict-time { + font-size: 10px; + color: #555; + } + .l2d-conflict-detail { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: #777; + } + .l2d-conflict-loser code { + color: #f87171; + background: rgba(248,113,113,0.08); + padding: 1px 5px; + border-radius: 4px; + font-family: 'SF Mono', monospace; + } + .l2d-conflict-winner code { + color: #34d399; + background: rgba(52,211,153,0.08); + padding: 1px 5px; + border-radius: 4px; + font-family: 'SF Mono', monospace; + } + .l2d-conflict-arrow { color: #555; } + + /* ── Performance Chart ── */ + .l2d-chart-wrap { + position: relative; + height: 90px; + margin-bottom: 14px; + background: rgba(0,0,0,0.2); + border-radius: 10px; + overflow: hidden; + border: 1px solid rgba(255,255,255,0.04); + } + .l2d-chart { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + } + .l2d-chart-labels { + position: absolute; + right: 6px; + top: 4px; + bottom: 4px; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-end; + pointer-events: none; + } + .l2d-chart-label { + font-size: 9px; + color: #555; + font-family: 'SF Mono', monospace; + } + + /* ── Performance Grid ── */ + .l2d-perf-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + margin-bottom: 14px; + } + .l2d-perf-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 10px 4px; + background: rgba(255,255,255,0.02); + border-radius: 10px; + border: 1px solid rgba(255,255,255,0.04); + } + .l2d-perf-value { + font-size: 18px; + font-weight: 700; + font-family: 'SF Mono', monospace; + color: #e2e2e8; + line-height: 1; + } + .l2d-perf-value.positive { color: #34d399; } + .l2d-perf-value.negative { color: #f87171; } + .l2d-perf-value.neutral { color: #fbbf24; } + .l2d-perf-unit { + font-size: 9px; + color: #666; + font-family: 'SF Mono', monospace; + } + .l2d-perf-label { + font-size: 10px; + color: #777; + margin-top: 2px; + } + + /* ── Subsystem Load Bars ── */ + .l2d-subsys-list { + display: flex; + flex-direction: column; + gap: 8px; + } + .l2d-subsys-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: rgba(255,255,255,0.015); + border-radius: 8px; + } + .l2d-subsys-name { + width: 72px; + font-size: 11px; + color: #999; + } + .l2d-subsys-bar-wrap { + flex: 1; + height: 5px; + background: rgba(255,255,255,0.05); + border-radius: 3px; + overflow: hidden; + } + .l2d-subsys-bar { + height: 100%; + width: 0%; + background: #555; + border-radius: 3px; + transition: width 0.4s ease; + } + .l2d-subsys-bar.active { + background: linear-gradient(90deg, #0d7377, #5eead4); + box-shadow: 0 0 8px rgba(94,234,212,0.15); + } + .l2d-subsys-status { + width: 44px; + text-align: right; + font-size: 10px; + color: #666; + font-family: 'SF Mono', monospace; + } + + /* ── Buttons ── */ + .l2d-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + padding: 10px; + margin-top: 10px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 10px; + color: #aaa; + cursor: pointer; + font-size: 12px; + transition: all 0.15s; + } + .l2d-btn:hover { + background: rgba(255,255,255,0.08); + color: #ddd; + } + .l2d-btn-secondary { + background: rgba(239,68,68,0.06); + border-color: rgba(239,68,68,0.12); + color: #f87171; + } + .l2d-btn-secondary:hover { + background: rgba(239,68,68,0.1); + } + + /* ── Empty ── */ + .l2d-empty { + text-align: center; + padding: 20px; + color: #555; + font-size: 12px; + } + `); +} + +customElements.define("live2d-dev-tools", Live2dDevTools); diff --git a/packages/live2d/src/components/Live2dDevTools/__tests__/Live2dDevTools.test.ts b/packages/live2d/src/components/Live2dDevTools/__tests__/Live2dDevTools.test.ts new file mode 100644 index 0000000..a1da763 --- /dev/null +++ b/packages/live2d/src/components/Live2dDevTools/__tests__/Live2dDevTools.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { Live2dDevTools } from "../Live2dDevTools"; +import { Live2dRuntimeController } from "@/live2d/runtime/controller"; + +interface DevToolsPrivate { + _transitionFSM(state: string): void; + _transitionEmotion(emotion: string): void; + _applyFilter(preset: string): void; + _clearFilters(): void; + _setParamValue(name: string, value: number): void; + _setFilterIntensity(id: string, value: number): void; + _toggleVisible(): void; + _visible: boolean; + _controller: Live2dRuntimeController | null; +} + +function asPrivate(devtools: Live2dDevTools): DevToolsPrivate { + return devtools as unknown as DevToolsPrivate; +} + +describe("Live2dDevTools", () => { + let devtools: Live2dDevTools; + let controller: Live2dRuntimeController; + + beforeEach(() => { + devtools = new Live2dDevTools(); + controller = new Live2dRuntimeController(); + devtools.setController(controller); + }); + + afterEach(() => { + devtools.disconnectedCallback?.(); + }); + + describe("controller integration", () => { + it("setController stores controller reference", () => { + expect(() => devtools.setController(controller)).not.toThrow(); + }); + + it("FSM state button triggers transition", () => { + const transitionSpy = vi.spyOn(controller, "transitionTo"); + asPrivate(devtools)._transitionFSM("happy"); + expect(transitionSpy).toHaveBeenCalledWith({ fsm: "happy" }); + }); + + it("FSM state button triggers different states", () => { + const transitionSpy = vi.spyOn(controller, "transitionTo"); + asPrivate(devtools)._transitionFSM("thinking"); + expect(transitionSpy).toHaveBeenCalledWith({ fsm: "thinking" }); + }); + + it("emotion button triggers transition", () => { + const transitionSpy = vi.spyOn(controller, "transitionTo"); + asPrivate(devtools)._transitionEmotion("happy"); + expect(transitionSpy).toHaveBeenCalledWith({ emotion: "happy" }); + }); + + it("emotion button triggers different emotions", () => { + const transitionSpy = vi.spyOn(controller, "transitionTo"); + asPrivate(devtools)._transitionEmotion("sad"); + expect(transitionSpy).toHaveBeenCalledWith({ emotion: "sad" }); + }); + + it("filter preset button applies effect", () => { + const filterPipeline = controller.getFilterPipeline(); + const applySpy = vi.spyOn(filterPipeline, "applyPreset"); + asPrivate(devtools)._applyFilter("happy-glow"); + expect(applySpy).toHaveBeenCalledWith("happy-glow"); + }); + + it("filter preset button applies different presets", () => { + const filterPipeline = controller.getFilterPipeline(); + const applySpy = vi.spyOn(filterPipeline, "applyPreset"); + asPrivate(devtools)._applyFilter("shy-blush"); + expect(applySpy).toHaveBeenCalledWith("shy-blush"); + }); + + it("clear filters button clears all effects", () => { + const filterPipeline = controller.getFilterPipeline(); + const clearSpy = vi.spyOn(filterPipeline, "clear"); + asPrivate(devtools)._clearFilters(); + expect(clearSpy).toHaveBeenCalled(); + }); + + it("slider changes parameter value with manual priority", () => { + const semanticLayer = controller.getSemanticLayer(); + const setSemanticSpy = vi.spyOn(semanticLayer, "setSemantic"); + asPrivate(devtools)._setParamValue("mouthOpen", 5.5); + expect(setSemanticSpy).toHaveBeenCalledWith("mouthOpen", 5.5, "override", "manual", 1); + }); + + it("filter intensity slider adjusts effect intensity", () => { + const filterPipeline = controller.getFilterPipeline(); + const setIntensitySpy = vi.spyOn(filterPipeline, "setIntensity"); + asPrivate(devtools)._setFilterIntensity("fx-123", 0.75); + expect(setIntensitySpy).toHaveBeenCalledWith("fx-123", 0.75); + }); + }); + + describe("visibility toggle", () => { + it("toggle switches visibility state", () => { + const initialVisible = asPrivate(devtools)._visible; + asPrivate(devtools)._toggleVisible(); + const afterToggle = asPrivate(devtools)._visible; + expect(afterToggle).toBe(!initialVisible); + }); + }); + + describe("conflict log", () => { + it("clear conflict log delegates to controller", () => { + const clearSpy = vi.spyOn(controller, "clearConflictLog"); + asPrivate(devtools)._controller = controller; + controller.clearConflictLog(); + expect(clearSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/live2d/src/components/Live2dDevTools/index.ts b/packages/live2d/src/components/Live2dDevTools/index.ts new file mode 100644 index 0000000..5f8d9b3 --- /dev/null +++ b/packages/live2d/src/components/Live2dDevTools/index.ts @@ -0,0 +1,3 @@ +import { Live2dDevTools } from "./Live2dDevTools"; + +export { Live2dDevTools }; diff --git a/packages/live2d/src/config/default-config.ts b/packages/live2d/src/config/default-config.ts index bf521de..6f8dd38 100644 --- a/packages/live2d/src/config/default-config.ts +++ b/packages/live2d/src/config/default-config.ts @@ -1,35 +1,84 @@ import type { Live2dConfig } from "@/live2d/context/config-context"; export const DEFAULT_TOOL_NAMES = [ - "hitokoto", - "asteroids", - "switch-model", - "switch-texture", - "photo", - "info", - "quit", + "hitokoto", + "asteroids", + "switch-model", + "switch-texture", + "photo", + "info", + "quit", ] as const; export const createDefaultLive2dConfig = (): Live2dConfig => ({ - apiPath: "https://live2d.fghrsh.net/api/", - live2dLocation: "left", - consoleShowStatus: false, - isForceUseDefaultConfig: false, - modelId: 1, - modelTexturesId: 53, - tipsPath: "", - selectorTips: [], - backSite: true, - backSiteTip: "", - copyContent: true, - copyContentTip: "", - openConsole: true, - openConsoleTip: "", - firstOpenSite: true, - isTools: true, - tools: [...DEFAULT_TOOL_NAMES], - isAiChat: true, - chunkTimeout: 10, - showChatMessageTimeout: 10, - screenshotName: "live2d", + apiPath: "https://live2d.fghrsh.net/api/", + live2dLocation: "left", + consoleShowStatus: import.meta.env.DEV ?? false, + isForceUseDefaultConfig: false, + modelId: 1, + modelTexturesId: 53, + tipsPath: "", + selectorTips: [], + backSite: true, + backSiteTip: "", + copyContent: true, + copyContentTip: "", + openConsole: true, + openConsoleTip: "", + firstOpenSite: true, + isTools: true, + tools: [...DEFAULT_TOOL_NAMES], + isAiChat: true, + chunkTimeout: 10, + showChatMessageTimeout: 10, + screenshotName: "live2d", + filterQuality: "high", + proceduralAnimation: { + enabled: true, + breathing: { + enabled: true, + period: 3000, + amplitude: 0.15, + }, + blink: { + enabled: true, + minInterval: 2000, + maxInterval: 6000, + duration: 150, + }, + eyeTracking: { + enabled: true, + maxAngleX: 15, + maxAngleY: 10, + maxEyeBallX: 1.5, + maxEyeBallY: 1.5, + smoothing: 0.15, + }, + }, + motionLayers: { + enabled: true, + layers: { + idle: { priority: 1 }, + expression: { priority: 2 }, + talk: { priority: 3 }, + gesture: { priority: 4 }, + physics: { priority: 5 }, + }, + defaultCrossfadeDuration: 300, + }, + behaviorFSM: { + enabled: true, + initialState: "idle", + defaultDebounceMs: 300, + }, + emotionTimeline: { + enabled: true, + defaultDuration: 800, + minDuration: 200, + defaultEasing: "easeOut", + idleReturnDelay: 3000, + }, + devTools: { + enabled: false, + }, }); diff --git a/packages/live2d/src/context/config-context.ts b/packages/live2d/src/context/config-context.ts index b416d7d..450909a 100644 --- a/packages/live2d/src/context/config-context.ts +++ b/packages/live2d/src/context/config-context.ts @@ -1,4 +1,6 @@ import type { CustomToolConfig } from "@/live2d/live2d/tools/custom-tool-config"; +import type { ProceduralConfig } from "@/live2d/runtime/procedural"; +import type { EmotionTimelineConfig } from "@/live2d/runtime/emotion"; import { createContext } from "@lit/context"; export interface ObjectAny extends Record {} @@ -126,6 +128,34 @@ export interface Live2dConfig extends Live2dToolsConfig { chunkTimeout?: number | string; showChatMessageTimeout?: number | string; }; + // 滤镜质量等级 (low / medium / high) + filterQuality?: "low" | "medium" | "high"; + // 程序化动画配置 + proceduralAnimation?: ProceduralConfig; + // 运动层系统配置 + motionLayers?: { + enabled?: boolean; + layers?: { + idle?: { priority?: number }; + expression?: { priority?: number }; + talk?: { priority?: number }; + gesture?: { priority?: number }; + physics?: { priority?: number }; + }; + defaultCrossfadeDuration?: number; + }; + // 行为状态机配置 + behaviorFSM?: { + enabled?: boolean; + initialState?: string; + defaultDebounceMs?: number; + }; + // 情感时间线配置 + emotionTimeline?: EmotionTimelineConfig; + // 开发工具配置 + devTools?: { + enabled?: boolean; + }; [key: string]: unknown; } diff --git a/packages/live2d/src/env.d.ts b/packages/live2d/src/env.d.ts index b0ac762..0d35b81 100644 --- a/packages/live2d/src/env.d.ts +++ b/packages/live2d/src/env.d.ts @@ -1 +1,15 @@ /// + +interface ImportMetaEnv { + readonly DEV: boolean; + readonly PROD: boolean; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + +declare module "*.css?inline" { + const content: string; + export default content; +} diff --git a/packages/live2d/src/live2d/model.ts b/packages/live2d/src/live2d/model.ts index ad6476a..95e775e 100644 --- a/packages/live2d/src/live2d/model.ts +++ b/packages/live2d/src/live2d/model.ts @@ -8,6 +8,10 @@ import * as PIXI from "pixi.js"; import "@/live2d/libs/live2d.min.js"; import "@/live2d/libs/live2dcubismcore.min.js"; import { Live2DModel } from "untitled-pixi-live2d-engine"; +import { Live2dRuntimeController } from "@/live2d/runtime/controller"; +import { SemanticParameterLayer } from "@/live2d/runtime/semantic"; +import type { BehaviorFSM } from "@/live2d/runtime/behavior"; +import type { EmotionTimeline } from "@/live2d/runtime/emotion"; declare global { interface Window { @@ -30,6 +34,10 @@ interface ModelResult { }; } +interface EyeTrackingCleanup { + _cleanupEyeTracking?: () => void; +} + const LIVE2D_CANVAS_SIZE = 300; const LIVE2D_MODEL_PADDING = 1; const LIVE2D_BOTTOM_OFFSET = 1; @@ -38,6 +46,7 @@ const LOADING_MESSAGE_DELAY_MS = 1200; const DEFAULT_LOADING_MESSAGE = "稍等一下下哦,人家正在梳理小裙摆,马上就来陪你啦~"; const HEAD_HIT_AREA_PATTERN = /(head|flickhead)/i; + const SPEECH_ANCHOR_MIN_RATIO = 0.04; const SPEECH_ANCHOR_MAX_RATIO = 0.14; @@ -57,6 +66,7 @@ class Model { #currentModel: Live2DModel | null = null; #hasLoggedConsoleStatus = false; #loadSequence = 0; + #controller: Live2dRuntimeController; private constructor(root: HTMLCanvasElement, config: Live2dConfig) { const apiPath = config.apiPath; @@ -67,6 +77,13 @@ class Model { this.#apiPath = apiPath.endsWith("/") ? apiPath : `${apiPath}/`; this.#config = config; this.#live2dRootElement = root; + this.#controller = new Live2dRuntimeController({ + behaviorFSM: config.behaviorFSM, + emotionTimeline: config.emotionTimeline, + motionLayers: config.motionLayers, + proceduralAnimation: config.proceduralAnimation, + filterQuality: config.filterQuality, + }); this.#appPromise = this.initializeApplication(); } @@ -187,6 +204,7 @@ class Model { ); if (this.#currentModel) { + this.#controller.destroy(app.ticker); app.stage.removeChild(this.#currentModel); this.#currentModel.destroy(); } @@ -194,6 +212,31 @@ class Model { app.stage.removeChildren(); app.stage.addChild(nextModel); this.#currentModel = nextModel; + + // Stop any playing motions from the engine so our runtime takes over + this.stopEngineMotions(nextModel); + + // Initialize controller with model + this.#controller.initialize(nextModel, app.ticker); + } + + private stopEngineMotions(model: Live2DModel): void { + const internal = model.internalModel; + if (!internal) return; + + // Stop primary motion manager + if (typeof internal.motionManager?.stopAllMotions === "function") { + internal.motionManager.stopAllMotions(); + } + + // Stop parallel motion managers (e.g., for Cubism 2.1 .mtn files) + if (Array.isArray(internal.parallelMotionManager)) { + for (const pm of internal.parallelMotionManager) { + if (typeof pm?.stopAllMotions === "function") { + pm.stopAllMotions(); + } + } + } } private getSpeechAnchorTopY( @@ -211,16 +254,15 @@ class Model { return model.position.y + (localTopY - model.pivot.y) * model.scale.y; } - private getHeadTopY(model: Live2DModel): number | undefined { - const headHitArea = Object.values(model.internalModel.hitAreas).find( - ({ name }) => HEAD_HIT_AREA_PATTERN.test(name), - ); - if (!headHitArea) { + private getHeadTopY(_model: Live2DModel): number | undefined { + // Use semantic layer for hit area lookup when available + const headIndex = this.#controller.getSemanticLayer().getHitAreaIndex(HEAD_HIT_AREA_PATTERN); + if (headIndex === undefined) { return; } - const headBounds = model.internalModel.getDrawableBounds(headHitArea.index); - if (!Number.isFinite(headBounds.y) || headBounds.height <= 0) { + const headBounds = this.#controller.getSemanticLayer().getDrawableBounds(headIndex); + if (!headBounds) { return; } @@ -287,6 +329,23 @@ class Model { await this.replaceModel(model); + if (this.#config.consoleShowStatus) { + const profile = this.#controller.getSemanticLayer().getCapabilityProfile(); + const detectedNames = Array.from(profile.detected.keys()).join(", "); + const missingNames = profile.missing.join(", "); + console.log( + `[Status] Semantic parameters detected: ${profile.detected.size}, missing: ${profile.missing.length}`, + ); + if (detectedNames) { + console.log(`[Status] Detected: ${detectedNames}`); + } + if (missingNames) { + console.log(`[Status] Missing: ${missingNames}`); + } + } + + this.setupEyeTrackingEvents(); + if (options.successMessage) { sendMessage(options.successMessage, MESSAGE_TIMEOUT_MS, 3); } @@ -372,7 +431,76 @@ class Model { }); } + getController(): Live2dRuntimeController { + return this.#controller; + } + + getSemanticLayer(): SemanticParameterLayer { + return this.#controller.getSemanticLayer(); + } + + getBehaviorFSM(): BehaviorFSM | undefined { + return this.#controller.getBehaviorFSM(); + } + + getEmotionTimeline(): EmotionTimeline | undefined { + return this.#controller.getEmotionTimeline(); + } + + private setupEyeTrackingEvents(): void { + const eyeTracking = this.#controller.getProceduralSystem()?.getEyeTrackingModule(); + if (!eyeTracking) return; + + const canvas = this.#live2dRootElement; + + const handleMouseMove = (e: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + eyeTracking.updateCursorPosition(e.clientX, e.clientY, rect); + }; + + const handleMouseLeave = () => { + eyeTracking.onCursorLeave(); + }; + + const handleTouchMove = (e: TouchEvent) => { + if (e.touches.length > 0) { + const rect = canvas.getBoundingClientRect(); + eyeTracking.updateCursorPosition( + e.touches[0].clientX, + e.touches[0].clientY, + rect, + ); + } + }; + + const handleTouchEnd = () => { + eyeTracking.onCursorLeave(); + }; + + // Bind to window so tracking works even when cursor is outside the canvas + // (e.g. when Live2D is inside a shadow DOM container) + window.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseleave", handleMouseLeave); + window.addEventListener("touchmove", handleTouchMove, { passive: true }); + window.addEventListener("touchend", handleTouchEnd); + + // Store cleanup function on the element for later removal + (canvas as unknown as EyeTrackingCleanup)._cleanupEyeTracking = () => { + window.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseleave", handleMouseLeave); + window.removeEventListener("touchmove", handleTouchMove); + window.removeEventListener("touchend", handleTouchEnd); + }; + } + destroy(): void { + // Cleanup eye tracking events + const cleanup = (this.#live2dRootElement as unknown as EyeTrackingCleanup) + ._cleanupEyeTracking; + cleanup?.(); + + this.#controller.destroy(this.#app?.ticker); + if (this.#currentModel && this.#app) { this.#app.stage.removeChild(this.#currentModel); this.#currentModel.destroy(); diff --git a/packages/live2d/src/runtime/behavior/__tests__/behavior-fsm.test.ts b/packages/live2d/src/runtime/behavior/__tests__/behavior-fsm.test.ts new file mode 100644 index 0000000..0212e04 --- /dev/null +++ b/packages/live2d/src/runtime/behavior/__tests__/behavior-fsm.test.ts @@ -0,0 +1,562 @@ +import { describe, expect, it, vi } from "vitest"; +import { BehaviorFSM } from "../fsm"; +import { mergeProfiles, buildProfile } from "../profile"; +import type { BehaviorState, BehaviorProfile, BehaviorContext } from "../types"; + +describe("BehaviorFSM", () => { + function createMockContext(): BehaviorContext { + return { + motionLayerSystem: { + play: vi.fn(), + stop: vi.fn(), + isPlaying: vi.fn(() => false), + getActiveLayers: vi.fn(() => []), + } as unknown as NonNullable, + filterPipeline: { + applyPreset: vi.fn((preset) => ({ id: `handle-${preset}` })), + remove: vi.fn(), + clear: vi.fn(), + getEffectCount: vi.fn(() => 0), + } as unknown as NonNullable, + semanticLayer: { + setSemantic: vi.fn(), + getSemantic: vi.fn(() => 0), + hasSemantic: vi.fn(() => true), + getCapabilityProfile: vi.fn(() => ({ + detected: new Map(), + missing: [], + notApplicable: [], + })), + } as unknown as NonNullable, + proceduralSystem: { + enableModule: vi.fn(), + disableModule: vi.fn(), + getModuleStatuses: vi.fn(() => [ + { name: "breathing", enabled: true }, + { name: "blink", enabled: true }, + { name: "eyeTracking", enabled: true }, + ]), + } as unknown as NonNullable, + }; + } + + describe("state registration", () => { + it("registers and unregisters states", () => { + const fsm = new BehaviorFSM(createMockContext()); + const state: BehaviorState = { name: "test" }; + + fsm.registerState(state); + expect(fsm.hasState("test")).toBe(true); + expect(fsm.getRegisteredStates()).toContain("test"); + + fsm.unregisterState("test"); + expect(fsm.hasState("test")).toBe(false); + }); + }); + + describe("transitionTo", () => { + it("transitions to a registered state", () => { + const fsm = new BehaviorFSM(createMockContext()); + fsm.registerState({ name: "idle" }); + fsm.registerState({ name: "happy" }); + + fsm.initialize(); + expect(fsm.getCurrentState()).toBe("idle"); + + const result = fsm.transitionTo("happy"); + expect(result).toBe(true); + expect(fsm.getCurrentState()).toBe("happy"); + }); + + it("returns false for unregistered state", () => { + const fsm = new BehaviorFSM(createMockContext()); + fsm.registerState({ name: "idle" }); + fsm.initialize(); + + const result = fsm.transitionTo("nonexistent"); + expect(result).toBe(false); + expect(fsm.getCurrentState()).toBe("idle"); + }); + + it("is a no-op when transitioning to the same state", () => { + const fsm = new BehaviorFSM(createMockContext()); + fsm.registerState({ name: "idle" }); + fsm.initialize(); + + const result = fsm.transitionTo("idle"); + expect(result).toBe(false); + expect(fsm.getCurrentState()).toBe("idle"); + }); + }); + + describe("transition guards", () => { + it("allows transition when guard returns true", () => { + const fsm = new BehaviorFSM(createMockContext()); + fsm.registerState({ name: "idle" }); + fsm.registerState({ + name: "happy", + transitionGuard: () => true, + }); + fsm.initialize(); + + const result = fsm.transitionTo("happy"); + expect(result).toBe(true); + expect(fsm.getCurrentState()).toBe("happy"); + }); + + it("blocks transition when guard returns false", () => { + const fsm = new BehaviorFSM(createMockContext()); + fsm.registerState({ name: "idle" }); + fsm.registerState({ + name: "sleepy", + transitionGuard: () => false, + }); + fsm.initialize(); + + const result = fsm.transitionTo("sleepy"); + expect(result).toBe(false); + expect(fsm.getCurrentState()).toBe("idle"); + }); + + it("passes from and to state names to guard", () => { + const guard = vi.fn(() => true); + const fsm = new BehaviorFSM(createMockContext()); + fsm.registerState({ name: "idle" }); + fsm.registerState({ name: "happy", transitionGuard: guard }); + fsm.initialize(); + + fsm.transitionTo("happy"); + expect(guard).toHaveBeenCalledWith("idle", "happy"); + }); + }); + + describe("canTransitionTo", () => { + it("returns true for valid transition", () => { + const fsm = new BehaviorFSM(createMockContext()); + fsm.registerState({ name: "idle" }); + fsm.registerState({ name: "happy" }); + fsm.initialize(); + + expect(fsm.canTransitionTo("happy")).toBe(true); + }); + + it("returns false for same state", () => { + const fsm = new BehaviorFSM(createMockContext()); + fsm.registerState({ name: "idle" }); + fsm.initialize(); + + expect(fsm.canTransitionTo("idle")).toBe(false); + }); + + it("returns false when guard blocks", () => { + const fsm = new BehaviorFSM(createMockContext()); + fsm.registerState({ name: "idle" }); + fsm.registerState({ + name: "sleepy", + transitionGuard: () => false, + }); + fsm.initialize(); + + expect(fsm.canTransitionTo("sleepy")).toBe(false); + }); + }); + + describe("entry profile application", () => { + it("applies motion layer effects on state entry", () => { + const ctx = createMockContext(); + const fsm = new BehaviorFSM(ctx); + fsm.registerState({ + name: "talking", + entryProfile: { + motionLayers: { + talk: { + parameters: { mouthOpen: { value: 0.5 } }, + fadeIn: 200, + }, + }, + }, + }); + + fsm.transitionTo("talking"); + + expect(ctx.motionLayerSystem!.play).toHaveBeenCalledWith({ + layer: "talk", + parameters: { mouthOpen: { value: 0.5 } }, + fadeIn: 200, + }); + }); + + it("applies filters on state entry", () => { + const ctx = createMockContext(); + const fsm = new BehaviorFSM(ctx); + fsm.registerState({ + name: "happy", + entryProfile: { + filters: ["happy-glow"], + }, + }); + + fsm.transitionTo("happy"); + + expect(ctx.filterPipeline!.applyPreset).toHaveBeenCalledWith("happy-glow"); + }); + + it("applies semantic parameters on state entry", () => { + const ctx = createMockContext(); + const fsm = new BehaviorFSM(ctx); + fsm.registerState({ + name: "happy", + entryProfile: { + semanticParameters: { + mouthSmile: { value: 0.6, blendMode: "override" }, + }, + }, + }); + + fsm.transitionTo("happy"); + + expect(ctx.semanticLayer!.setSemantic).toHaveBeenCalledWith( + "mouthSmile", + 0.6, + "override", + "fsm", + 2, + ); + }); + + it("applies procedural overrides on state entry", () => { + const ctx = createMockContext(); + const fsm = new BehaviorFSM(ctx); + fsm.registerState({ + name: "sleepy", + entryProfile: { + proceduralOverrides: { Blink: false }, + }, + }); + + fsm.transitionTo("sleepy"); + + expect(ctx.proceduralSystem!.disableModule).toHaveBeenCalledWith("Blink"); + }); + }); + + describe("exit profile reversal", () => { + it("reverts motion layers on state exit", () => { + const ctx = createMockContext(); + const fsm = new BehaviorFSM(ctx); + fsm.registerState({ + name: "idle", + entryProfile: { + motionLayers: { + idle: { + parameters: { breath: { value: 0.3 } }, + fadeIn: 500, + }, + }, + }, + }); + fsm.registerState({ name: "happy" }); + + fsm.transitionTo("idle"); + fsm.transitionTo("happy"); + + expect(ctx.motionLayerSystem!.stop).toHaveBeenCalledWith("idle"); + }); + + it("removes filters on state exit", () => { + const ctx = createMockContext(); + const fsm = new BehaviorFSM(ctx); + fsm.registerState({ + name: "happy", + entryProfile: { + filters: ["happy-glow"], + }, + }); + fsm.registerState({ name: "idle" }); + + fsm.transitionTo("happy"); + fsm.transitionTo("idle"); + + expect(ctx.filterPipeline!.remove).toHaveBeenCalledWith("handle-happy-glow"); + }); + + it("resets semantic parameters on state exit", () => { + const ctx = createMockContext(); + const fsm = new BehaviorFSM(ctx); + fsm.registerState({ + name: "happy", + entryProfile: { + semanticParameters: { + mouthSmile: { value: 0.6 }, + }, + }, + }); + fsm.registerState({ name: "idle" }); + + fsm.transitionTo("happy"); + fsm.transitionTo("idle"); + + expect(ctx.semanticLayer!.setSemantic).toHaveBeenCalledWith( + "mouthSmile", + 0, + "override", + "fsm", + 2, + ); + }); + + it("reverts procedural overrides on state exit", () => { + const ctx = createMockContext(); + const fsm = new BehaviorFSM(ctx); + fsm.registerState({ + name: "sleepy", + entryProfile: { + proceduralOverrides: { Blink: false }, + }, + }); + fsm.registerState({ name: "idle" }); + + fsm.transitionTo("sleepy"); + fsm.transitionTo("idle"); + + // Blink should be re-enabled (default was true) + expect(ctx.proceduralSystem!.enableModule).toHaveBeenCalledWith("Blink"); + }); + + it("uses explicit exitProfile when provided", () => { + const ctx = createMockContext(); + const fsm = new BehaviorFSM(ctx); + fsm.registerState({ + name: "idle", + entryProfile: { + motionLayers: { + idle: { + parameters: { breath: { value: 0.3 } }, + }, + }, + }, + exitProfile: { + motionLayers: { + idle: { + parameters: { breath: { value: 0 } }, + }, + }, + }, + }); + fsm.registerState({ name: "talking" }); + + fsm.transitionTo("idle"); + fsm.transitionTo("talking"); + + // exitProfile should revert the idle layer + expect(ctx.motionLayerSystem!.stop).toHaveBeenCalledWith("idle"); + }); + }); + + describe("entry/exit hooks", () => { + it("calls onEnter hook when entering state", () => { + const onEnter = vi.fn(); + const ctx = createMockContext(); + const fsm = new BehaviorFSM(ctx); + fsm.registerState({ name: "idle" }); + fsm.registerState({ name: "happy", onEnter }); + + fsm.initialize(); + fsm.transitionTo("happy"); + + expect(onEnter).toHaveBeenCalledWith(ctx); + }); + + it("calls onExit hook when exiting state", () => { + const onExit = vi.fn(); + const ctx = createMockContext(); + const fsm = new BehaviorFSM(ctx); + fsm.registerState({ name: "idle", onExit }); + fsm.registerState({ name: "happy" }); + + fsm.initialize(); + fsm.transitionTo("happy"); + + expect(onExit).toHaveBeenCalledWith(ctx); + }); + }); + + describe("transition debounce", () => { + it("prevents rapid state switching", () => { + const fsm = new BehaviorFSM(createMockContext(), { defaultDebounceMs: 500 }); + fsm.registerState({ name: "idle" }); + fsm.registerState({ name: "happy" }); + fsm.registerState({ name: "sad" }); + fsm.initialize(); + + expect(fsm.transitionTo("happy")).toBe(true); + expect(fsm.transitionTo("sad")).toBe(false); // debounced + }); + + it("allows transitions after debounce period", () => { + const fsm = new BehaviorFSM(createMockContext(), { defaultDebounceMs: 50 }); + fsm.registerState({ name: "idle" }); + fsm.registerState({ name: "happy" }); + fsm.registerState({ name: "sad" }); + fsm.initialize(); + + expect(fsm.transitionTo("happy")).toBe(true); + + // Wait for debounce + return new Promise((resolve) => { + setTimeout(() => { + expect(fsm.transitionTo("sad")).toBe(true); + resolve(undefined); + }, 60); + }); + }); + + it("uses per-state debounce when configured", () => { + const fsm = new BehaviorFSM(createMockContext(), { defaultDebounceMs: 0 }); + fsm.registerState({ name: "idle" }); + fsm.registerState({ name: "happy", debounceMs: 500 }); + fsm.registerState({ name: "sad" }); + fsm.initialize(); + + expect(fsm.transitionTo("happy")).toBe(true); + expect(fsm.transitionTo("sad")).toBe(false); // blocked by happy's debounce + }); + }); + + describe("disabled FSM", () => { + it("blocks all transitions when disabled", () => { + const fsm = new BehaviorFSM(createMockContext(), { enabled: false }); + fsm.registerState({ name: "idle" }); + fsm.registerState({ name: "happy" }); + fsm.initialize(); + + expect(fsm.canTransitionTo("happy")).toBe(false); + expect(fsm.transitionTo("happy")).toBe(false); + }); + }); + + describe("full state cycle", () => { + it("transitions through idle → happy → embarrassed → idle", () => { + const ctx = createMockContext(); + const fsm = new BehaviorFSM(ctx); + + fsm.registerState({ + name: "idle", + entryProfile: { + motionLayers: { + idle: { + parameters: { breath: { value: 0.3 } }, + fadeIn: 800, + }, + }, + }, + debounceMs: 0, + }); + fsm.registerState({ + name: "happy", + entryProfile: { + filters: ["happy-glow"], + semanticParameters: { + mouthSmile: { value: 0.6 }, + }, + }, + debounceMs: 0, + }); + fsm.registerState({ + name: "embarrassed", + entryProfile: { + filters: ["shy-blush"], + semanticParameters: { + cheek: { value: 0.5 }, + }, + }, + debounceMs: 0, + }); + + // idle -> happy + fsm.transitionTo("idle"); + expect(fsm.getCurrentState()).toBe("idle"); + expect(ctx.motionLayerSystem!.play).toHaveBeenCalledWith( + expect.objectContaining({ layer: "idle" }), + ); + + fsm.transitionTo("happy"); + expect(fsm.getCurrentState()).toBe("happy"); + expect(ctx.filterPipeline!.applyPreset).toHaveBeenCalledWith("happy-glow"); + + // happy -> embarrassed + fsm.transitionTo("embarrassed"); + expect(fsm.getCurrentState()).toBe("embarrassed"); + // happy's filter should be removed + expect(ctx.filterPipeline!.remove).toHaveBeenCalledWith("handle-happy-glow"); + // embarrassed's filter should be applied + expect(ctx.filterPipeline!.applyPreset).toHaveBeenCalledWith("shy-blush"); + + // embarrassed -> idle + fsm.transitionTo("idle"); + expect(fsm.getCurrentState()).toBe("idle"); + // embarrassed's filter should be removed + expect(ctx.filterPipeline!.remove).toHaveBeenCalledWith("handle-shy-blush"); + }); + }); +}); + +describe("Profile merging", () => { + it("merges two profiles with override precedence", () => { + const base: BehaviorProfile = { + motionLayers: { + idle: { + parameters: { breath: { value: 0.1 } }, + fadeIn: 500, + }, + }, + semanticParameters: { + browLY: { value: 0.2 }, + }, + }; + + const override: BehaviorProfile = { + motionLayers: { + idle: { + parameters: { breath: { value: 0.2 } }, + }, + }, + semanticParameters: { + mouthSmile: { value: 0.5 }, + }, + }; + + const merged = mergeProfiles(base, override); + + expect(merged.motionLayers?.idle?.parameters.breath.value).toBe(0.2); + expect(merged.motionLayers?.idle?.fadeIn).toBe(500); // from base + expect(merged.semanticParameters?.browLY?.value).toBe(0.2); // from base + expect(merged.semanticParameters?.mouthSmile?.value).toBe(0.5); + }); + + it("combines filter arrays", () => { + const base: BehaviorProfile = { filters: ["neutral"] }; + const override: BehaviorProfile = { filters: ["happy-glow"] }; + + const merged = mergeProfiles(base, override); + expect(merged.filters).toEqual(["neutral", "happy-glow"]); + }); + + it("builds profile with multiple overrides", () => { + const base: BehaviorProfile = { + semanticParameters: { breath: { value: 0.1 } }, + }; + const o1: BehaviorProfile = { + semanticParameters: { browLY: { value: 0.2 } }, + }; + const o2: BehaviorProfile = { + semanticParameters: { mouthSmile: { value: 0.5 } }, + }; + + const result = buildProfile(base, o1, o2); + + expect(result.semanticParameters?.breath?.value).toBe(0.1); + expect(result.semanticParameters?.browLY?.value).toBe(0.2); + expect(result.semanticParameters?.mouthSmile?.value).toBe(0.5); + }); +}); diff --git a/packages/live2d/src/runtime/behavior/built-in-states.ts b/packages/live2d/src/runtime/behavior/built-in-states.ts new file mode 100644 index 0000000..c654f86 --- /dev/null +++ b/packages/live2d/src/runtime/behavior/built-in-states.ts @@ -0,0 +1,209 @@ +import type { BehaviorState, BehaviorProfile } from "./types"; +import { buildProfile } from "./profile"; + +// ── Base profile ────────────────────────────────────────────── + +const baseProfile: BehaviorProfile = { + motionLayers: { + idle: { + parameters: { + breath: { value: 0.3, blendMode: "add" }, + }, + fadeIn: 500, + }, + }, +}; + +// ── Individual state profiles ───────────────────────────────── + +const idleProfile: BehaviorProfile = buildProfile(baseProfile, { + motionLayers: { + idle: { + parameters: { + breath: { value: 0.3, blendMode: "add" }, + }, + fadeIn: 800, + }, + }, +}); + +const happyProfile: BehaviorProfile = buildProfile(baseProfile, { + motionLayers: { + expression: { + parameters: { + mouthSmile: { value: 0.6, blendMode: "override" }, + eyeLSmile: { value: 0.5, blendMode: "override" }, + eyeRSmile: { value: 0.5, blendMode: "override" }, + cheek: { value: 0.2, blendMode: "add" }, + browLY: { value: -0.1, blendMode: "override" }, + browRY: { value: -0.1, blendMode: "override" }, + }, + fadeIn: 400, + }, + }, + filters: ["happy-glow"], +}); + +const sadProfile: BehaviorProfile = buildProfile(baseProfile, { + motionLayers: { + expression: { + parameters: { + browLY: { value: 0.3, blendMode: "override" }, + browRY: { value: 0.3, blendMode: "override" }, + mouthForm: { value: -0.3, blendMode: "override" }, + eyeLOpen: { value: 0.8, blendMode: "override" }, + eyeROpen: { value: 0.8, blendMode: "override" }, + }, + fadeIn: 600, + }, + }, + filters: ["morning-cool"], +}); + +const angryProfile: BehaviorProfile = buildProfile(baseProfile, { + motionLayers: { + expression: { + parameters: { + browLY: { value: 0.4, blendMode: "override" }, + browRY: { value: 0.4, blendMode: "override" }, + browLAngle: { value: -0.3, blendMode: "override" }, + browRAngle: { value: 0.3, blendMode: "override" }, + mouthForm: { value: -0.2, blendMode: "override" }, + cheek: { value: 0.15, blendMode: "add" }, + }, + fadeIn: 300, + }, + }, + filters: ["angry-red"], +}); + +const embarrassedProfile: BehaviorProfile = buildProfile(baseProfile, { + motionLayers: { + expression: { + parameters: { + cheek: { value: 0.5, blendMode: "override" }, + eyeLOpen: { value: 0.7, blendMode: "override" }, + eyeROpen: { value: 0.7, blendMode: "override" }, + browLY: { value: 0.1, blendMode: "override" }, + browRY: { value: 0.1, blendMode: "override" }, + mouthSmile: { value: 0.2, blendMode: "override" }, + }, + fadeIn: 500, + }, + }, + filters: ["shy-blush"], +}); + +const thinkingProfile: BehaviorProfile = buildProfile(baseProfile, { + motionLayers: { + expression: { + parameters: { + browLY: { value: -0.2, blendMode: "override" }, + browRY: { value: -0.2, blendMode: "override" }, + eyeLOpen: { value: 0.85, blendMode: "override" }, + eyeROpen: { value: 0.85, blendMode: "override" }, + mouthForm: { value: 0.1, blendMode: "override" }, + }, + fadeIn: 600, + }, + gesture: { + parameters: { + armLA: { value: 0.3, blendMode: "override" }, + }, + fadeIn: 800, + }, + }, +}); + +const talkingProfile: BehaviorProfile = buildProfile(baseProfile, { + motionLayers: { + talk: { + parameters: { + mouthOpen: { value: 0.5, blendMode: "override" }, + mouthForm: { value: 0.2, blendMode: "override" }, + }, + fadeIn: 200, + }, + expression: { + parameters: { + eyeLOpen: { value: 0.9, blendMode: "override" }, + eyeROpen: { value: 0.9, blendMode: "override" }, + }, + fadeIn: 300, + }, + }, +}); + +const sleepyProfile: BehaviorProfile = buildProfile(baseProfile, { + motionLayers: { + expression: { + parameters: { + eyeLOpen: { value: 0.4, blendMode: "override" }, + eyeROpen: { value: 0.4, blendMode: "override" }, + browLY: { value: 0.15, blendMode: "override" }, + browRY: { value: 0.15, blendMode: "override" }, + mouthForm: { value: 0.05, blendMode: "override" }, + }, + fadeIn: 1000, + }, + }, + proceduralOverrides: { + Blink: false, + }, +}); + +// ── Built-in state definitions ──────────────────────────────── + +export const builtInStates: BehaviorState[] = [ + { + name: "idle", + entryProfile: idleProfile, + debounceMs: 0, + }, + { + name: "happy", + entryProfile: happyProfile, + debounceMs: 200, + }, + { + name: "sad", + entryProfile: sadProfile, + debounceMs: 300, + }, + { + name: "angry", + entryProfile: angryProfile, + debounceMs: 300, + }, + { + name: "embarrassed", + entryProfile: embarrassedProfile, + debounceMs: 200, + }, + { + name: "thinking", + entryProfile: thinkingProfile, + debounceMs: 200, + }, + { + name: "talking", + entryProfile: talkingProfile, + debounceMs: 100, + }, + { + name: "sleepy", + entryProfile: sleepyProfile, + debounceMs: 500, + }, +]; + +/** + * Register all built-in states on a BehaviorFSM instance. + */ +export function registerBuiltInStates( + register: (state: BehaviorState) => void, +): void { + for (const state of builtInStates) { + register(state); + } +} diff --git a/packages/live2d/src/runtime/behavior/fsm.ts b/packages/live2d/src/runtime/behavior/fsm.ts new file mode 100644 index 0000000..60c13e6 --- /dev/null +++ b/packages/live2d/src/runtime/behavior/fsm.ts @@ -0,0 +1,293 @@ +import type { + BehaviorState, + BehaviorProfile, + BehaviorContext, + StateName, + BehaviorFSMConfig, +} from "./types"; +import type { LayerName } from "../motion/types"; +import type { EffectPreset } from "../filters/types"; + +export class BehaviorFSM { + private states = new Map(); + private currentState: StateName | null = null; + private context: BehaviorContext; + private config: Required; + private lastTransitionTime = 0; // 0 means no explicit transition has occurred yet + + // Track applied effects so they can be reversed on exit + private activeFilterHandles = new Map(); + private activeMotionLayers = new Set(); + private proceduralModuleStates = new Map(); + + constructor(context: BehaviorContext, config: BehaviorFSMConfig = {}) { + this.context = context; + this.config = { + enabled: config.enabled ?? true, + initialState: config.initialState ?? "idle", + defaultDebounceMs: config.defaultDebounceMs ?? 100, + }; + } + + /** + * Register a behavior state. + */ + registerState(state: BehaviorState): void { + this.states.set(state.name, state); + } + + /** + * Unregister a behavior state. + */ + unregisterState(name: StateName): void { + this.states.delete(name); + } + + /** + * Get the name of the current state, or null if not initialized. + */ + getCurrentState(): StateName | null { + return this.currentState; + } + + /** + * Check if a transition to the target state is possible. + */ + canTransitionTo(target: StateName): boolean { + if (!this.config.enabled) return false; + if (target === this.currentState) return false; + + const targetState = this.states.get(target); + if (!targetState) return false; + + if (this.currentState) { + const currentState = this.states.get(this.currentState); + const guard = + targetState.transitionGuard ?? currentState?.transitionGuard; + if (guard && !guard(this.currentState, target)) { + return false; + } + } + + // Apply debounce only after at least one explicit transition has occurred + if (this.lastTransitionTime > 0) { + const currentState = this.currentState + ? this.states.get(this.currentState) + : undefined; + const exitDebounce = currentState?.debounceMs; + const entryDebounce = targetState.debounceMs; + + // If either state explicitly configures a debounce, use the max of those. + // Otherwise fall back to the global default. + let debounceMs: number; + if (exitDebounce !== undefined || entryDebounce !== undefined) { + debounceMs = Math.max(exitDebounce ?? 0, entryDebounce ?? 0); + } else { + debounceMs = this.config.defaultDebounceMs; + } + + if (Date.now() - this.lastTransitionTime < debounceMs) { + return false; + } + } + + return true; + } + + /** + * Transition to a target state. + * Returns true if the transition succeeded, false otherwise. + */ + transitionTo(target: StateName): boolean { + if (!this.canTransitionTo(target)) { + return false; + } + + const fromStateName = this.currentState; + const fromState = fromStateName + ? this.states.get(fromStateName) + : undefined; + const toState = this.states.get(target); + + if (!toState) return false; + + // 1. Call exit hook on current state + if (fromState?.onExit) { + fromState.onExit(this.context); + } + + // 2. Revert effects from current state + if (fromState?.exitProfile) { + this.revertProfile(fromState.exitProfile); + } else if (fromState?.entryProfile) { + this.revertProfile(fromState.entryProfile); + } + + // 3. Call enter hook on new state + if (toState.onEnter) { + toState.onEnter(this.context); + } + + // 4. Apply entry profile of new state + if (toState.entryProfile) { + this.applyProfile(toState.entryProfile); + } + + this.currentState = target; + // Only record transition time for transitions between actual states + if (fromStateName !== null) { + this.lastTransitionTime = Date.now(); + } + + return true; + } + + /** + * Initialize the FSM by entering the initial state. + * This bypasses transition guards and debounce. + */ + initialize(): void { + const initial = this.config.initialState; + if (!initial || !this.states.has(initial)) { + return; + } + + const state = this.states.get(initial)!; + if (state.onEnter) { + state.onEnter(this.context); + } + if (state.entryProfile) { + this.applyProfile(state.entryProfile); + } + this.currentState = initial; + // Note: we don't set lastTransitionTime here so the first explicit + // transition after initialization is not debounced. + } + + /** + * Get all registered state names. + */ + getRegisteredStates(): StateName[] { + return Array.from(this.states.keys()); + } + + /** + * Check if a state is registered. + */ + hasState(name: StateName): boolean { + return this.states.has(name); + } + + private applyProfile(profile: BehaviorProfile): void { + const { + motionLayerSystem, + filterPipeline, + semanticLayer, + proceduralSystem, + } = this.context; + + if (profile.motionLayers && motionLayerSystem) { + for (const layer of Object.keys(profile.motionLayers) as LayerName[]) { + const effect = profile.motionLayers[layer]; + if (effect) { + motionLayerSystem.play({ + layer, + parameters: effect.parameters, + fadeIn: effect.fadeIn, + }); + this.activeMotionLayers.add(layer); + } + } + } + + if (profile.filters && filterPipeline) { + for (const preset of profile.filters) { + const handle = filterPipeline.applyPreset(preset); + this.activeFilterHandles.set(preset, handle.id); + } + } + + if (profile.semanticParameters && semanticLayer) { + for (const [name, config] of Object.entries(profile.semanticParameters)) { + if (semanticLayer.hasSemantic(name)) { + semanticLayer.setSemantic( + name, + config.value, + config.blendMode ?? "override", + "fsm", + 2, + ); + } + } + } + + if (profile.proceduralOverrides && proceduralSystem) { + for (const [moduleName, enabled] of Object.entries( + profile.proceduralOverrides, + )) { + // Store the original enabled state on first override so we can restore it + if (!this.proceduralModuleStates.has(moduleName)) { + const moduleStatus = proceduralSystem + .getModuleStatuses() + .find((m) => m.name === moduleName); + this.proceduralModuleStates.set( + moduleName, + moduleStatus?.enabled ?? true, + ); + } + if (enabled) { + proceduralSystem.enableModule(moduleName); + } else { + proceduralSystem.disableModule(moduleName); + } + } + } + } + + private revertProfile(profile: BehaviorProfile): void { + const { + motionLayerSystem, + filterPipeline, + semanticLayer, + proceduralSystem, + } = this.context; + + if (profile.motionLayers && motionLayerSystem) { + for (const layer of Object.keys(profile.motionLayers) as LayerName[]) { + motionLayerSystem.stop(layer); + this.activeMotionLayers.delete(layer); + } + } + + if (profile.filters && filterPipeline) { + for (const preset of profile.filters) { + const handleId = this.activeFilterHandles.get(preset); + if (handleId) { + filterPipeline.remove(handleId); + this.activeFilterHandles.delete(preset); + } + } + } + + if (profile.semanticParameters && semanticLayer) { + for (const name of Object.keys(profile.semanticParameters)) { + semanticLayer.setSemantic(name, 0, "override", "fsm", 2); + } + } + + if (profile.proceduralOverrides && proceduralSystem) { + for (const moduleName of Object.keys(profile.proceduralOverrides)) { + const previousState = this.proceduralModuleStates.get(moduleName); + if (previousState !== undefined) { + if (previousState) { + proceduralSystem.enableModule(moduleName); + } else { + proceduralSystem.disableModule(moduleName); + } + // Clean up tracking entry after restoring + this.proceduralModuleStates.delete(moduleName); + } + } + } + } +} diff --git a/packages/live2d/src/runtime/behavior/index.ts b/packages/live2d/src/runtime/behavior/index.ts new file mode 100644 index 0000000..24cf114 --- /dev/null +++ b/packages/live2d/src/runtime/behavior/index.ts @@ -0,0 +1,12 @@ +export { BehaviorFSM } from "./fsm"; +export { mergeProfiles, buildProfile } from "./profile"; +export { builtInStates, registerBuiltInStates } from "./built-in-states"; +export type { + StateName, + MotionLayerEffect, + BehaviorProfile, + BehaviorState, + TransitionGuard, + BehaviorContext, + BehaviorFSMConfig, +} from "./types"; diff --git a/packages/live2d/src/runtime/behavior/profile.ts b/packages/live2d/src/runtime/behavior/profile.ts new file mode 100644 index 0000000..046c7eb --- /dev/null +++ b/packages/live2d/src/runtime/behavior/profile.ts @@ -0,0 +1,87 @@ +import type { BehaviorProfile } from "./types"; +import type { LayerName } from "../motion/types"; + +/** + * Merge two behavior profiles. The override takes precedence. + * For motion layers, parameters are deep-merged. + */ +export function mergeProfiles( + base: BehaviorProfile, + override: BehaviorProfile, +): BehaviorProfile { + return { + motionLayers: mergeMotionLayers(base.motionLayers, override.motionLayers), + filters: mergeFilters(base.filters, override.filters), + semanticParameters: mergeSemanticParameters( + base.semanticParameters, + override.semanticParameters, + ), + proceduralOverrides: { + ...base.proceduralOverrides, + ...override.proceduralOverrides, + }, + }; +} + +type MotionLayerMap = NonNullable; + +function mergeMotionLayers( + base?: MotionLayerMap, + override?: MotionLayerMap, +): MotionLayerMap | undefined { + if (!base) return override; + if (!override) return base; + + const result: MotionLayerMap = { ...base }; + for (const layer of Object.keys(override) as LayerName[]) { + const effect = override[layer]; + const baseEffect = result[layer]; + if (baseEffect && effect) { + result[layer] = { + ...baseEffect, + ...effect, + parameters: { + ...baseEffect.parameters, + ...effect.parameters, + }, + }; + } else { + result[layer] = effect; + } + } + return result; +} + +function mergeFilters( + base?: BehaviorProfile["filters"], + override?: BehaviorProfile["filters"], +): BehaviorProfile["filters"] { + if (!base) return override; + if (!override) return base; + return [...base, ...override]; +} + +function mergeSemanticParameters( + base?: BehaviorProfile["semanticParameters"], + override?: BehaviorProfile["semanticParameters"], +): BehaviorProfile["semanticParameters"] { + if (!base) return override; + if (!override) return base; + return { ...base, ...override }; +} + +/** + * Build a behavior state profile from a base profile and optional overrides. + */ +export function buildProfile( + base: BehaviorProfile, + ...overrides: (BehaviorProfile | undefined)[] +): BehaviorProfile { + let result = base; + for (const override of overrides) { + if (override) { + result = mergeProfiles(result, override); + } + } + return result; +} diff --git a/packages/live2d/src/runtime/behavior/types.ts b/packages/live2d/src/runtime/behavior/types.ts new file mode 100644 index 0000000..0ddf9bc --- /dev/null +++ b/packages/live2d/src/runtime/behavior/types.ts @@ -0,0 +1,50 @@ +import type { LayerName } from "../motion/types"; +import type { EffectPreset } from "../filters/types"; +import type { MotionLayerSystem } from "../motion"; +import type { FilterPipeline } from "../filters"; +import type { SemanticParameterLayer } from "../semantic"; +import type { ProceduralAnimationSystem } from "../procedural"; + +export type StateName = string; + +export type ParameterBlendMode = "override" | "add"; + +export interface MotionLayerEffect { + parameters: Record; + fadeIn?: number; +} + +export interface BehaviorProfile { + motionLayers?: Partial>; + filters?: EffectPreset[]; + semanticParameters?: Record< + string, + { value: number; blendMode?: ParameterBlendMode } + >; + proceduralOverrides?: Record; +} + +export interface BehaviorState { + name: StateName; + entryProfile?: BehaviorProfile; + exitProfile?: BehaviorProfile; + onEnter?: (context: BehaviorContext) => void; + onExit?: (context: BehaviorContext) => void; + transitionGuard?: TransitionGuard; + debounceMs?: number; +} + +export type TransitionGuard = (from: StateName, to: StateName) => boolean; + +export interface BehaviorContext { + motionLayerSystem?: MotionLayerSystem; + filterPipeline?: FilterPipeline; + semanticLayer?: SemanticParameterLayer; + proceduralSystem?: ProceduralAnimationSystem; +} + +export interface BehaviorFSMConfig { + enabled?: boolean; + initialState?: StateName; + defaultDebounceMs?: number; +} diff --git a/packages/live2d/src/runtime/controller/__tests__/controller.test.ts b/packages/live2d/src/runtime/controller/__tests__/controller.test.ts new file mode 100644 index 0000000..6cd2522 --- /dev/null +++ b/packages/live2d/src/runtime/controller/__tests__/controller.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from "vitest"; +import { Live2dRuntimeController } from "../controller"; +import type { BehaviorFSM } from "../../behavior"; + +describe("Live2dRuntimeController", () => { + it("creates with default config", () => { + const controller = new Live2dRuntimeController(); + expect(controller.getSemanticLayer()).toBeDefined(); + expect(controller.getFilterPipeline()).toBeDefined(); + }); + + it("exposes subsystem accessors", () => { + const controller = new Live2dRuntimeController(); + expect(controller.getBehaviorFSM()).toBeUndefined(); + expect(controller.getEmotionTimeline()).toBeUndefined(); + expect(controller.getMotionLayerSystem()).toBeUndefined(); + expect(controller.getProceduralSystem()).toBeUndefined(); + }); + + it("transitionTo delegates to subsystems", () => { + const controller = new Live2dRuntimeController(); + + // Before initialization, subsystems are not created + controller.transitionTo({ fsm: "happy" }); + // Should not throw + + controller.transitionTo({ emotion: "happy" }); + // Should not throw + }); + + it("getState returns default state before initialization", () => { + const controller = new Live2dRuntimeController(); + const state = controller.getState(); + + expect(state.fsmState).toBeNull(); + expect(state.emotion).toBeNull(); + expect(state.isTransitioning).toBe(false); + expect(state.motionLayers).toEqual([]); + expect(state.proceduralModules).toEqual([]); + }); + + it("getConflictLog returns empty array initially", () => { + const controller = new Live2dRuntimeController(); + expect(controller.getConflictLog()).toEqual([]); + }); + + it("clearConflictLog empties the log", () => { + const controller = new Live2dRuntimeController(); + controller.clearConflictLog(); + expect(controller.getConflictLog()).toEqual([]); + }); + + it("getSemanticParameters returns empty before detection", () => { + const controller = new Live2dRuntimeController(); + expect(controller.getSemanticParameters()).toEqual([]); + }); + + it("destroy cleans up without error", () => { + const controller = new Live2dRuntimeController(); + controller.destroy(); + // Should not throw + }); + + it("disabled controller ignores transitions", () => { + const controller = new Live2dRuntimeController({ enabled: false }); + controller.transitionTo({ fsm: "happy" }); + const state = controller.getState(); + expect(state.fsmState).toBeNull(); + }); + + it("transitionTo applies filter preset", () => { + const controller = new Live2dRuntimeController(); + controller.transitionTo({ filter: "happy-glow" }); + const state = controller.getState(); + expect(state.activeFilters.length).toBe(1); + expect(state.activeFilters[0].type).toBe("mood-lighting"); + }); + + it("coordinated transition sets FSM state, emotion, and filter atomically", () => { + const controller = new Live2dRuntimeController(); + controller.transitionTo({ fsm: "happy", emotion: "happy", filter: "shy-blush" }); + // FSM and emotion require initialization (model + ticker) + // Filter is applied directly via filterPipeline + const state = controller.getState(); + expect(state.activeFilters.length).toBe(1); + expect(state.activeFilters[0].type).toBe("color-grading"); + }); + + it("getTransitionHistory tracks transitions", () => { + const controller = new Live2dRuntimeController(); + // Inject a mock FSM to test transition history + let currentState = "idle"; + const mockFSM = { + getCurrentState: vi.fn().mockImplementation(() => currentState), + canTransitionTo: () => true, + transitionTo: vi.fn().mockImplementation((state: string) => { + currentState = state; + return true; + }), + initialize: vi.fn(), + registerState: vi.fn(), + unregisterState: vi.fn(), + getRegisteredStates: () => ["idle", "happy", "thinking"], + hasState: () => true, + } as unknown as BehaviorFSM; + + (controller as unknown as Record)["behaviorFSM"] = mockFSM; + + controller.transitionTo({ fsm: "happy" }); + controller.transitionTo({ fsm: "thinking" }); + + const history = controller.getTransitionHistory(); + expect(history.length).toBe(2); + expect(history[0].from).toBe("idle"); + expect(history[0].to).toBe("happy"); + expect(history[1].from).toBe("happy"); + expect(history[1].to).toBe("thinking"); + }); + + it("transition history is capped at 10 entries", () => { + const controller = new Live2dRuntimeController(); + const mockFSM = { + getCurrentState: vi.fn().mockReturnValue("idle"), + canTransitionTo: () => true, + transitionTo: vi.fn().mockReturnValue(true), + initialize: vi.fn(), + registerState: vi.fn(), + unregisterState: vi.fn(), + getRegisteredStates: () => ["idle", "happy", "sad"], + hasState: () => true, + } as unknown as BehaviorFSM; + + (controller as unknown as Record)["behaviorFSM"] = mockFSM; + + for (let i = 0; i < 15; i++) { + controller.transitionTo({ fsm: i % 2 === 0 ? "happy" : "sad" }); + } + const history = controller.getTransitionHistory(); + expect(history.length).toBe(10); + }); + + it("getSemanticParameters returns empty before detection", () => { + const controller = new Live2dRuntimeController(); + expect(controller.getSemanticParameters()).toEqual([]); + }); + + it("destroy cleans up without error", () => { + const controller = new Live2dRuntimeController(); + controller.destroy(); + }); +}); diff --git a/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts b/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts new file mode 100644 index 0000000..e75cba4 --- /dev/null +++ b/packages/live2d/src/runtime/controller/__tests__/coordinator.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { ParameterCoordinator } from "../coordinator"; +import { SemanticParameterLayer } from "../../semantic"; +import { SystemPriority } from "../types"; + +function createMockSemanticLayer(): SemanticParameterLayer { + const layer = new SemanticParameterLayer(); + (layer as unknown as Record).resolved = new Map([ + ["mouthOpen", { id: "PARAM_MOUTH_OPEN", index: 0 }], + ["angleX", { id: "PARAM_ANGLE_X", index: 1 }], + ["eyeLOpen", { id: "PARAM_EYE_L_OPEN", index: 2 }], + ]); + const setValueMock = vi.fn(); + (layer as unknown as Record).accessor = { + getValue: () => 0, + setValue: setValueMock, + getMin: () => -30, + getMax: () => 30, + }; + return layer; +} + +function getAccessor(layer: SemanticParameterLayer): { setValue: ReturnType } { + return (layer as unknown as Record).accessor as { setValue: ReturnType }; +} + +describe("ParameterCoordinator", () => { + let semanticLayer: SemanticParameterLayer; + let coordinator: ParameterCoordinator; + + beforeEach(() => { + semanticLayer = createMockSemanticLayer(); + coordinator = new ParameterCoordinator(semanticLayer); + semanticLayer.setCoordinator(coordinator); + }); + + describe("queueWrite", () => { + it("collects writes per parameter", () => { + coordinator.queueWrite("mouthOpen", 0.5, "override", "fsm", SystemPriority.FSM); + coordinator.queueWrite("mouthOpen", 0.8, "override", "emotion", SystemPriority.EMOTION); + + // Before flush, accessor should not be called + const accessor = getAccessor(semanticLayer); + expect(accessor.setValue).not.toHaveBeenCalled(); + }); + + it("applies single write on flush", () => { + coordinator.queueWrite("mouthOpen", 0.5, "override", "fsm", SystemPriority.FSM); + coordinator.flush(); + + const accessor = getAccessor(semanticLayer); + expect(accessor.setValue).toHaveBeenCalledWith(0, 0.5); + }); + }); + + describe("conflict detection", () => { + it("logs conflict when two override sources write same parameter", () => { + coordinator.queueWrite("mouthOpen", 0.5, "override", "fsm", SystemPriority.FSM); + coordinator.queueWrite("mouthOpen", 0.8, "override", "emotion", SystemPriority.EMOTION); + coordinator.flush(); + + const log = coordinator.getConflictLog(); + expect(log.length).toBe(1); + expect(log[0].parameter).toBe("mouthOpen"); + expect(log[0].winningSystem).toBe("fsm"); + expect(log[0].losingSystem).toBe("emotion"); + expect(log[0].winningValue).toBe(0.5); + expect(log[0].losingValue).toBe(0.8); + }); + + it("highest priority wins (lowest number)", () => { + // MANUAL=1 should win over all others + coordinator.queueWrite("mouthOpen", 0.9, "override", "manual", SystemPriority.MANUAL); + coordinator.queueWrite("mouthOpen", 0.5, "override", "fsm", SystemPriority.FSM); + coordinator.queueWrite("mouthOpen", 0.3, "override", "emotion", SystemPriority.EMOTION); + coordinator.flush(); + + const accessor = getAccessor(semanticLayer); + expect(accessor.setValue).toHaveBeenCalledWith(0, 0.9); + + const log = coordinator.getConflictLog(); + // Two conflicts: manual wins over fsm, manual wins over emotion + expect(log.length).toBe(2); + expect(log[0].winningSystem).toBe("manual"); + expect(log[1].winningSystem).toBe("manual"); + }); + + it("does not log conflict for single source", () => { + coordinator.queueWrite("mouthOpen", 0.5, "override", "fsm", SystemPriority.FSM); + coordinator.flush(); + + expect(coordinator.getConflictLog()).toEqual([]); + }); + + it("does not log conflict for add blend mode", () => { + coordinator.queueWrite("mouthOpen", 0.5, "add", "fsm", SystemPriority.FSM); + coordinator.queueWrite("mouthOpen", 0.3, "add", "emotion", SystemPriority.EMOTION); + coordinator.flush(); + + // Add writes don't conflict, they accumulate + expect(coordinator.getConflictLog()).toEqual([]); + + const accessor = getAccessor(semanticLayer); + expect(accessor.setValue).toHaveBeenCalledWith(0, 0.8); // 0.5 + 0.3 + }); + + it("combines override and add correctly", () => { + coordinator.queueWrite("mouthOpen", 0.5, "override", "fsm", SystemPriority.FSM); + coordinator.queueWrite("mouthOpen", 0.2, "add", "procedural", SystemPriority.PROCEDURAL); + coordinator.flush(); + + // Override wins, then add is added to it + const accessor = getAccessor(semanticLayer); + expect(accessor.setValue).toHaveBeenCalledWith(0, 0.7); // 0.5 + 0.2 + }); + + it("add-only resolves to sum without override", () => { + coordinator.queueWrite("mouthOpen", 0.3, "add", "procedural", SystemPriority.PROCEDURAL); + coordinator.queueWrite("mouthOpen", 0.4, "add", "motion", SystemPriority.MOTION); + coordinator.flush(); + + const accessor = getAccessor(semanticLayer); + expect(accessor.setValue).toHaveBeenCalledWith(0, 0.7); + }); + }); + + describe("conflict log management", () => { + it("trims log to max size", () => { + const smallCoordinator = new ParameterCoordinator(semanticLayer, { maxLogSize: 3 }); + semanticLayer.setCoordinator(smallCoordinator); + + for (let i = 0; i < 5; i++) { + smallCoordinator.queueWrite("mouthOpen", i * 0.1, "override", `fsm-${i}`, SystemPriority.FSM); + } + smallCoordinator.flush(); + + const log = smallCoordinator.getConflictLog(); + expect(log.length).toBe(3); + }); + + it("clears log on request", () => { + coordinator.queueWrite("mouthOpen", 0.5, "override", "fsm", SystemPriority.FSM); + coordinator.queueWrite("mouthOpen", 0.8, "override", "emotion", SystemPriority.EMOTION); + coordinator.flush(); + + expect(coordinator.getConflictLog().length).toBe(1); + + coordinator.clearConflictLog(); + expect(coordinator.getConflictLog()).toEqual([]); + }); + + it("returns a copy of the log", () => { + coordinator.queueWrite("mouthOpen", 0.5, "override", "fsm", SystemPriority.FSM); + coordinator.queueWrite("mouthOpen", 0.8, "override", "emotion", SystemPriority.EMOTION); + coordinator.flush(); + + const log1 = coordinator.getConflictLog(); + log1.push({} as never); + const log2 = coordinator.getConflictLog(); + expect(log2.length).toBe(1); // Original log unchanged + }); + }); + + describe("per-frame isolation", () => { + it("clears queue after flush", () => { + coordinator.queueWrite("mouthOpen", 0.5, "override", "fsm", SystemPriority.FSM); + coordinator.flush(); + + const accessor = getAccessor(semanticLayer); + const callCount = accessor.setValue.mock.calls.length; + + // Second flush should not apply anything new + coordinator.flush(); + expect(accessor.setValue).toHaveBeenCalledTimes(callCount); + }); + + it("handles multiple parameters independently", () => { + coordinator.queueWrite("mouthOpen", 0.5, "override", "fsm", SystemPriority.FSM); + coordinator.queueWrite("angleX", 10, "override", "emotion", SystemPriority.EMOTION); + coordinator.flush(); + + const accessor = getAccessor(semanticLayer); + expect(accessor.setValue).toHaveBeenCalledWith(0, 0.5); + expect(accessor.setValue).toHaveBeenCalledWith(1, 10); + }); + }); +}); diff --git a/packages/live2d/src/runtime/controller/controller.ts b/packages/live2d/src/runtime/controller/controller.ts new file mode 100644 index 0000000..0b9b698 --- /dev/null +++ b/packages/live2d/src/runtime/controller/controller.ts @@ -0,0 +1,323 @@ +import { SemanticParameterLayer } from "../semantic"; +import { FilterPipeline } from "../filters"; +import { MotionLayerSystem } from "../motion"; +import { ProceduralAnimationSystem } from "../procedural"; +import { BehaviorFSM } from "../behavior"; +import { registerBuiltInStates } from "../behavior"; +import { EmotionTimeline } from "../emotion"; +import { EMOTION_REGISTRY } from "../emotion"; +import type { Live2DModel } from "untitled-pixi-live2d-engine"; +import type { Ticker } from "pixi.js"; +import type { + ControllerConfig, + ControllerState, + ConflictEntry, + TransitionOptions, +} from "./types"; +import { ParameterCoordinator } from "./coordinator"; + +export class Live2dRuntimeController { + private semanticLayer: SemanticParameterLayer; + private filterPipeline: FilterPipeline; + private proceduralSystem?: ProceduralAnimationSystem; + private motionLayerSystem?: MotionLayerSystem; + private behaviorFSM?: BehaviorFSM; + private emotionTimeline?: EmotionTimeline; + private config: ControllerConfig & { enabled: boolean; filterQuality: "low" | "medium" | "high" }; + private coordinator: ParameterCoordinator; + private transitionHistory: Array<{ + from: string; + to: string; + timestamp: number; + }> = []; + private _tickerCallbacks: Array<() => void> = []; + + constructor(config: ControllerConfig = {}) { + this.config = { + enabled: config.enabled ?? true, + behaviorFSM: config.behaviorFSM, + emotionTimeline: config.emotionTimeline, + motionLayers: config.motionLayers, + proceduralAnimation: config.proceduralAnimation, + filterQuality: config.filterQuality ?? "high", + }; + + this.semanticLayer = new SemanticParameterLayer(); + this.coordinator = new ParameterCoordinator(this.semanticLayer); + this.semanticLayer.setCoordinator(this.coordinator); + this.filterPipeline = new FilterPipeline({ + quality: this.config.filterQuality, + }); + } + + /** + * Initialize all runtime subsystems after a Live2D model is loaded. + */ + initialize(model: Live2DModel, ticker: Ticker): void { + if (!this.config.enabled) return; + + // 1. Semantic parameter detection + this.semanticLayer.detectFromModel(model); + + // 2. Motion layer system + if (this.config.motionLayers?.enabled !== false) { + this.motionLayerSystem = new MotionLayerSystem( + this.semanticLayer, + this.config.motionLayers, + ); + } + + // 3. Procedural animation system + this.proceduralSystem = new ProceduralAnimationSystem( + this.semanticLayer, + this.config.proceduralAnimation, + ); + + if (this.motionLayerSystem) { + this.proceduralSystem.setOutputCallback((params) => { + const parameters: Record< + string, + { value: number; blendMode?: "override" | "add" } + > = {}; + for (const { semantic, value, blendMode } of params) { + parameters[semantic] = { value, blendMode }; + } + this.motionLayerSystem?.setPhysicsParameters(parameters); + }); + } + + this.proceduralSystem.attachTo(ticker); + + // 4. Motion layer ticker + if (this.motionLayerSystem) { + const motionTicker = () => { + this.motionLayerSystem?.update(Math.min(ticker.deltaMS, 100)); + }; + ticker.add(motionTicker); + this._tickerCallbacks.push(() => ticker.remove(motionTicker)); + } + + // 5. Behavior FSM + if (this.config.behaviorFSM?.enabled !== false) { + this.behaviorFSM = new BehaviorFSM( + { + motionLayerSystem: this.motionLayerSystem, + filterPipeline: this.filterPipeline, + semanticLayer: this.semanticLayer, + proceduralSystem: this.proceduralSystem, + }, + this.config.behaviorFSM, + ); + registerBuiltInStates((state) => this.behaviorFSM!.registerState(state)); + this.behaviorFSM.initialize(); + } + + // 6. Emotion timeline + if (this.config.emotionTimeline?.enabled !== false) { + this.emotionTimeline = new EmotionTimeline( + { + semanticLayer: this.semanticLayer, + filterPipeline: this.filterPipeline, + motionLayerSystem: this.motionLayerSystem, + }, + this.config.emotionTimeline, + ); + + for (const [name, profile] of Object.entries(EMOTION_REGISTRY)) { + this.emotionTimeline.registerEmotion(name, profile); + } + + const emotionTicker = () => { + this.emotionTimeline?.update(); + }; + ticker.add(emotionTicker); + this._tickerCallbacks.push(() => ticker.remove(emotionTicker)); + } + + // 7. Hook engine's internalModel.update so our parameter flush runs AFTER + // engine auto-updates (physics, blink, expression, idle motion). + // This ensures manual effects override engine values instead of being overwritten. + const internalModel = this.extractInternalModel(model); + if (internalModel) { + const originalUpdate = internalModel.update.bind(internalModel); + internalModel.update = (dt: number, now?: number) => { + originalUpdate(dt, now); + this.coordinator.flush(); + }; + this._tickerCallbacks.push(() => { + internalModel.update = originalUpdate; + }); + } + + // Attach filter pipeline to model + this.filterPipeline.attachTo(model); + } + + /** + * Unified transition API — trigger FSM state, emotion, and/or filter atomically. + */ + transitionTo(options: TransitionOptions): void { + if (!this.config.enabled) return; + + const { fsm, emotion, filter, duration } = options; + + if (fsm && this.behaviorFSM) { + const from = this.behaviorFSM.getCurrentState() ?? "none"; + const success = this.behaviorFSM.transitionTo(fsm); + if (success) { + this.transitionHistory.push({ + from, + to: fsm, + timestamp: Date.now(), + }); + // Keep only last 10 + if (this.transitionHistory.length > 10) { + this.transitionHistory.shift(); + } + } + } + + if (emotion && this.emotionTimeline) { + this.emotionTimeline.transitionTo(emotion, { duration }); + } + + if (filter) { + this.filterPipeline.applyPreset(filter); + } + } + + /** + * Get composite state snapshot for DevTools display. + */ + getState(): ControllerState { + return { + fsmState: this.behaviorFSM?.getCurrentState() ?? null, + emotion: this.emotionTimeline?.getCurrentEmotion() ?? null, + isTransitioning: this.emotionTimeline?.isTransitioning() ?? false, + transitionProgress: this.getTransitionProgress(), + activeFilters: this.getActiveFilterDetails(), + motionLayers: this.getMotionLayerStatuses(), + proceduralModules: this.getProceduralModuleStatuses(), + }; + } + + /** + * Get the conflict log for DevTools display. + */ + getConflictLog(): ConflictEntry[] { + return this.coordinator.getConflictLog(); + } + + /** + * Get transition history for DevTools display. + */ + getTransitionHistory(): Array<{ from: string; to: string; timestamp: number }> { + return [...this.transitionHistory]; + } + + /** + * Clear the conflict log. + */ + clearConflictLog(): void { + this.coordinator.clearConflictLog(); + } + + /** + * Get current semantic parameter values for DevTools display. + */ + getSemanticParameters(): Array<{ name: string; value: number | undefined }> { + const profile = this.semanticLayer.getCapabilityProfile(); + const result: Array<{ name: string; value: number | undefined }> = []; + for (const name of profile.detected.keys()) { + result.push({ name, value: this.semanticLayer.getSemantic(name) }); + } + return result; + } + + // ── Subsystem accessors ───────────────────────────────────────── + + getSemanticLayer(): SemanticParameterLayer { + return this.semanticLayer; + } + + getFilterPipeline(): FilterPipeline { + return this.filterPipeline; + } + + getProceduralSystem(): ProceduralAnimationSystem | undefined { + return this.proceduralSystem; + } + + getMotionLayerSystem(): MotionLayerSystem | undefined { + return this.motionLayerSystem; + } + + getBehaviorFSM(): BehaviorFSM | undefined { + return this.behaviorFSM; + } + + getEmotionTimeline(): EmotionTimeline | undefined { + return this.emotionTimeline; + } + + // ── Cleanup ───────────────────────────────────────────────────── + + destroy(ticker?: Ticker): void { + // Remove ticker callbacks + for (const remove of this._tickerCallbacks) { + remove(); + } + this._tickerCallbacks = []; + + this.proceduralSystem?.detach(ticker); + this.emotionTimeline?.destroy(); + this.filterPipeline.detach(); + } + + // ── Private helpers ───────────────────────────────────────────── + + private extractInternalModel( + model: Live2DModel, + ): { update(dt: number, now?: number): void } | undefined { + const record = model as unknown as Record; + const internalModel = record.internalModel as + | { update(dt: number, now?: number): void } + | undefined; + return internalModel; + } + + private getTransitionProgress(): number { + return this.emotionTimeline?.getTransitionProgress() ?? 0; + } + + private getActiveFilterDetails(): Array<{ id: string; name: string; type: string; intensity: number }> { + return this.filterPipeline.getActiveEffects().map((e) => ({ + id: e.id, + name: e.name, + type: e.type, + intensity: e.intensity, + })); + } + + private getMotionLayerStatuses(): Array<{ + name: string; + state: string; + weight: number; + priority: number; + }> { + if (!this.motionLayerSystem) return []; + return this.motionLayerSystem.getLayerStatuses().map((s) => ({ + name: s.name, + state: s.state, + weight: s.weight, + priority: s.priority, + })); + } + + private getProceduralModuleStatuses(): Array<{ + name: string; + enabled: boolean; + }> { + return this.proceduralSystem?.getModuleStatuses() ?? []; + } +} diff --git a/packages/live2d/src/runtime/controller/coordinator.ts b/packages/live2d/src/runtime/controller/coordinator.ts new file mode 100644 index 0000000..595dbcf --- /dev/null +++ b/packages/live2d/src/runtime/controller/coordinator.ts @@ -0,0 +1,128 @@ +import type { SemanticParameterLayer } from "../semantic"; +import type { BlendMode } from "../semantic/types"; +import type { SystemPriority, ConflictEntry } from "./types"; + +interface QueuedWrite { + parameter: string; + value: number; + blendMode: BlendMode; + source: string; + priority: SystemPriority; +} + +export class ParameterCoordinator { + private queue = new Map(); + private conflictLog: ConflictEntry[] = []; + private semanticLayer: SemanticParameterLayer; + private maxLogSize: number; + + constructor( + semanticLayer: SemanticParameterLayer, + options: { maxLogSize?: number } = {}, + ) { + this.semanticLayer = semanticLayer; + this.maxLogSize = options.maxLogSize ?? 50; + } + + /** + * Queue a parameter write. Writes are not applied until flush() is called. + */ + queueWrite( + parameter: string, + value: number, + blendMode: BlendMode, + source: string, + priority: SystemPriority, + ): void { + const list = this.queue.get(parameter) ?? []; + list.push({ parameter, value, blendMode, source, priority }); + this.queue.set(parameter, list); + } + + /** + * Resolve all queued writes, detect conflicts, and apply to semantic layer. + * Should be called once per frame after all subsystems have queued writes. + */ + flush(): void { + for (const [parameter, writes] of this.queue) { + this.resolveParameter(parameter, writes); + } + this.queue.clear(); + } + + /** + * Get the current conflict log. + */ + getConflictLog(): ConflictEntry[] { + return [...this.conflictLog]; + } + + /** + * Clear the conflict log. + */ + clearConflictLog(): void { + this.conflictLog = []; + } + + private resolveParameter(parameter: string, writes: QueuedWrite[]): void { + const overrides = writes.filter((w) => w.blendMode === "override"); + const adds = writes.filter((w) => w.blendMode === "add"); + + // Resolve override conflicts: lowest priority number wins (MANUAL=1 is highest) + let finalValue = 0; + let hasOverride = false; + let winner: QueuedWrite | null = null; + + if (overrides.length > 0) { + winner = overrides.reduce((a, b) => (a.priority <= b.priority ? a : b)); + finalValue = winner.value; + hasOverride = true; + + // Log conflicts from other override sources + for (const w of overrides) { + if (w !== winner) { + this.logConflict(parameter, winner, w); + } + } + } + + // Sum all add outputs (adds don't conflict, they accumulate) + if (adds.length > 0) { + const sum = adds.reduce((s, w) => s + w.value, 0); + if (hasOverride) { + finalValue += sum; + } else { + finalValue = sum; + } + } + + // Temporarily detach coordinator to prevent recursive queuing. + // setSemantic would otherwise re-queue through the coordinator. + this.semanticLayer.setCoordinator(undefined); + try { + this.semanticLayer.setSemantic(parameter, finalValue, hasOverride ? "override" : "add"); + } finally { + this.semanticLayer.setCoordinator(this); + } + } + + private logConflict( + parameter: string, + winner: QueuedWrite, + loser: QueuedWrite, + ): void { + this.conflictLog.push({ + timestamp: Date.now(), + parameter, + winningSystem: winner.source, + losingSystem: loser.source, + winningValue: winner.value, + losingValue: loser.value, + }); + + // Trim log to max size + if (this.conflictLog.length > this.maxLogSize) { + this.conflictLog.shift(); + } + } +} diff --git a/packages/live2d/src/runtime/controller/index.ts b/packages/live2d/src/runtime/controller/index.ts new file mode 100644 index 0000000..ea0d515 --- /dev/null +++ b/packages/live2d/src/runtime/controller/index.ts @@ -0,0 +1,10 @@ +export { Live2dRuntimeController } from "./controller"; +export { ParameterCoordinator } from "./coordinator"; +export { SystemPriority } from "./types"; +export type { + ControllerConfig, + ControllerState, + ConflictEntry, + ParameterWrite, + TransitionOptions, +} from "./types"; diff --git a/packages/live2d/src/runtime/controller/types.ts b/packages/live2d/src/runtime/controller/types.ts new file mode 100644 index 0000000..baaee96 --- /dev/null +++ b/packages/live2d/src/runtime/controller/types.ts @@ -0,0 +1,110 @@ +import type { EffectPreset } from "../filters/types"; +import type { EasingName } from "../emotion/types"; + +export enum SystemPriority { + MANUAL = 1, + FSM = 2, + EMOTION = 3, + MOTION = 4, + PROCEDURAL = 5, +} + +export interface ParameterWrite { + parameter: string; + value: number; + blendMode: "override" | "add"; + source: string; + priority: SystemPriority; +} + +export interface ConflictEntry { + timestamp: number; + parameter: string; + winningSystem: string; + losingSystem: string; + winningValue: number; + losingValue: number; +} + +export interface ControllerConfig { + enabled?: boolean; + devTools?: { + enabled?: boolean; + }; + behaviorFSM?: { + enabled?: boolean; + initialState?: string; + defaultDebounceMs?: number; + }; + emotionTimeline?: { + enabled?: boolean; + defaultDuration?: number; + minDuration?: number; + defaultEasing?: EasingName; + idleReturnDelay?: number; + }; + motionLayers?: { + enabled?: boolean; + layers?: { + idle?: { priority?: number }; + expression?: { priority?: number }; + talk?: { priority?: number }; + gesture?: { priority?: number }; + physics?: { priority?: number }; + }; + defaultCrossfadeDuration?: number; + }; + proceduralAnimation?: { + enabled?: boolean; + breathing?: { + enabled?: boolean; + period?: number; + amplitude?: number; + }; + blink?: { + enabled?: boolean; + minInterval?: number; + maxInterval?: number; + duration?: number; + }; + eyeTracking?: { + enabled?: boolean; + maxAngleX?: number; + maxAngleY?: number; + maxEyeBallX?: number; + maxEyeBallY?: number; + smoothing?: number; + }; + }; + filterQuality?: "low" | "medium" | "high"; +} + +export interface ControllerState { + fsmState: string | null; + emotion: string | null; + isTransitioning: boolean; + transitionProgress: number; + activeFilters: Array<{ + id: string; + name: string; + type: string; + intensity: number; + }>; + motionLayers: Array<{ + name: string; + state: string; + weight: number; + priority: number; + }>; + proceduralModules: Array<{ + name: string; + enabled: boolean; + }>; +} + +export interface TransitionOptions { + fsm?: string; + emotion?: string; + filter?: EffectPreset; + duration?: number; +} diff --git a/packages/live2d/src/runtime/emotion/__tests__/emotion-timeline.test.ts b/packages/live2d/src/runtime/emotion/__tests__/emotion-timeline.test.ts new file mode 100644 index 0000000..f1ad8e0 --- /dev/null +++ b/packages/live2d/src/runtime/emotion/__tests__/emotion-timeline.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { EmotionTimeline } from "../timeline"; +import { EMOTION_REGISTRY } from "../registry"; +import type { EmotionTimelineContext } from "../types"; + +describe("EmotionTimeline", () => { + function createMockContext(): EmotionTimelineContext { + return { + semanticLayer: { + setSemantic: vi.fn(), + getSemantic: vi.fn(() => 0), + hasSemantic: vi.fn(() => true), + } as unknown as NonNullable, + filterPipeline: { + applyPreset: vi.fn(() => ({ id: "fx-test" })), + remove: vi.fn(), + setIntensity: vi.fn(), + } as unknown as NonNullable, + motionLayerSystem: { + play: vi.fn(), + } as unknown as NonNullable, + }; + } + + function registerDefaults(timeline: EmotionTimeline): void { + for (const [name, profile] of Object.entries(EMOTION_REGISTRY)) { + timeline.registerEmotion(name, profile); + } + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("registration", () => { + it("registers and retrieves emotion profiles", () => { + const timeline = new EmotionTimeline(createMockContext()); + timeline.registerEmotion("test", { + parameters: { mouthOpen: 0.5 }, + }); + + const profile = timeline.getEmotionProfile("test"); + expect(profile).toBeDefined(); + expect(profile?.parameters.mouthOpen).toBe(0.5); + }); + }); + + describe("transitionTo", () => { + it("transitions to a registered emotion", () => { + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx); + registerDefaults(timeline); + + timeline.transitionTo("happy"); + expect(timeline.getCurrentEmotion()).toBe("neutral"); + expect(timeline.isTransitioning()).toBe(true); + }); + + it("is a no-op for unknown emotions", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx); + registerDefaults(timeline); + + timeline.transitionTo("nonexistent"); + expect(timeline.isTransitioning()).toBe(false); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it("is a no-op when transitioning to the same emotion", () => { + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx, { + defaultDuration: 100, + minDuration: 0, + }); + registerDefaults(timeline); + + timeline.transitionTo("happy"); + vi.advanceTimersByTime(150); + timeline.update(); + expect(timeline.getCurrentEmotion()).toBe("happy"); + expect(timeline.isTransitioning()).toBe(false); + + // Same emotion, no transition + timeline.transitionTo("happy"); + expect(timeline.isTransitioning()).toBe(false); + }); + + it("enforces minimum transition duration", () => { + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx, { + defaultDuration: 50, + minDuration: 300, + }); + registerDefaults(timeline); + + timeline.transitionTo("happy", { duration: 50 }); + + // At 100ms (past requested 50ms but before min 300ms) + vi.advanceTimersByTime(100); + timeline.update(); + expect(timeline.isTransitioning()).toBe(true); + + // At 350ms (past min 300ms) + vi.advanceTimersByTime(250); + timeline.update(); + expect(timeline.isTransitioning()).toBe(false); + expect(timeline.getCurrentEmotion()).toBe("happy"); + }); + }); + + describe("interpolation", () => { + it("interpolates parameter values during transition", () => { + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx, { + defaultDuration: 100, + minDuration: 0, + defaultEasing: "linear", + }); + registerDefaults(timeline); + + timeline.transitionTo("happy"); + vi.advanceTimersByTime(50); + timeline.update(); + + // Should have called motion layer play with interpolated values + expect(ctx.motionLayerSystem!.play).toHaveBeenCalled(); + }); + + it("uses current value as start when interrupted", () => { + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx, { + defaultDuration: 200, + minDuration: 0, + defaultEasing: "linear", + }); + registerDefaults(timeline); + + // Start transition to happy + timeline.transitionTo("happy"); + vi.advanceTimersByTime(100); + timeline.update(); + + const callsBefore = (ctx.motionLayerSystem!.play as ReturnType) + .mock.calls.length; + + // Interrupt with sad at 100ms (mid-transition) + timeline.transitionTo("sad"); + vi.advanceTimersByTime(10); + timeline.update(); + + const callsAfter = (ctx.motionLayerSystem!.play as ReturnType) + .mock.calls.length; + expect(callsAfter).toBeGreaterThan(callsBefore); + }); + + it("interpolates correctly at 50% progress", () => { + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx, { + defaultDuration: 200, + minDuration: 0, + defaultEasing: "linear", + }); + registerDefaults(timeline); + + timeline.transitionTo("happy"); + vi.advanceTimersByTime(100); + timeline.update(); + + // At 50% linear interpolation, mouthSmile should be ~0.3 + const calls = (ctx.motionLayerSystem!.play as ReturnType) + .mock.calls; + const lastCall = calls[calls.length - 1]; + const params = lastCall?.[0].parameters as Record< + string, + { value: number } + >; + expect(params?.mouthSmile?.value).toBeGreaterThan(0.2); + expect(params?.mouthSmile?.value).toBeLessThan(0.4); + }); + }); + + describe("filter integration", () => { + it("applies filter preset when transition completes", () => { + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx, { + defaultDuration: 100, + minDuration: 0, + }); + registerDefaults(timeline); + + timeline.transitionTo("happy"); + vi.advanceTimersByTime(150); + timeline.update(); + + expect(ctx.filterPipeline!.applyPreset).toHaveBeenCalledWith("happy-glow"); + }); + + it("removes filter when transitioning to neutral", () => { + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx, { + defaultDuration: 100, + minDuration: 0, + }); + registerDefaults(timeline); + + // Transition to happy + timeline.transitionTo("happy"); + vi.advanceTimersByTime(150); + timeline.update(); + expect(ctx.filterPipeline!.applyPreset).toHaveBeenCalledWith("happy-glow"); + + // Transition to neutral + timeline.transitionTo("neutral"); + vi.advanceTimersByTime(150); + timeline.update(); + expect(ctx.filterPipeline!.remove).toHaveBeenCalled(); + }); + + it("sets filter intensity when specified", () => { + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx, { + defaultDuration: 100, + minDuration: 0, + }); + registerDefaults(timeline); + + timeline.transitionTo("happy"); + vi.advanceTimersByTime(150); + timeline.update(); + + expect(ctx.filterPipeline!.setIntensity).toHaveBeenCalledWith( + expect.objectContaining({ id: "fx-test" }), + 0.4, + ); + }); + }); + + describe("idle timeout", () => { + it("auto-returns to neutral after idle timeout", () => { + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx, { + defaultDuration: 100, + minDuration: 0, + }); + registerDefaults(timeline); + // Override happy's idleTimeout with a short one for testing + timeline.registerEmotion("happy", { + parameters: { mouthSmile: 0.6 }, + idleTimeout: 200, + }); + + timeline.transitionTo("happy"); + vi.advanceTimersByTime(150); + timeline.update(); + expect(timeline.getCurrentEmotion()).toBe("happy"); + + // Wait for idle timeout + vi.advanceTimersByTime(300); + timeline.update(); + expect(timeline.getCurrentEmotion()).toBe("neutral"); + }); + }); + + describe("disabled", () => { + it("blocks transitions when disabled", () => { + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx, { enabled: false }); + registerDefaults(timeline); + + timeline.transitionTo("happy"); + expect(timeline.isTransitioning()).toBe(false); + }); + }); + + describe("full cycle", () => { + it("transitions neutral -> happy -> sad -> neutral", () => { + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx, { + defaultDuration: 100, + minDuration: 0, + defaultEasing: "linear", + }); + registerDefaults(timeline); + + // neutral -> happy + timeline.transitionTo("happy"); + vi.advanceTimersByTime(150); + timeline.update(); + expect(timeline.getCurrentEmotion()).toBe("happy"); + expect(ctx.filterPipeline!.applyPreset).toHaveBeenCalledWith("happy-glow"); + + // happy -> sad + timeline.transitionTo("sad"); + vi.advanceTimersByTime(150); + timeline.update(); + expect(timeline.getCurrentEmotion()).toBe("sad"); + + // sad -> neutral + timeline.transitionTo("neutral"); + vi.advanceTimersByTime(150); + timeline.update(); + expect(timeline.getCurrentEmotion()).toBe("neutral"); + }); + }); + + describe("destroy", () => { + it("cleans up filter handle on destroy", () => { + const ctx = createMockContext(); + const timeline = new EmotionTimeline(ctx, { + defaultDuration: 100, + minDuration: 0, + }); + registerDefaults(timeline); + + timeline.transitionTo("happy"); + vi.advanceTimersByTime(150); + timeline.update(); + + timeline.destroy(); + expect(ctx.filterPipeline!.remove).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/live2d/src/runtime/emotion/index.ts b/packages/live2d/src/runtime/emotion/index.ts new file mode 100644 index 0000000..7fd001d --- /dev/null +++ b/packages/live2d/src/runtime/emotion/index.ts @@ -0,0 +1,9 @@ +export { EmotionTimeline } from "./timeline"; +export { EMOTION_REGISTRY, getDefaultEmotionProfile } from "./registry"; +export type { + EmotionName, + EmotionProfile, + EmotionTimelineConfig, + TransitionState, + EmotionTimelineContext, +} from "./types"; diff --git a/packages/live2d/src/runtime/emotion/registry.ts b/packages/live2d/src/runtime/emotion/registry.ts new file mode 100644 index 0000000..f9dea7b --- /dev/null +++ b/packages/live2d/src/runtime/emotion/registry.ts @@ -0,0 +1,133 @@ +import type { EmotionName, EmotionProfile } from "./types"; + +export const EMOTION_REGISTRY: Record = { + neutral: { + parameters: { + mouthSmile: 0, + mouthOpen: 0, + mouthForm: 0, + eyeLOpen: 1, + eyeROpen: 1, + eyeLSmile: 0, + eyeRSmile: 0, + cheek: 0, + browLY: 0, + browRY: 0, + browLAngle: 0, + browRAngle: 0, + angleX: 0, + angleY: 0, + breath: 0, + }, + idleTimeout: 0, + }, + + happy: { + parameters: { + mouthSmile: 0.6, + mouthOpen: 0.2, + eyeLSmile: 0.5, + eyeRSmile: 0.5, + cheek: 0.2, + browLY: -0.1, + browRY: -0.1, + eyeLOpen: 0.9, + eyeROpen: 0.9, + }, + filterPreset: "happy-glow", + filterIntensity: 0.4, + idleTimeout: 3000, + }, + + sad: { + parameters: { + browLY: 0.3, + browRY: 0.3, + mouthForm: -0.3, + eyeLOpen: 0.8, + eyeROpen: 0.8, + mouthSmile: 0, + angleY: 0.1, + }, + filterPreset: "morning-cool", + filterIntensity: 0.2, + idleTimeout: 4000, + }, + + angry: { + parameters: { + browLY: 0.4, + browRY: 0.4, + browLAngle: -0.3, + browRAngle: 0.3, + mouthForm: -0.2, + cheek: 0.15, + eyeLOpen: 0.85, + eyeROpen: 0.85, + mouthOpen: 0.1, + }, + filterPreset: "angry-red", + filterIntensity: 0.4, + idleTimeout: 3000, + }, + + embarrassed: { + parameters: { + cheek: 0.5, + eyeLOpen: 0.7, + eyeROpen: 0.7, + browLY: 0.1, + browRY: 0.1, + mouthSmile: 0.2, + angleX: 0.05, + }, + filterPreset: "shy-blush", + filterIntensity: 0.35, + idleTimeout: 2500, + }, + + surprised: { + parameters: { + eyeLOpen: 1.0, + eyeROpen: 1.0, + mouthOpen: 0.6, + browLY: -0.3, + browRY: -0.3, + mouthForm: 0.2, + }, + filterPreset: "neutral", + filterIntensity: 0.1, + idleTimeout: 2000, + }, + + sleepy: { + parameters: { + eyeLOpen: 0.4, + eyeROpen: 0.4, + browLY: 0.15, + browRY: 0.15, + mouthForm: 0.05, + angleY: 0.1, + breath: 0.2, + }, + idleTimeout: 5000, + }, + + thinking: { + parameters: { + browLY: -0.2, + browRY: -0.2, + eyeLOpen: 0.85, + eyeROpen: 0.85, + mouthForm: 0.1, + angleX: 0.1, + }, + idleTimeout: 3000, + }, +}; + +export function getDefaultEmotionProfile( + name: EmotionName, +): EmotionProfile | undefined { + return EMOTION_REGISTRY[name]; +} diff --git a/packages/live2d/src/runtime/emotion/timeline.ts b/packages/live2d/src/runtime/emotion/timeline.ts new file mode 100644 index 0000000..aab543a --- /dev/null +++ b/packages/live2d/src/runtime/emotion/timeline.ts @@ -0,0 +1,276 @@ +import type { + EmotionName, + EmotionProfile, + EmotionTimelineConfig, + EmotionTimelineContext, + TransitionState, + EasingName, +} from "./types"; +import { getEasing } from "../procedural/easing"; + +export class EmotionTimeline { + private registry = new Map(); + private currentEmotion: EmotionName = "neutral"; + private transition: TransitionState | null = null; + private context: EmotionTimelineContext; + private config: Required; + private idleTimer: ReturnType | null = null; + private currentFilterHandle: string | null = null; + private currentParameters = new Map(); + + constructor( + context: EmotionTimelineContext, + config: EmotionTimelineConfig = {}, + ) { + this.context = context; + this.config = { + enabled: config.enabled ?? true, + defaultDuration: config.defaultDuration ?? 800, + minDuration: config.minDuration ?? 300, + defaultEasing: config.defaultEasing ?? "easeOut", + idleReturnDelay: config.idleReturnDelay ?? 0, + }; + } + + /** + * Register a custom emotion profile. + */ + registerEmotion(name: EmotionName, profile: EmotionProfile): void { + this.registry.set(name, profile); + } + + /** + * Get a registered emotion profile. + */ + getEmotionProfile(name: EmotionName): EmotionProfile | undefined { + return this.registry.get(name); + } + + /** + * Transition to a target emotion. + */ + transitionTo( + emotion: EmotionName, + options?: { duration?: number; easing?: EasingName }, + ): void { + if (!this.config.enabled) return; + if (emotion === this.currentEmotion && !this.transition) return; + + const profile = this.registry.get(emotion); + if (!profile) { + console.warn(`[EmotionTimeline] Unknown emotion: ${emotion}`); + return; + } + + // Clear idle timer + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + + // Determine duration (enforced minimum) + const duration = Math.max( + options?.duration ?? this.config.defaultDuration, + this.config.minDuration, + ); + + // Resolve easing + const easingName = options?.easing ?? this.config.defaultEasing; + const easing = getEasing(easingName); + + // Capture current parameter values as starting point + const fromParameters = this.captureCurrentParameters(); + const toParameters = profile.parameters; + + this.transition = { + fromEmotion: this.currentEmotion, + toEmotion: emotion, + startTime: performance.now(), + duration, + easing, + fromParameters, + toParameters, + }; + } + + /** + * Update the timeline. Should be called each frame. + */ + update(): void { + if (!this.transition || !this.config.enabled) return; + + const now = performance.now(); + const elapsed = now - this.transition.startTime; + const progress = Math.min(elapsed / this.transition.duration, 1); + const easedProgress = this.transition.easing(progress); + + // Interpolate each parameter + const { fromParameters, toParameters } = this.transition; + const allParams = new Set([ + ...Object.keys(fromParameters), + ...Object.keys(toParameters), + ]); + + const parametersToApply: Record< + string, + { value: number; blendMode: "override" } + > = {}; + + for (const param of allParams) { + const fromValue = fromParameters[param] ?? 0; + const toValue = toParameters[param] ?? 0; + const currentValue = fromValue + (toValue - fromValue) * easedProgress; + this.currentParameters.set(param, currentValue); + parametersToApply[param] = { value: currentValue, blendMode: "override" }; + } + + // Output through motion layer system if available, otherwise direct + if (this.context.motionLayerSystem) { + // Only include parameters that the model actually supports + const supportedParams: typeof parametersToApply = {}; + for (const [param, config] of Object.entries(parametersToApply)) { + if (this.context.semanticLayer?.hasSemantic(param)) { + supportedParams[param] = config; + } + } + if (Object.keys(supportedParams).length > 0) { + this.context.motionLayerSystem.play({ + layer: "expression", + parameters: supportedParams, + fadeIn: 0, + }); + } + } else if (this.context.semanticLayer) { + for (const [param, { value }] of Object.entries(parametersToApply)) { + if (this.context.semanticLayer.hasSemantic(param)) { + this.context.semanticLayer.setSemantic( + param, + value, + "override", + "emotion", + 3, + ); + } + } + } + + if (progress >= 1) { + this.finishTransition(); + } + } + + /** + * Get the current dominant emotion. + */ + getCurrentEmotion(): EmotionName { + return this.currentEmotion; + } + + /** + * Check if a transition is currently active. + */ + isTransitioning(): boolean { + return this.transition !== null; + } + + /** + * Get the current transition progress (0–1). + * Returns 0 if no transition is active. + */ + getTransitionProgress(): number { + if (!this.transition) return 0; + const elapsed = performance.now() - this.transition.startTime; + return Math.min(elapsed / this.transition.duration, 1); + } + + /** + * Get current interpolated parameter values. + */ + getCurrentParameters(): ReadonlyMap { + return this.currentParameters; + } + + /** + * Destroy the timeline and clean up resources. + */ + destroy(): void { + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + if (this.currentFilterHandle && this.context.filterPipeline) { + this.context.filterPipeline.remove(this.currentFilterHandle); + this.currentFilterHandle = null; + } + } + + private finishTransition(): void { + if (!this.transition) return; + + const { toEmotion } = this.transition; + const profile = this.registry.get(toEmotion); + this.currentEmotion = toEmotion; + this.transition = null; + + // Apply filter preset for the new emotion + this.applyFilterForEmotion(profile); + + // Schedule auto-return to neutral if idle timeout is configured + if ( + profile?.idleTimeout && + profile.idleTimeout > 0 && + toEmotion !== "neutral" + ) { + this.scheduleIdleReturn(profile.idleTimeout); + } + } + + private applyFilterForEmotion(profile: EmotionProfile | undefined): void { + if (!this.context.filterPipeline) return; + + // Remove previous filter if present + if (this.currentFilterHandle) { + this.context.filterPipeline.remove(this.currentFilterHandle); + this.currentFilterHandle = null; + } + + // Apply new filter if specified + if (profile?.filterPreset) { + const handle = this.context.filterPipeline.applyPreset( + profile.filterPreset, + ); + this.currentFilterHandle = handle.id; + + if (profile.filterIntensity !== undefined) { + this.context.filterPipeline.setIntensity( + handle, + profile.filterIntensity, + ); + } + } + } + + private scheduleIdleReturn(delay: number): void { + this.idleTimer = setTimeout(() => { + this.transitionTo("neutral", { + duration: this.config.defaultDuration, + easing: this.config.defaultEasing, + }); + }, delay); + } + + private captureCurrentParameters(): Record { + // If mid-transition, use current interpolated values as starting point + if (this.transition) { + const result: Record = {}; + for (const [param, value] of this.currentParameters) { + result[param] = value; + } + return result; + } + + // Otherwise use the current emotion's target parameters + const profile = this.registry.get(this.currentEmotion); + return profile ? { ...profile.parameters } : {}; + } +} diff --git a/packages/live2d/src/runtime/emotion/types.ts b/packages/live2d/src/runtime/emotion/types.ts new file mode 100644 index 0000000..a25a496 --- /dev/null +++ b/packages/live2d/src/runtime/emotion/types.ts @@ -0,0 +1,45 @@ +import type { EasingFunction } from "../procedural/easing"; +import type { EffectPreset } from "../filters/types"; +import type { SemanticParameterLayer } from "../semantic"; +import type { FilterPipeline } from "../filters"; +import type { MotionLayerSystem } from "../motion"; + +export type EmotionName = string; + +export type EasingName = + | "linear" + | "easeIn" + | "easeOut" + | "easeInOut" + | "spring"; + +export interface EmotionProfile { + parameters: Record; + filterPreset?: EffectPreset; + filterIntensity?: number; + idleTimeout?: number; +} + +export interface EmotionTimelineConfig { + enabled?: boolean; + defaultDuration?: number; + minDuration?: number; + defaultEasing?: EasingName; + idleReturnDelay?: number; +} + +export interface TransitionState { + fromEmotion: EmotionName; + toEmotion: EmotionName; + startTime: number; + duration: number; + easing: EasingFunction; + fromParameters: Record; + toParameters: Record; +} + +export interface EmotionTimelineContext { + semanticLayer?: SemanticParameterLayer; + filterPipeline?: FilterPipeline; + motionLayerSystem?: MotionLayerSystem; +} diff --git a/packages/live2d/src/runtime/filters/__tests__/filter-pipeline.test.ts b/packages/live2d/src/runtime/filters/__tests__/filter-pipeline.test.ts new file mode 100644 index 0000000..3305f4b --- /dev/null +++ b/packages/live2d/src/runtime/filters/__tests__/filter-pipeline.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { FilterPipeline } from "../filter-pipeline"; + +describe("FilterPipeline", () => { + it("adds mood lighting effect", () => { + const pipeline = new FilterPipeline(); + const handle = pipeline.addMoodLighting({ color: "warm", intensity: 0.3 }); + + expect(handle.id).toBeDefined(); + expect(pipeline.getEffectCount()).toBe(1); + expect(pipeline.hasEffects()).toBe(true); + }); + + it("adds blush effect", () => { + const pipeline = new FilterPipeline(); + const handle = pipeline.addBlush({ intensity: 0.4 }); + + expect(handle.id).toBeDefined(); + expect(pipeline.getEffectCount()).toBe(1); + }); + + it("adds glow effect", () => { + const pipeline = new FilterPipeline(); + const handle = pipeline.addGlow({ intensity: 0.5 }); + + expect(handle.id).toBeDefined(); + expect(pipeline.getEffectCount()).toBe(1); + }); + + it("adds color grading effect", () => { + const pipeline = new FilterPipeline(); + const handle = pipeline.addColorGrading({ + temperature: "cool", + intensity: 0.2, + }); + + expect(handle.id).toBeDefined(); + expect(pipeline.getEffectCount()).toBe(1); + }); + + it("supports multiple concurrent effects", () => { + const pipeline = new FilterPipeline(); + pipeline.addMoodLighting({ color: "warm" }); + pipeline.addBlush(); + pipeline.addGlow(); + + expect(pipeline.getEffectCount()).toBe(3); + }); + + it("removes effect by handle", () => { + const pipeline = new FilterPipeline(); + const handle = pipeline.addMoodLighting({ color: "warm" }); + + expect(pipeline.getEffectCount()).toBe(1); + + pipeline.remove(handle); + expect(pipeline.getEffectCount()).toBe(0); + expect(pipeline.hasEffects()).toBe(false); + }); + + it("adjusts effect intensity dynamically", () => { + const pipeline = new FilterPipeline(); + const handle = pipeline.addMoodLighting({ color: "warm", intensity: 0.1 }); + + pipeline.setIntensity(handle, 0.8); + // Intensity updated without error + expect(pipeline.getEffectCount()).toBe(1); + }); + + it("clears all effects", () => { + const pipeline = new FilterPipeline(); + pipeline.addMoodLighting({ color: "warm" }); + pipeline.addBlush(); + + expect(pipeline.getEffectCount()).toBe(2); + + pipeline.clear(); + expect(pipeline.getEffectCount()).toBe(0); + expect(pipeline.hasEffects()).toBe(false); + }); + + it("supports presets", () => { + const pipeline = new FilterPipeline(); + + const warm = pipeline.applyPreset("evening-warm"); + expect(warm.id).toBeDefined(); + expect(pipeline.getEffectCount()).toBe(1); + + pipeline.clear(); + + const cool = pipeline.applyPreset("morning-cool"); + expect(cool.id).toBeDefined(); + + pipeline.clear(); + + const neutral = pipeline.applyPreset("neutral"); + expect(neutral.id).toBeDefined(); + }); + + it("skips expensive effects on low quality", () => { + const pipeline = new FilterPipeline({ quality: "low" }); + pipeline.addGlow({ intensity: 0.5 }); + + // Glow is considered expensive, so it should be skipped on low quality + expect(pipeline.getEffectCount()).toBe(0); + }); + + it("allows effects on high quality", () => { + const pipeline = new FilterPipeline({ quality: "high" }); + pipeline.addGlow({ intensity: 0.5 }); + + expect(pipeline.getEffectCount()).toBe(1); + }); + + it("ignores removing non-existent effect", () => { + const pipeline = new FilterPipeline(); + // Should not throw + pipeline.remove("non-existent-id"); + expect(pipeline.getEffectCount()).toBe(0); + }); + + it("falls back to full-model filtering when part targeting is unavailable", () => { + const pipeline = new FilterPipeline(); + const handle = pipeline.applyPresetToPart("happy-glow", /eye/); + + // Should still create an effect (fallback to full model) + expect(handle.id).toBeDefined(); + expect(pipeline.getEffectCount()).toBe(1); + }); + + it("reports part-level targeting unavailable without model", () => { + const pipeline = new FilterPipeline(); + expect(pipeline.isPartLevelTargetingAvailable()).toBe(false); + }); +}); diff --git a/packages/live2d/src/runtime/filters/effects.ts b/packages/live2d/src/runtime/filters/effects.ts new file mode 100644 index 0000000..c8278db --- /dev/null +++ b/packages/live2d/src/runtime/filters/effects.ts @@ -0,0 +1,226 @@ +import { BlurFilter, ColorMatrixFilter } from "pixi.js"; +import type { + BlushOptions, + ColorGradingOptions, + EffectIntensity, + FilterEffect, + GlowOptions, + MoodLightingOptions, +} from "./types"; + +/** 4x5 color matrix used by PixiJS ColorMatrixFilter. */ +type ColorMatrix = [ + number, number, number, number, number, + number, number, number, number, number, + number, number, number, number, number, + number, number, number, number, number, +]; + +const IDENTITY_MATRIX: ColorMatrix = [ + 1, 0, 0, 0, 0, + 0, 1, 0, 0, 0, + 0, 0, 1, 0, 0, + 0, 0, 0, 1, 0, +]; + +function lerpMatrix(base: ColorMatrix, intensity: EffectIntensity): ColorMatrix { + const result = new Array(20); + for (let i = 0; i < 20; i++) { + result[i] = IDENTITY_MATRIX[i] + (base[i] - IDENTITY_MATRIX[i]) * intensity; + } + return result as unknown as ColorMatrix; +} + +export class MoodLightingEffect implements FilterEffect { + readonly id: string; + readonly name = "氛围光照"; + readonly type = "mood-lighting"; + readonly filter: ColorMatrixFilter; + intensity: EffectIntensity; + private baseMatrix: ColorMatrix; + + constructor(options: MoodLightingOptions & { id: string }) { + this.id = options.id; + this.filter = new ColorMatrixFilter(); + this.intensity = options.intensity ?? 0.3; + this.baseMatrix = this.createMatrixForColor(options.color); + this.applyIntensity(); + } + + setIntensity(value: EffectIntensity): void { + this.intensity = Math.max(0, Math.min(1, value)); + this.applyIntensity(); + } + + destroy(): void { + this.filter.destroy(); + } + + private applyIntensity(): void { + this.filter.matrix = lerpMatrix(this.baseMatrix, this.intensity); + } + + private createMatrixForColor(color: MoodLightingOptions["color"]): ColorMatrix { + switch (color) { + case "warm": + // Increase red, slightly decrease blue for warm glow + return [ + 1.15, 0, 0, 0, 0, + 0, 1.05, 0, 0, 0, + 0, 0, 0.85, 0, 0, + 0, 0, 0, 1, 0, + ]; + case "cool": + // Increase blue, slightly decrease red for cool tone + return [ + 0.9, 0, 0, 0, 0, + 0, 1.0, 0, 0, 0, + 0, 0, 1.15, 0, 0, + 0, 0, 0, 1, 0, + ]; + case "neutral": + default: + // Slight brightness boost + return [ + 1.05, 0, 0, 0, 0, + 0, 1.05, 0, 0, 0, + 0, 0, 1.05, 0, 0, + 0, 0, 0, 1, 0, + ]; + } + } +} + +export class BlushEffect implements FilterEffect { + readonly id: string; + readonly name = "害羞红晕"; + readonly type = "blush"; + readonly filter: BlurFilter; + intensity: EffectIntensity; + private readonly baseStrength: number; + + constructor(options: BlushOptions & { id: string }) { + this.id = options.id; + this.baseStrength = 4; + this.filter = new BlurFilter({ + strength: 0, // Start at 0, will be set by intensity + }); + this.intensity = options.intensity ?? 0.3; + this.applyIntensity(); + } + + setIntensity(value: EffectIntensity): void { + this.intensity = Math.max(0, Math.min(1, value)); + this.applyIntensity(); + } + + destroy(): void { + this.filter.destroy(); + } + + private applyIntensity(): void { + this.filter.strength = this.baseStrength * this.intensity; + } +} + +export class GlowEffect implements FilterEffect { + readonly id: string; + readonly name = "光晕效果"; + readonly type = "glow"; + readonly filter: ColorMatrixFilter; + intensity: EffectIntensity; + private readonly color: number; + + constructor(options: GlowOptions & { id: string }) { + this.id = options.id; + this.filter = new ColorMatrixFilter(); + this.intensity = options.intensity ?? 0.3; + this.color = options.color ?? 0xffaa44; + this.applyIntensity(); + } + + setIntensity(value: EffectIntensity): void { + this.intensity = Math.max(0, Math.min(1, value)); + this.applyIntensity(); + } + + destroy(): void { + this.filter.destroy(); + } + + private applyIntensity(): void { + const r = ((this.color >> 16) & 0xff) / 255; + const g = ((this.color >> 8) & 0xff) / 255; + const b = (this.color & 0xff) / 255; + + const boost = this.intensity * 0.3; // brightness boost 0-30% + const shift = this.intensity * 0.2; // color shift 0-20% + + // Matrix: increase brightness + shift toward target color + const matrix: ColorMatrix = [ + 1 + boost + shift * r, shift * g, shift * b, 0, shift * r * 0.3, + shift * r, 1 + boost + shift * g, shift * b, 0, shift * g * 0.3, + shift * r, shift * g, 1 + boost + shift * b, 0, shift * b * 0.3, + 0, 0, 0, 1, 0, + ]; + + this.filter.matrix = matrix; + } +} + +export class ColorGradingEffect implements FilterEffect { + readonly id: string; + readonly name = "色彩分级"; + readonly type = "color-grading"; + readonly filter: ColorMatrixFilter; + intensity: EffectIntensity; + private baseMatrix: ColorMatrix; + + constructor(options: ColorGradingOptions & { id: string }) { + this.id = options.id; + this.filter = new ColorMatrixFilter(); + this.intensity = options.intensity ?? 0.2; + this.baseMatrix = this.createMatrixForTemperature(options.temperature); + this.applyIntensity(); + } + + setIntensity(value: EffectIntensity): void { + this.intensity = Math.max(0, Math.min(1, value)); + this.applyIntensity(); + } + + destroy(): void { + this.filter.destroy(); + } + + private applyIntensity(): void { + this.filter.matrix = lerpMatrix(this.baseMatrix, this.intensity); + } + + private createMatrixForTemperature(temperature: ColorGradingOptions["temperature"]): ColorMatrix { + switch (temperature) { + case "warm": + return [ + 1.2, 0.05, 0, 0, 0, + 0.05, 1.1, 0, 0, 0, + 0, 0, 0.85, 0, 0, + 0, 0, 0, 1, 0, + ]; + case "cool": + return [ + 0.85, 0, 0.05, 0, 0, + 0, 0.95, 0.05, 0, 0, + 0.05, 0.05, 1.2, 0, 0, + 0, 0, 0, 1, 0, + ]; + case "neutral": + default: + return [ + 1, 0, 0, 0, 0, + 0, 1, 0, 0, 0, + 0, 0, 1, 0, 0, + 0, 0, 0, 1, 0, + ]; + } + } +} diff --git a/packages/live2d/src/runtime/filters/filter-pipeline.ts b/packages/live2d/src/runtime/filters/filter-pipeline.ts new file mode 100644 index 0000000..c922e23 --- /dev/null +++ b/packages/live2d/src/runtime/filters/filter-pipeline.ts @@ -0,0 +1,282 @@ +import type { Live2DModel } from "untitled-pixi-live2d-engine"; +import type { + BlushOptions, + ColorGradingOptions, + EffectPreset, + FilterEffect, + FilterHandle, + FilterPipelineOptions, + GlowOptions, + MoodLightingOptions, + QualityTier, +} from "./types"; +import { + BlushEffect, + ColorGradingEffect, + GlowEffect, + MoodLightingEffect, +} from "./effects"; + +export class FilterPipeline { + private model: Live2DModel | null = null; + private effects = new Map(); + private partTargetHandles = new Map(); + private quality: QualityTier; + + constructor(options: FilterPipelineOptions = {}) { + this.quality = options.quality ?? "high"; + } + + /** + * Attach this pipeline to a Live2D model. + */ + attachTo(model: Live2DModel): void { + this.model = model; + this.rebuildFilters(); + } + + /** + * Detach from the current model and clear all effects. + */ + detach(): void { + if (this.model) { + this.model.filters = null; + } + this.clear(); + this.model = null; + } + + /** + * Add a mood lighting effect. + */ + addMoodLighting(options: MoodLightingOptions): FilterHandle { + const id = this.generateId(); + const effect = new MoodLightingEffect({ ...options, id }); + return this.addEffect(effect); + } + + /** + * Add a blush effect. + */ + addBlush(options: BlushOptions = {}): FilterHandle { + const id = this.generateId(); + const effect = new BlushEffect({ ...options, id }); + return this.addEffect(effect); + } + + /** + * Add a glow effect. + */ + addGlow(options: GlowOptions = {}): FilterHandle { + const id = this.generateId(); + const effect = new GlowEffect({ ...options, id }); + return this.addEffect(effect); + } + + /** + * Add a color grading effect. + */ + addColorGrading(options: ColorGradingOptions): FilterHandle { + const id = this.generateId(); + const effect = new ColorGradingEffect({ ...options, id }); + return this.addEffect(effect); + } + + /** + * Apply a named preset. + */ + applyPreset(preset: EffectPreset): FilterHandle { + switch (preset) { + case "evening-warm": + return this.addMoodLighting({ color: "warm", intensity: 0.25 }); + case "morning-cool": + return this.addMoodLighting({ color: "cool", intensity: 0.2 }); + case "neutral": + return this.addColorGrading({ temperature: "neutral", intensity: 0.1 }); + case "happy-glow": + // Warm golden mood lighting instead of a bright white wash + return this.addMoodLighting({ color: "warm", intensity: 0.35 }); + case "shy-blush": + // Warm reddish color grading instead of a full-screen blur + return this.addColorGrading({ temperature: "warm", intensity: 0.3 }); + case "angry-red": + return this.addColorGrading({ temperature: "warm", intensity: 0.4 }); + default: + throw new Error(`Unknown preset: ${preset}`); + } + } + + /** + * Apply a preset to a specific part of the model by drawable name pattern. + * + * Part-level targeting requires the engine to expose individual drawables + * as separate DisplayObjects. If this is not available (e.g. the current + * version of untitled-pixi-live2d-engine renders through a custom render + * pipe), the effect falls back to full-model filtering. + */ + applyPresetToPart( + preset: EffectPreset, + namePattern: RegExp, + ): FilterHandle { + const drawable = this.findDrawableByPattern(namePattern); + if (drawable) { + // Part-level filtering is available — apply to the specific drawable + const handle = this.applyPreset(preset); + this.partTargetHandles.set(handle.id, handle); + return handle; + } + + // Fallback: apply to the entire model + return this.applyPreset(preset); + } + + /** + * Check if part-level targeting is available for the current model. + */ + isPartLevelTargetingAvailable(): boolean { + if (!this.model) return false; + + // Part-level targeting requires the engine to expose individual + // drawables as Pixi DisplayObjects with their own filter arrays. + // The current engine uses a custom render pipe, so this is not + // supported yet. This check allows future versions to opt-in. + const modelRecord = this.model as unknown as Record; + const internalModel = modelRecord.internalModel as + | Record + | undefined; + if (!internalModel) return false; + + // Check for drawable access via core model (Cubism 4/5) + const coreModel = internalModel.coreModel as + | Record + | undefined; + if (coreModel) { + const drawables = + coreModel._drawables ?? coreModel.drawables; + if (drawables != null) return true; + } + + return false; + } + + /** + * Remove an effect by its handle. + */ + remove(handle: FilterHandle | string): void { + const id = typeof handle === "string" ? handle : handle.id; + const effect = this.effects.get(id); + if (effect) { + effect.destroy(); + this.effects.delete(id); + this.rebuildFilters(); + } + } + + /** + * Update the intensity of an active effect. + */ + setIntensity(handle: FilterHandle | string, intensity: number): void { + const id = typeof handle === "string" ? handle : handle.id; + const effect = this.effects.get(id); + if (effect) { + effect.setIntensity(intensity); + } + } + + /** + * Remove all effects. + */ + clear(): void { + for (const effect of this.effects.values()) { + effect.destroy(); + } + this.effects.clear(); + if (this.model) { + this.model.filters = null; + } + } + + /** + * Get the number of active effects. + */ + getEffectCount(): number { + return this.effects.size; + } + + /** + * Check if any effects are active. + */ + hasEffects(): boolean { + return this.effects.size > 0; + } + + /** + * Get metadata for all active effects. + */ + getActiveEffects(): Array<{ + id: string; + name: string; + type: string; + intensity: number; + }> { + return Array.from(this.effects.values()).map((e) => ({ + id: e.id, + name: e.name, + type: e.type, + intensity: e.intensity, + })); + } + + /** + * Set the quality tier. This affects future effects but not existing ones. + */ + setQuality(quality: QualityTier): void { + this.quality = quality; + } + + private addEffect(effect: FilterEffect): FilterHandle { + // Skip expensive effects on low quality + if (this.quality === "low" && this.isExpensiveEffect(effect)) { + effect.destroy(); + return { id: effect.id }; + } + + this.effects.set(effect.id, effect); + this.rebuildFilters(); + return { id: effect.id }; + } + + private isExpensiveEffect(effect: FilterEffect): boolean { + // Blur-based effects are more expensive than color matrix + return effect.constructor.name.includes("Blur") || + effect.constructor.name.includes("Glow"); + } + + private rebuildFilters(): void { + if (!this.model) return; + + const filters = Array.from(this.effects.values()).map((e) => e.filter); + this.model.filters = filters.length > 0 ? filters : null; + } + + private findDrawableByPattern(_pattern: RegExp): unknown { + if (!this.model) return null; + + // The current version of untitled-pixi-live2d-engine renders Live2D + // models through a custom render pipe. Individual drawables are not + // exposed as separate Pixi DisplayObjects, so we cannot target filters + // to specific parts. This method returns null, causing applyPresetToPart + // to gracefully fallback to full-model filtering. + // + // Future engine versions may expose drawables via: + // - model.internalModel.coreModel._drawables (Cubism 4/5) + // - model.children as separate DisplayObjects per part + // When that happens, this method can match drawables by name and + // return them for filter targeting. + return null; + } + + private generateId(): string { + return `fx-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + } +} diff --git a/packages/live2d/src/runtime/filters/index.ts b/packages/live2d/src/runtime/filters/index.ts new file mode 100644 index 0000000..b8a3fbf --- /dev/null +++ b/packages/live2d/src/runtime/filters/index.ts @@ -0,0 +1,19 @@ +export { FilterPipeline } from "./filter-pipeline"; +export { + MoodLightingEffect, + BlushEffect, + GlowEffect, + ColorGradingEffect, +} from "./effects"; +export type { + EffectIntensity, + QualityTier, + FilterHandle, + FilterEffect, + FilterPipelineOptions, + MoodLightingOptions, + BlushOptions, + GlowOptions, + ColorGradingOptions, + EffectPreset, +} from "./types"; diff --git a/packages/live2d/src/runtime/filters/types.ts b/packages/live2d/src/runtime/filters/types.ts new file mode 100644 index 0000000..07e176f --- /dev/null +++ b/packages/live2d/src/runtime/filters/types.ts @@ -0,0 +1,51 @@ +import type { Filter } from "pixi.js"; + +export type EffectIntensity = number; + +export type QualityTier = "low" | "medium" | "high"; + +export interface FilterHandle { + id: string; +} + +export interface FilterEffect { + readonly id: string; + readonly name: string; + readonly type: string; + readonly filter: Filter; + intensity: EffectIntensity; + setIntensity(value: EffectIntensity): void; + destroy(): void; +} + +export interface FilterPipelineOptions { + quality?: QualityTier; +} + +export interface MoodLightingOptions { + color: "warm" | "cool" | "neutral"; + intensity?: EffectIntensity; +} + +export interface BlushOptions { + intensity?: EffectIntensity; + color?: number; +} + +export interface GlowOptions { + intensity?: EffectIntensity; + color?: number; +} + +export interface ColorGradingOptions { + temperature: "warm" | "cool" | "neutral"; + intensity?: EffectIntensity; +} + +export type EffectPreset = + | "evening-warm" + | "morning-cool" + | "neutral" + | "happy-glow" + | "shy-blush" + | "angry-red"; diff --git a/packages/live2d/src/runtime/motion/__tests__/motion-layer-system.test.ts b/packages/live2d/src/runtime/motion/__tests__/motion-layer-system.test.ts new file mode 100644 index 0000000..74c72c1 --- /dev/null +++ b/packages/live2d/src/runtime/motion/__tests__/motion-layer-system.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest"; +import { FadeEnvelope } from "../fade-envelope"; +import { MotionTrack } from "../motion-track"; +import { MotionLayerSystem } from "../motion-layer-system"; +import { SemanticParameterLayer } from "../../semantic"; + +describe("FadeEnvelope", () => { + it("starts at weight 0", () => { + const env = new FadeEnvelope(); + expect(env.getWeight()).toBe(0); + expect(env.isActive()).toBe(false); + }); + + it("fades in to weight 1", () => { + const env = new FadeEnvelope({ fadeInDuration: 100 }); + env.beginFadeIn(); + + env.update(50); + expect(env.getWeight()).toBeGreaterThan(0); + expect(env.getWeight()).toBeLessThan(1); + + env.update(50); + expect(env.getWeight()).toBe(1); + expect(env.state).toBe("active"); + }); + + it("fades out to weight 0", () => { + const env = new FadeEnvelope({ fadeOutDuration: 100 }); + env.activate(); + expect(env.getWeight()).toBe(1); + + env.beginFadeOut(); + env.update(100); + expect(env.getWeight()).toBe(0); + expect(env.isStopped()).toBe(true); + }); +}); + +describe("MotionTrack", () => { + it("plays parameters with fade in", () => { + const track = new MotionTrack({ name: "talk", priority: 3 }); + track.play({ + layer: "talk", + parameters: { mouthOpen: { value: 0.8 } }, + fadeIn: 100, + }); + + expect(track.getState()).toBe("fadingIn"); + + track.update(50); + const outputs = track.getOutputs(); + expect(outputs.length).toBe(1); + expect(outputs[0].semantic).toBe("mouthOpen"); + expect(outputs[0].value).toBeGreaterThan(0); + expect(outputs[0].value).toBeLessThan(0.8); + }); + + it("activates to full weight without fade", () => { + const track = new MotionTrack({ name: "physics", priority: 0 }); + track.setParameters({ breath: { value: 0.15, blendMode: "add" } }); + + expect(track.getWeight()).toBe(1); + const outputs = track.getOutputs(); + expect(outputs[0].value).toBe(0.15); + expect(outputs[0].blendMode).toBe("add"); + }); + + it("stops with fade out", () => { + const track = new MotionTrack({ name: "talk", priority: 3 }); + track.play({ + layer: "talk", + parameters: { mouthOpen: { value: 0.8 } }, + fadeIn: 0, + }); + + // After play with fadeIn=0, track should be active + track.update(0); + track.stop(100); + track.update(100); + + expect(track.getState()).toBe("stopped"); + expect(track.getOutputs().length).toBe(0); + }); +}); + +describe("MotionLayerSystem", () => { + function createMockSemanticLayer(): SemanticParameterLayer { + const layer = new SemanticParameterLayer(); + (layer as unknown as { resolved: Map }).resolved = new Map([ + ["mouthOpen", { id: "PARAM_MOUTH_OPEN", index: 0 }], + ["angleX", { id: "PARAM_ANGLE_X", index: 1 }], + ["breath", { id: "PARAM_BREATH", index: 2 }], + ["eyeLOpen", { id: "PARAM_EYE_L_OPEN", index: 3 }], + ]); + (layer as unknown as { accessor: unknown }).accessor = { + getValue: () => 0, + setValue: () => {}, + getMin: () => -30, + getMax: () => 30, + }; + return layer; + } + + it("initializes 5 standard layers", () => { + const semantic = createMockSemanticLayer(); + const system = new MotionLayerSystem(semantic); + + expect(system.isPlaying("idle")).toBe(false); + expect(system.isPlaying("physics")).toBe(false); + expect(system.getActiveLayers()).toEqual([]); + }); + + it("plays on a layer and activates it", () => { + const semantic = createMockSemanticLayer(); + const system = new MotionLayerSystem(semantic); + + system.play({ + layer: "talk", + parameters: { mouthOpen: { value: 0.8 } }, + fadeIn: 0, + }); + + expect(system.isPlaying("talk")).toBe(true); + expect(system.getActiveLayers()).toContain("talk"); + }); + + it("higher priority overrides lower on same parameter", () => { + const semantic = createMockSemanticLayer(); + const system = new MotionLayerSystem(semantic); + + system.play({ + layer: "idle", + parameters: { angleX: { value: 5 } }, + priority: 1, + fadeIn: 0, + }); + + system.play({ + layer: "gesture", + parameters: { angleX: { value: 20 } }, + priority: 4, + fadeIn: 0, + }); + + system.update(0); + + const statuses = system.getLayerStatuses(); + const gestureStatus = statuses.find((s) => s.name === "gesture"); + expect(gestureStatus?.priority).toBe(4); + }); + + it("crossfades between motions on same layer", () => { + const semantic = createMockSemanticLayer(); + const system = new MotionLayerSystem(semantic); + + system.play({ + layer: "expression", + parameters: { mouthOpen: { value: 0.2 } }, + fadeIn: 0, + }); + + system.crossfade("expression", { + parameters: { mouthOpen: { value: 0.6 } }, + fadeIn: 100, + }); + + expect(system.isPlaying("expression")).toBe(true); + }); + + it("clears all layers", () => { + const semantic = createMockSemanticLayer(); + const system = new MotionLayerSystem(semantic); + + system.play({ + layer: "talk", + parameters: { mouthOpen: { value: 0.8 } }, + fadeIn: 0, + }); + + system.clearAll(); + expect(system.getActiveLayers()).toEqual([]); + }); + + it("sets physics parameters directly", () => { + const semantic = createMockSemanticLayer(); + const system = new MotionLayerSystem(semantic); + + system.setPhysicsParameters({ + breath: { value: 0.15, blendMode: "add" }, + }); + + const physics = system.getTrack("physics"); + expect(physics?.isActive()).toBe(true); + expect(physics?.getOutputs()[0]?.value).toBe(0.15); + }); + + it("stops a layer with fade out", () => { + const semantic = createMockSemanticLayer(); + const system = new MotionLayerSystem(semantic); + + system.play({ + layer: "talk", + parameters: { mouthOpen: { value: 0.8 } }, + fadeIn: 0, + }); + + expect(system.isPlaying("talk")).toBe(true); + + system.stop("talk", 100); + system.update(100); + + expect(system.isPlaying("talk")).toBe(false); + }); +}); diff --git a/packages/live2d/src/runtime/motion/fade-envelope.ts b/packages/live2d/src/runtime/motion/fade-envelope.ts new file mode 100644 index 0000000..a6dfe6e --- /dev/null +++ b/packages/live2d/src/runtime/motion/fade-envelope.ts @@ -0,0 +1,122 @@ +import { easeInOut } from "../procedural/easing"; +import type { EasingFunction } from "../procedural/easing"; + +export type FadeState = "idle" | "fadingIn" | "active" | "fadingOut" | "stopped"; + +export interface FadeEnvelopeOptions { + fadeInDuration?: number; + fadeOutDuration?: number; + fadeInCurve?: EasingFunction; + fadeOutCurve?: EasingFunction; +} + +export class FadeEnvelope { + state: FadeState = "idle"; + private weight = 0; + private fadeInDuration: number; + private fadeOutDuration: number; + private fadeInCurve: EasingFunction; + private fadeOutCurve: EasingFunction; + private fadeProgress = 0; + + constructor(options: FadeEnvelopeOptions = {}) { + this.fadeInDuration = options.fadeInDuration ?? 300; + this.fadeOutDuration = options.fadeOutDuration ?? 300; + this.fadeInCurve = options.fadeInCurve ?? easeInOut; + this.fadeOutCurve = options.fadeOutCurve ?? easeInOut; + } + + /** + * Start fading in. + */ + beginFadeIn(duration?: number): void { + if (duration !== undefined) { + this.fadeInDuration = duration; + } + this.state = "fadingIn"; + this.fadeProgress = 0; + } + + /** + * Start fading out. + */ + beginFadeOut(duration?: number): void { + if (duration !== undefined) { + this.fadeOutDuration = duration; + } + if (this.state === "idle" || this.state === "stopped") { + return; + } + this.state = "fadingOut"; + this.fadeProgress = 0; + } + + /** + * Immediately set to full weight. + */ + activate(): void { + this.state = "active"; + this.weight = 1; + this.fadeProgress = 1; + } + + /** + * Immediately stop. + */ + stop(): void { + this.state = "stopped"; + this.weight = 0; + this.fadeProgress = 0; + } + + /** + * Update the fade state by elapsed time. + * Returns the current weight. + */ + update(dt: number): number { + switch (this.state) { + case "fadingIn": { + this.fadeProgress += dt / this.fadeInDuration; + if (this.fadeProgress >= 1) { + this.fadeProgress = 1; + this.state = "active"; + } + this.weight = this.fadeInCurve(this.fadeProgress); + break; + } + case "active": { + this.weight = 1; + break; + } + case "fadingOut": { + this.fadeProgress += dt / this.fadeOutDuration; + if (this.fadeProgress >= 1) { + this.fadeProgress = 1; + this.state = "stopped"; + this.weight = 0; + } else { + this.weight = 1 - this.fadeOutCurve(this.fadeProgress); + } + break; + } + case "idle": + case "stopped": { + this.weight = 0; + break; + } + } + return this.weight; + } + + getWeight(): number { + return this.weight; + } + + isActive(): boolean { + return this.state === "active" || this.state === "fadingIn" || this.state === "fadingOut"; + } + + isStopped(): boolean { + return this.state === "stopped" || this.state === "idle"; + } +} diff --git a/packages/live2d/src/runtime/motion/index.ts b/packages/live2d/src/runtime/motion/index.ts new file mode 100644 index 0000000..53abb1d --- /dev/null +++ b/packages/live2d/src/runtime/motion/index.ts @@ -0,0 +1,14 @@ +export { MotionLayerSystem } from "./motion-layer-system"; +export { MotionTrack } from "./motion-track"; +export { FadeEnvelope } from "./fade-envelope"; +export type { + LayerName, + LayerState, + BlendMode, + FadeConfig, + TrackConfig, + LayerOutput, + PlayOptions, + MotionLayerConfig, + LayerStatus, +} from "./types"; diff --git a/packages/live2d/src/runtime/motion/motion-layer-system.ts b/packages/live2d/src/runtime/motion/motion-layer-system.ts new file mode 100644 index 0000000..129708c --- /dev/null +++ b/packages/live2d/src/runtime/motion/motion-layer-system.ts @@ -0,0 +1,231 @@ +import type { SemanticParameterLayer } from "../semantic"; +import type { + BlendMode, + LayerName, + LayerStatus, + MotionLayerConfig, + PlayOptions, + TrackConfig, +} from "./types"; +import { MotionTrack } from "./motion-track"; + +const DEFAULT_LAYER_CONFIGS: Record = { + physics: { name: "physics", priority: 0, interruptible: true }, + idle: { name: "idle", priority: 1, interruptible: true }, + expression: { name: "expression", priority: 2, interruptible: true }, + talk: { name: "talk", priority: 3, interruptible: true }, + gesture: { name: "gesture", priority: 4, interruptible: true }, +}; + +interface BlendedOutput { + semantic: string; + value: number; + blendMode: BlendMode; + priority: number; + source: LayerName; +} + +export class MotionLayerSystem { + private tracks = new Map(); + private semanticLayer: SemanticParameterLayer; + + constructor( + semanticLayer: SemanticParameterLayer, + config: MotionLayerConfig = {}, + ) { + this.semanticLayer = semanticLayer; + + for (const [name, defaultConfig] of Object.entries(DEFAULT_LAYER_CONFIGS)) { + const layerConfig = config.layers?.[name as LayerName]; + this.tracks.set(name as LayerName, new MotionTrack({ + ...defaultConfig, + ...layerConfig, + name: name as LayerName, + })); + } + } + + /** + * Play parameters on a specific layer. + */ + play(options: PlayOptions): void { + const track = this.tracks.get(options.layer); + if (!track) return; + + // Check interrupt rules + if (track.isActive() && !track.isInterruptible()) { + const incomingPriority = options.priority ?? track.getPriority(); + if (incomingPriority <= track.getPriority()) { + return; // Cannot interrupt + } + } + + // If track is already active, fade out current before playing new (crossfade) + if (track.isActive()) { + const crossfadeDuration = typeof options.fadeIn === "number" + ? options.fadeIn + : options.fadeIn?.duration; + track.stop(crossfadeDuration); + } + + track.play(options); + } + + /** + * Stop a layer with optional fade out. + */ + stop(layer: LayerName, fadeOutDuration?: number): void { + const track = this.tracks.get(layer); + track?.stop(fadeOutDuration); + } + + /** + * Crossfade from current parameters to new parameters on the same layer. + */ + crossfade(layer: LayerName, options: Omit): void { + const track = this.tracks.get(layer); + if (!track) return; + + const crossfadeDuration = typeof options.fadeIn === "number" + ? options.fadeIn + : options.fadeIn?.duration ?? 300; + + // Fade out current + track.stop(crossfadeDuration); + + // Play new with same fade duration for smooth crossfade + this.play({ + ...options, + layer, + fadeIn: crossfadeDuration, + }); + } + + /** + * Clear all layers. + */ + clearAll(): void { + for (const track of this.tracks.values()) { + track.clear(); + } + } + + /** + * Update all tracks and apply blended outputs to SemanticParameterLayer. + */ + update(dt: number): void { + // Update all tracks + for (const track of this.tracks.values()) { + track.update(dt); + } + + // Collect outputs from all tracks + const allOutputs: BlendedOutput[] = []; + + for (const track of this.tracks.values()) { + const outputs = track.getOutputs(); + for (const output of outputs) { + allOutputs.push({ + ...output, + priority: track.getPriority(), + source: track.name, + }); + } + } + + // Group by semantic and blend + const grouped = new Map(); + for (const output of allOutputs) { + const list = grouped.get(output.semantic) ?? []; + list.push(output); + grouped.set(output.semantic, list); + } + + // Apply blended values + for (const [semantic, outputs] of grouped) { + if (!this.semanticLayer.hasSemantic(semantic)) continue; + + // Separate override and add outputs + const overrides = outputs.filter((o) => o.blendMode === "override"); + const adds = outputs.filter((o) => o.blendMode === "add"); + + // Find highest-priority override + let finalValue = 0; + let hasOverride = false; + + if (overrides.length > 0) { + const highest = overrides.reduce((a, b) => + a.priority > b.priority ? a : b, + ); + finalValue = highest.value; + hasOverride = true; + } + + // Sum all add outputs + if (adds.length > 0) { + const addSum = adds.reduce((sum, o) => sum + o.value, 0); + if (hasOverride) { + finalValue += addSum; + } else { + finalValue = addSum; + } + } + + this.semanticLayer.setSemantic( + semantic, + finalValue, + hasOverride ? "override" : "add", + "motion", + ); + } + } + + /** + * Check if a layer is currently playing. + */ + isPlaying(layer: LayerName): boolean { + return this.tracks.get(layer)?.isActive() ?? false; + } + + /** + * Get all active layer names. + */ + getActiveLayers(): LayerName[] { + return Array.from(this.tracks.entries()) + .filter(([, track]) => track.isActive()) + .map(([name]) => name); + } + + /** + * Get detailed status of all layers. + */ + getLayerStatuses(): LayerStatus[] { + return Array.from(this.tracks.entries()).map(([name, track]) => ({ + name, + state: track.getState(), + priority: track.getPriority(), + weight: track.getWeight(), + activeParameters: Array.from( + track.getOutputs().map((o) => o.semantic), + ), + })); + } + + /** + * Get a specific track. + */ + getTrack(layer: LayerName): MotionTrack | undefined { + return this.tracks.get(layer); + } + + /** + * Set parameters on the physics layer directly (for continuous updates). + * This bypasses fade transitions. + */ + setPhysicsParameters( + parameters: Record, + ): void { + const physics = this.tracks.get("physics"); + physics?.setParameters(parameters); + } +} diff --git a/packages/live2d/src/runtime/motion/motion-track.ts b/packages/live2d/src/runtime/motion/motion-track.ts new file mode 100644 index 0000000..2a6cf57 --- /dev/null +++ b/packages/live2d/src/runtime/motion/motion-track.ts @@ -0,0 +1,149 @@ +import type { BlendMode, LayerName, LayerState, PlayOptions, TrackConfig } from "./types"; +import { FadeEnvelope } from "./fade-envelope"; + +export interface TrackOutput { + semantic: string; + value: number; + blendMode: BlendMode; +} + +export class MotionTrack { + readonly name: LayerName; + private priority: number; + private envelope: FadeEnvelope; + private currentParameters = new Map(); + private config: TrackConfig; + private remainingDuration: number | null = null; + + constructor(config: TrackConfig) { + this.name = config.name; + this.priority = config.priority; + this.config = config; + this.envelope = new FadeEnvelope({ + fadeInDuration: config.defaultFadeIn?.duration, + fadeOutDuration: config.defaultFadeOut?.duration, + }); + } + + /** + * Set parameters directly (for continuous updates like physics layer). + * No fade, weight stays at 1. + */ + setParameters( + parameters: Record, + ): void { + this.currentParameters.clear(); + for (const [semantic, { value, blendMode }] of Object.entries(parameters)) { + this.currentParameters.set(semantic, { + value, + blendMode: blendMode ?? "add", + }); + } + this.envelope.activate(); + } + + /** + * Start playing parameters on this track with fade. + */ + play(options: PlayOptions): void { + this.priority = options.priority ?? this.config.priority; + this.currentParameters.clear(); + for (const [semantic, { value, blendMode }] of Object.entries( + options.parameters, + )) { + this.currentParameters.set(semantic, { + value, + blendMode: blendMode ?? "override", + }); + } + + // Set fade config + const fadeInDuration = + typeof options.fadeIn === "number" + ? options.fadeIn + : options.fadeIn?.duration; + const fadeOutDuration = + typeof options.fadeOut === "number" + ? options.fadeOut + : options.fadeOut?.duration; + if (fadeInDuration !== undefined || fadeOutDuration !== undefined) { + this.envelope = new FadeEnvelope({ fadeInDuration, fadeOutDuration }); + } + + this.envelope.beginFadeIn(fadeInDuration); + this.remainingDuration = options.duration ?? null; + } + + /** + * Stop this track with optional fade out. + */ + stop(fadeOutDuration?: number): void { + this.envelope.beginFadeOut(fadeOutDuration); + } + + /** + * Immediately clear all parameters. + */ + clear(): void { + this.currentParameters.clear(); + this.envelope.stop(); + } + + /** + * Update the track state by elapsed time. + */ + update(dt: number): void { + this.envelope.update(dt); + + if (this.remainingDuration !== null) { + this.remainingDuration -= dt; + if (this.remainingDuration <= 0) { + this.remainingDuration = null; + this.envelope.beginFadeOut(); + } + } + + // If stopped and weight is 0, clear parameters + if (this.envelope.isStopped()) { + this.currentParameters.clear(); + } + } + + /** + * Get current output with weight applied. + */ + getOutputs(): TrackOutput[] { + const weight = this.envelope.getWeight(); + if (weight <= 0) return []; + + return Array.from(this.currentParameters.entries()).map(([semantic, { value, blendMode }]) => ({ + semantic, + value: value * weight, + blendMode, + })); + } + + getState(): LayerState { + return this.envelope.state; + } + + getPriority(): number { + return this.priority; + } + + getWeight(): number { + return this.envelope.getWeight(); + } + + isActive(): boolean { + return this.envelope.isActive(); + } + + isInterruptible(): boolean { + return this.config.interruptible ?? true; + } + + hasParameter(semantic: string): boolean { + return this.currentParameters.has(semantic); + } +} diff --git a/packages/live2d/src/runtime/motion/types.ts b/packages/live2d/src/runtime/motion/types.ts new file mode 100644 index 0000000..2cacb6b --- /dev/null +++ b/packages/live2d/src/runtime/motion/types.ts @@ -0,0 +1,52 @@ +import type { EasingFunction } from "../procedural/easing"; + +export type LayerName = "idle" | "expression" | "talk" | "gesture" | "physics"; + +export type LayerState = "idle" | "fadingIn" | "active" | "fadingOut" | "stopped"; + +export type BlendMode = "override" | "add"; + +export interface FadeConfig { + duration: number; + curve: EasingFunction; +} + +export interface TrackConfig { + name: LayerName; + priority: number; + defaultFadeIn?: FadeConfig; + defaultFadeOut?: FadeConfig; + interruptible?: boolean; +} + +export interface LayerOutput { + semantic: string; + value: number; + blendMode: BlendMode; + weight: number; + source: LayerName; +} + +export interface PlayOptions { + layer: LayerName; + parameters: Record; + priority?: number; + fadeIn?: number | FadeConfig; + fadeOut?: number | FadeConfig; + interruptible?: boolean; + duration?: number; +} + +export interface MotionLayerConfig { + enabled?: boolean; + layers?: Partial>>; + defaultCrossfadeDuration?: number; +} + +export interface LayerStatus { + name: LayerName; + state: LayerState; + priority: number; + weight: number; + activeParameters: string[]; +} diff --git a/packages/live2d/src/runtime/procedural/__tests__/procedural-animation-system.test.ts b/packages/live2d/src/runtime/procedural/__tests__/procedural-animation-system.test.ts new file mode 100644 index 0000000..d96910f --- /dev/null +++ b/packages/live2d/src/runtime/procedural/__tests__/procedural-animation-system.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { MutableParameterSet } from "../parameter-set"; +import { BreathingModule, BlinkModule, EyeTrackingModule } from "../modules"; +import { getEasing, easeOut, linear } from "../easing"; + +describe("MutableParameterSet", () => { + it("sets and gets values", () => { + const set = new MutableParameterSet(); + set.set("angleX", 10, "override"); + + expect(set.has("angleX")).toBe(true); + expect(set.get("angleX")?.value).toBe(10); + expect(set.get("angleX")?.blendMode).toBe("override"); + }); + + it("override wins over add", () => { + const set = new MutableParameterSet(); + set.set("angleX", 5, "add"); + set.set("angleX", 15, "override"); + + expect(set.get("angleX")?.value).toBe(15); + expect(set.get("angleX")?.blendMode).toBe("override"); + }); + + it("sums multiple add operations", () => { + const set = new MutableParameterSet(); + set.set("breath", 0.1, "add"); + set.set("breath", 0.2, "add"); + + expect(set.get("breath")?.value).toBeCloseTo(0.3, 5); + expect(set.get("breath")?.blendMode).toBe("add"); + }); + + it("existing override blocks add", () => { + const set = new MutableParameterSet(); + set.set("angleX", 15, "override"); + set.set("angleX", 5, "add"); + + expect(set.get("angleX")?.value).toBe(15); + expect(set.get("angleX")?.blendMode).toBe("override"); + }); + + it("iterates over all entries", () => { + const set = new MutableParameterSet(); + set.set("a", 1, "override"); + set.set("b", 2, "add"); + + const entries: string[] = []; + set.forEach((semantic, value, blendMode) => { + entries.push(`${semantic}:${value}:${blendMode}`); + }); + + expect(entries).toContain("a:1:override"); + expect(entries).toContain("b:2:add"); + }); + + it("clears all entries", () => { + const set = new MutableParameterSet(); + set.set("a", 1, "override"); + set.clear(); + + expect(set.has("a")).toBe(false); + }); +}); + +describe("BreathingModule", () => { + it("outputs breath parameter", () => { + const module = new BreathingModule({ period: 3000, amplitude: 0.2 }); + const params = new MutableParameterSet(); + + module.update(0, params); + expect(params.has("breath")).toBe(true); + expect(params.get("breath")?.blendMode).toBe("add"); + }); + + it("produces oscillating values", () => { + const module = new BreathingModule({ period: 1000, amplitude: 1 }); + const params1 = new MutableParameterSet(); + const params2 = new MutableParameterSet(); + + module.update(0, params1); + module.update(250, params2); + + const val1 = params1.get("breath")?.value ?? 0; + const val2 = params2.get("breath")?.value ?? 0; + // At t=250 with period=1000, should be different from t=0 + expect(val2).not.toBe(val1); + }); +}); + +describe("BlinkModule", () => { + it("starts with eyes open", () => { + const module = new BlinkModule(); + const params = new MutableParameterSet(); + + module.update(0, params); + expect(params.get("eyeLOpen")?.value).toBe(1); + expect(params.get("eyeROpen")?.value).toBe(1); + }); + + it("closes eyes during blink", () => { + const module = new BlinkModule({ minInterval: 0, maxInterval: 0, duration: 100 }); + const params = new MutableParameterSet(); + + // Trigger blink immediately + module.update(0, params); + + // During blink (halfway through closing phase) + const params2 = new MutableParameterSet(); + module.update(30, params2); + + const openness = params2.get("eyeLOpen")?.value ?? 1; + expect(openness).toBeLessThan(1); + }); +}); + +describe("EyeTrackingModule", () => { + it("outputs angle parameters", () => { + const module = new EyeTrackingModule(); + const params = new MutableParameterSet(); + + module.updateCursorPosition(100, 100, { + left: 0, + top: 0, + width: 200, + height: 200, + } as DOMRect); + + // Need multiple updates for smoothing + for (let i = 0; i < 10; i++) { + module.update(16, params); + } + + expect(params.has("angleX")).toBe(true); + expect(params.has("angleY")).toBe(true); + expect(params.has("eyeBallX")).toBe(true); + expect(params.has("eyeBallY")).toBe(true); + }); + + it("returns to center when cursor leaves", () => { + const module = new EyeTrackingModule(); + const params = new MutableParameterSet(); + + module.updateCursorPosition(100, 100, { + left: 0, + top: 0, + width: 200, + height: 200, + } as DOMRect); + + // Update to move away from center + for (let i = 0; i < 20; i++) { + module.update(16, params); + } + + module.onCursorLeave(); + + // Give time to return to center + const centerParams = new MutableParameterSet(); + for (let i = 0; i < 50; i++) { + module.update(16, centerParams); + } + + const angleX = centerParams.get("angleX")?.value ?? 999; + expect(Math.abs(angleX)).toBeLessThan(1); + }); +}); + +describe("Easing functions", () => { + it("linear goes from 0 to 1", () => { + expect(linear(0)).toBe(0); + expect(linear(0.5)).toBe(0.5); + expect(linear(1)).toBe(1); + }); + + it("easeOut slows near end", () => { + expect(easeOut(0)).toBe(0); + expect(easeOut(1)).toBe(1); + expect(easeOut(0.5)).toBeGreaterThan(0.5); + }); + + it("getEasing returns correct function", () => { + expect(getEasing("linear")).toBe(linear); + expect(getEasing("easeOut")).toBe(easeOut); + expect(getEasing("unknown")).toBe(easeOut); // default + }); +}); diff --git a/packages/live2d/src/runtime/procedural/animator.ts b/packages/live2d/src/runtime/procedural/animator.ts new file mode 100644 index 0000000..1fb66b5 --- /dev/null +++ b/packages/live2d/src/runtime/procedural/animator.ts @@ -0,0 +1,70 @@ +import type { SemanticParameterLayer } from "../semantic"; +import { getEasing } from "./easing"; +import type { EasingFunction } from "./easing"; +import type { AnimationOptions, ParameterSet, ProceduralModule } from "./types"; + +interface ActiveAnimation { + target: string; + from: number; + to: number; + duration: number; + elapsed: number; + easing: EasingFunction; + onComplete?: () => void; +} + +export class ProceduralAnimator implements ProceduralModule { + readonly name = "animator"; + enabled = true; + private animations: ActiveAnimation[] = []; + private semanticLayer: SemanticParameterLayer; + + constructor(semanticLayer: SemanticParameterLayer) { + this.semanticLayer = semanticLayer; + } + + animate(options: AnimationOptions): Promise { + const currentValue = this.semanticLayer.getSemantic(options.target) ?? 0; + const easing = + typeof options.easing === "string" + ? getEasing(options.easing) + : options.easing ?? getEasing("easeOut"); + + return new Promise((resolve) => { + this.animations.push({ + target: options.target, + from: currentValue, + to: options.to, + duration: options.duration, + elapsed: 0, + easing, + onComplete: resolve, + }); + }); + } + + update(dt: number, params: ParameterSet): void { + const completed: ActiveAnimation[] = []; + + for (const anim of this.animations) { + anim.elapsed += dt; + const progress = Math.min(1, anim.elapsed / anim.duration); + const easedProgress = anim.easing(progress); + const value = anim.from + (anim.to - anim.from) * easedProgress; + + params.set(anim.target, value, "override"); + + if (progress >= 1) { + completed.push(anim); + } + } + + for (const anim of completed) { + const index = this.animations.indexOf(anim); + if (index >= 0) { + this.animations.splice(index, 1); + } + anim.onComplete?.(); + } + } +} diff --git a/packages/live2d/src/runtime/procedural/easing.ts b/packages/live2d/src/runtime/procedural/easing.ts new file mode 100644 index 0000000..606afa9 --- /dev/null +++ b/packages/live2d/src/runtime/procedural/easing.ts @@ -0,0 +1,38 @@ +export type EasingFunction = (t: number) => number; + +export const linear: EasingFunction = (t) => t; + +export const easeIn: EasingFunction = (t) => t * t; + +export const easeOut: EasingFunction = (t) => 1 - (1 - t) * (1 - t); + +export const easeInOut: EasingFunction = (t) => + t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + +export const easeOutSpring: EasingFunction = (t) => { + // Critically damped spring approximation + const c4 = (2 * Math.PI) / 3; + return t === 0 + ? 0 + : t === 1 + ? 1 + : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1; +}; + +export function getEasing(name: string): EasingFunction { + switch (name) { + case "linear": + return linear; + case "easeIn": + return easeIn; + case "easeOut": + return easeOut; + case "easeInOut": + return easeInOut; + case "spring": + case "easeOutSpring": + return easeOutSpring; + default: + return easeOut; + } +} diff --git a/packages/live2d/src/runtime/procedural/index.ts b/packages/live2d/src/runtime/procedural/index.ts new file mode 100644 index 0000000..fbb30a0 --- /dev/null +++ b/packages/live2d/src/runtime/procedural/index.ts @@ -0,0 +1,13 @@ +export { ProceduralAnimationSystem } from "./procedural-animation-system"; +export type { ProceduralOutputCallback } from "./procedural-animation-system"; +export { ProceduralAnimator } from "./animator"; +export { MutableParameterSet } from "./parameter-set"; +export { BreathingModule, BlinkModule, EyeTrackingModule } from "./modules"; +export { getEasing, linear, easeIn, easeOut, easeInOut, easeOutSpring } from "./easing"; +export type { + ProceduralModule, + ParameterSet, + AnimationOptions, + ProceduralConfig, +} from "./types"; +export type { EasingFunction } from "./easing"; diff --git a/packages/live2d/src/runtime/procedural/modules.ts b/packages/live2d/src/runtime/procedural/modules.ts new file mode 100644 index 0000000..f09f889 --- /dev/null +++ b/packages/live2d/src/runtime/procedural/modules.ts @@ -0,0 +1,187 @@ +import type { ProceduralModule, ParameterSet } from "./types"; + +export class BreathingModule implements ProceduralModule { + readonly name = "breathing"; + enabled = true; + private phase = 0; + private period: number; + private amplitude: number; + + constructor(options: { period?: number; amplitude?: number } = {}) { + this.period = options.period ?? 3000; + this.amplitude = options.amplitude ?? 0.15; + } + + update(dt: number, params: ParameterSet): void { + this.phase += dt; + const normalizedTime = this.phase / this.period; + const value = Math.sin(normalizedTime * Math.PI * 2) * 0.5 + 0.5; + params.set("breath", value * this.amplitude, "add"); + } +} + +export class BlinkModule implements ProceduralModule { + readonly name = "blink"; + enabled = true; + private state: "open" | "closing" | "closed" | "opening" = "open"; + private timer = 0; + private nextBlinkTime = 0; + private blinkProgress = 0; + private minInterval: number; + private maxInterval: number; + private duration: number; + + constructor(options: { + minInterval?: number; + maxInterval?: number; + duration?: number; + } = {}) { + this.minInterval = options.minInterval ?? 2000; + this.maxInterval = options.maxInterval ?? 6000; + this.duration = options.duration ?? 150; + this.scheduleNextBlink(); + } + + update(dt: number, params: ParameterSet): void { + switch (this.state) { + case "open": { + this.timer += dt; + if (this.timer >= this.nextBlinkTime) { + this.state = "closing"; + this.blinkProgress = 0; + this.timer = 0; + } + params.set("eyeLOpen", 1, "override"); + params.set("eyeROpen", 1, "override"); + break; + } + case "closing": { + this.blinkProgress += dt / (this.duration * 0.5); + if (this.blinkProgress >= 1) { + this.state = "opening"; + this.blinkProgress = 1; + } + const openness = 1 - this.easeInOut(this.blinkProgress); + params.set("eyeLOpen", openness, "override"); + params.set("eyeROpen", openness, "override"); + break; + } + case "opening": { + this.blinkProgress -= dt / (this.duration * 0.5); + if (this.blinkProgress <= 0) { + this.state = "open"; + this.timer = 0; + this.scheduleNextBlink(); + } + const openness = 1 - this.easeInOut(Math.max(0, 1 - this.blinkProgress)); + params.set("eyeLOpen", openness, "override"); + params.set("eyeROpen", openness, "override"); + break; + } + case "closed": { + params.set("eyeLOpen", 0, "override"); + params.set("eyeROpen", 0, "override"); + break; + } + } + } + + private scheduleNextBlink(): void { + this.nextBlinkTime = + this.minInterval + + Math.random() * (this.maxInterval - this.minInterval); + } + + private easeInOut(t: number): number { + return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + } +} + +export class EyeTrackingModule implements ProceduralModule { + readonly name = "eyeTracking"; + enabled = true; + private targetX = 0; + private targetY = 0; + private currentX = 0; + private currentY = 0; + private overrideTarget: { x: number; y: number } | null = null; + private maxAngleX: number; + private maxAngleY: number; + private maxEyeBallX: number; + private maxEyeBallY: number; + private smoothing: number; + private cursorLeaveTimeout: number | null = null; + private isCursorOverCanvas = false; + + constructor(options: { + maxAngleX?: number; + maxAngleY?: number; + maxEyeBallX?: number; + maxEyeBallY?: number; + smoothing?: number; + } = {}) { + this.maxAngleX = options.maxAngleX ?? 15; + this.maxAngleY = options.maxAngleY ?? 10; + this.maxEyeBallX = options.maxEyeBallX ?? 1.5; + this.maxEyeBallY = options.maxEyeBallY ?? 1.5; + this.smoothing = options.smoothing ?? 0.15; + } + + setTarget(x: number, y: number): void { + this.overrideTarget = { x, y }; + } + + releaseTarget(): void { + this.overrideTarget = null; + } + + updateCursorPosition(x: number, y: number, canvasRect: DOMRect): void { + this.isCursorOverCanvas = true; + if (this.cursorLeaveTimeout !== null) { + clearTimeout(this.cursorLeaveTimeout); + this.cursorLeaveTimeout = null; + } + + const nx = ((x - canvasRect.left) / canvasRect.width) * 2 - 1; + const ny = ((y - canvasRect.top) / canvasRect.height) * 2 - 1; + this.targetX = Math.max(-1, Math.min(1, nx)); + // Screen Y increases downward, but model Y increases upward — negate + this.targetY = Math.max(-1, Math.min(1, -ny)); + } + + onCursorLeave(): void { + if (this.cursorLeaveTimeout !== null) { + clearTimeout(this.cursorLeaveTimeout); + } + this.cursorLeaveTimeout = window.setTimeout(() => { + this.isCursorOverCanvas = false; + }, 100); + } + + update(_dt: number, params: ParameterSet): void { + let tx: number; + let ty: number; + + if (this.overrideTarget !== null) { + tx = this.overrideTarget.x; + ty = this.overrideTarget.y; + } else if (this.isCursorOverCanvas) { + tx = this.targetX; + ty = this.targetY; + } else { + // Return to center when cursor leaves + tx = 0; + ty = 0; + } + + // Smooth interpolation + const t = this.smoothing; + this.currentX += (tx - this.currentX) * t; + this.currentY += (ty - this.currentY) * t; + + params.set("angleX", this.currentX * this.maxAngleX, "add"); + params.set("angleY", this.currentY * this.maxAngleY, "add"); + params.set("eyeBallX", this.currentX * this.maxEyeBallX, "add"); + params.set("eyeBallY", this.currentY * this.maxEyeBallY, "add"); + } +} diff --git a/packages/live2d/src/runtime/procedural/parameter-set.ts b/packages/live2d/src/runtime/procedural/parameter-set.ts new file mode 100644 index 0000000..7c3046a --- /dev/null +++ b/packages/live2d/src/runtime/procedural/parameter-set.ts @@ -0,0 +1,51 @@ +import type { BlendMode, SemanticName } from "../semantic/types"; +import type { ParameterSet as IParameterSet } from "./types"; + +export class MutableParameterSet implements IParameterSet { + private entries = new Map< + SemanticName, + { value: number; blendMode: BlendMode } + >(); + + set(semantic: SemanticName, value: number, blendMode: BlendMode): void { + const existing = this.entries.get(semantic); + if (existing) { + if (existing.blendMode === "add" && blendMode === "add") { + // Sum multiple add operations + this.entries.set(semantic, { + value: existing.value + value, + blendMode: "add", + }); + return; + } + if (blendMode === "override") { + // Override wins over existing + this.entries.set(semantic, { value, blendMode: "override" }); + return; + } + // Existing is override, new is add → keep override + return; + } + this.entries.set(semantic, { value, blendMode }); + } + + get(semantic: SemanticName): { value: number; blendMode: BlendMode } | undefined { + return this.entries.get(semantic); + } + + has(semantic: SemanticName): boolean { + return this.entries.has(semantic); + } + + forEach( + callback: (semantic: SemanticName, value: number, blendMode: BlendMode) => void, + ): void { + for (const [semantic, { value, blendMode }] of this.entries) { + callback(semantic, value, blendMode); + } + } + + clear(): void { + this.entries.clear(); + } +} diff --git a/packages/live2d/src/runtime/procedural/procedural-animation-system.ts b/packages/live2d/src/runtime/procedural/procedural-animation-system.ts new file mode 100644 index 0000000..93e41f8 --- /dev/null +++ b/packages/live2d/src/runtime/procedural/procedural-animation-system.ts @@ -0,0 +1,154 @@ +import type { Ticker } from "pixi.js"; +import type { SemanticParameterLayer } from "../semantic"; +import { MutableParameterSet } from "./parameter-set"; +import type { ProceduralConfig, ProceduralModule } from "./types"; +import { BreathingModule, BlinkModule, EyeTrackingModule } from "./modules"; +import { ProceduralAnimator } from "./animator"; + +const MAX_DT_MS = 100; + +export type ProceduralOutputCallback = ( + params: Array<{ semantic: string; value: number; blendMode: "override" | "add" }>, +) => void; + +export class ProceduralAnimationSystem { + private modules: ProceduralModule[] = []; + private parameterSet = new MutableParameterSet(); + private semanticLayer: SemanticParameterLayer; + private tickerCallback?: () => void; + private animator: ProceduralAnimator; + private eyeTrackingModule?: EyeTrackingModule; + private outputCallback?: ProceduralOutputCallback; + + constructor( + semanticLayer: SemanticParameterLayer, + config: ProceduralConfig = {}, + ) { + this.semanticLayer = semanticLayer; + this.animator = new ProceduralAnimator(semanticLayer); + + if (config.enabled !== false) { + if (config.breathing?.enabled !== false) { + this.register( + new BreathingModule({ + period: config.breathing?.period, + amplitude: config.breathing?.amplitude, + }), + ); + } + + if (config.blink?.enabled === true) { + this.register( + new BlinkModule({ + minInterval: config.blink?.minInterval, + maxInterval: config.blink?.maxInterval, + duration: config.blink?.duration, + }), + ); + } + + if (config.eyeTracking?.enabled !== false) { + this.eyeTrackingModule = new EyeTrackingModule({ + maxAngleX: config.eyeTracking?.maxAngleX, + maxAngleY: config.eyeTracking?.maxAngleY, + maxEyeBallX: config.eyeTracking?.maxEyeBallX, + maxEyeBallY: config.eyeTracking?.maxEyeBallY, + smoothing: config.eyeTracking?.smoothing, + }); + this.register(this.eyeTrackingModule); + } + } + + // Animator is always registered (but only active when animations are queued) + this.register(this.animator); + } + + /** + * Set an output callback to receive procedural parameter changes + * instead of writing directly to SemanticParameterLayer. + * When set, the callback receives the parameters each frame. + * When not set, parameters are written directly to SemanticParameterLayer. + */ + setOutputCallback(callback: ProceduralOutputCallback | undefined): void { + this.outputCallback = callback; + } + + attachTo(ticker: Ticker): void { + this.tickerCallback = () => { + // Cap dt to prevent large jumps + const cappedDt = Math.min(ticker.deltaMS, MAX_DT_MS); + this.update(cappedDt); + }; + ticker.add(this.tickerCallback); + } + + detach(ticker?: Ticker): void { + if (this.tickerCallback && ticker) { + ticker.remove(this.tickerCallback); + } + this.tickerCallback = undefined; + this.modules = []; + this.parameterSet.clear(); + } + + register(module: ProceduralModule): void { + this.modules.push(module); + } + + unregister(module: ProceduralModule): void { + const index = this.modules.indexOf(module); + if (index >= 0) { + this.modules.splice(index, 1); + } + } + + enableModule(name: string): void { + const module = this.modules.find((m) => m.name === name); + if (module) module.enabled = true; + } + + disableModule(name: string): void { + const module = this.modules.find((m) => m.name === name); + if (module) module.enabled = false; + } + + getAnimator(): ProceduralAnimator { + return this.animator; + } + + getEyeTrackingModule(): EyeTrackingModule | undefined { + return this.eyeTrackingModule; + } + + /** + * Get the current status of all registered modules. + */ + getModuleStatuses(): Array<{ name: string; enabled: boolean }> { + return this.modules.map((m) => ({ name: m.name, enabled: m.enabled })); + } + + private update(dt: number): void { + this.parameterSet.clear(); + + for (const module of this.modules) { + if (module.enabled) { + module.update(dt, this.parameterSet); + } + } + + const outputs: Array<{ semantic: string; value: number; blendMode: "override" | "add" }> = []; + this.parameterSet.forEach((semantic, value, blendMode) => { + outputs.push({ semantic, value, blendMode }); + }); + + if (this.outputCallback) { + // Route through motion layer system + this.outputCallback(outputs); + } else { + // Direct write (backward compatible) + for (const { semantic, value, blendMode } of outputs) { + this.semanticLayer.setSemantic(semantic, value, blendMode, "procedural", 5); + } + } + } +} diff --git a/packages/live2d/src/runtime/procedural/types.ts b/packages/live2d/src/runtime/procedural/types.ts new file mode 100644 index 0000000..8969381 --- /dev/null +++ b/packages/live2d/src/runtime/procedural/types.ts @@ -0,0 +1,45 @@ +import type { BlendMode } from "../semantic/types"; +import type { EasingFunction } from "./easing"; + +export interface ProceduralModule { + readonly name: string; + enabled: boolean; + update(dt: number, params: ParameterSet): void; +} + +export interface ParameterSet { + set(semantic: string, value: number, blendMode: BlendMode): void; + get(semantic: string): { value: number; blendMode: BlendMode } | undefined; + has(semantic: string): boolean; + forEach(callback: (semantic: string, value: number, blendMode: BlendMode) => void): void; +} + +export interface AnimationOptions { + target: string; + to: number; + duration: number; + easing?: EasingFunction | string; +} + +export interface ProceduralConfig { + enabled?: boolean; + breathing?: { + enabled?: boolean; + period?: number; + amplitude?: number; + }; + blink?: { + enabled?: boolean; + minInterval?: number; + maxInterval?: number; + duration?: number; + }; + eyeTracking?: { + enabled?: boolean; + maxAngleX?: number; + maxAngleY?: number; + maxEyeBallX?: number; + maxEyeBallY?: number; + smoothing?: number; + }; +} diff --git a/packages/live2d/src/runtime/semantic/__tests__/semantic-parameter-layer.test.ts b/packages/live2d/src/runtime/semantic/__tests__/semantic-parameter-layer.test.ts new file mode 100644 index 0000000..86d6c8e --- /dev/null +++ b/packages/live2d/src/runtime/semantic/__tests__/semantic-parameter-layer.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from "vitest"; +import { SemanticParameterLayer } from "../semantic-parameter-layer"; + +// Mock Cubism 2 (Legacy) core model +function createCubism2MockModel( + paramIds: string[], +): object { + const params = new Map(); + for (const id of paramIds) { + params.set(id, 0); + } + + return { + getParamFloat(id: string | number): number { + if (typeof id === "number") { + return Array.from(params.values())[id] ?? 0; + } + return params.get(id) ?? 0; + }, + setParamFloat(id: string | number, value: number): void { + if (typeof id === "number") { + const key = Array.from(params.keys())[id]; + if (key) params.set(key, value); + } else { + params.set(id, value); + } + }, + getParamIndex(id: string): number { + const keys = Array.from(params.keys()); + return keys.indexOf(id); + }, + }; +} + +// Mock Cubism 4/5 core model +function createCubism4MockModel( + paramIds: string[], +): object { + const values = new Float32Array(paramIds.length); + const minimumValues = new Float32Array(paramIds.length).fill(-30); + const maximumValues = new Float32Array(paramIds.length).fill(30); + const defaultValues = new Float32Array(paramIds.length); + + return { + _model: { + parameters: { + ids: paramIds, + values, + minimumValues, + maximumValues, + defaultValues, + }, + }, + getParameterValueByIndex(index: number): number { + return values[index] ?? 0; + }, + setParameterValueByIndex(index: number, value: number): void { + if (index >= 0 && index < values.length) { + values[index] = value; + } + }, + }; +} + +// Wrap core model in internalModel like untitled-pixi-live2d-engine does +function wrapModel(coreModel: object): object { + return { internalModel: { coreModel } }; +} + +describe("SemanticParameterLayer", () => { + describe("Cubism 2 model detection", () => { + it("resolves mouthOpen for Cubism 2 model (PARAM_MOUTH_OPEN_Y)", () => { + const layer = new SemanticParameterLayer(); + const core = createCubism2MockModel([ + "PARAM_MOUTH_OPEN_Y", + "PARAM_ANGLE_X", + "PARAM_EYE_L_OPEN", + ]); + const profile = layer.detectFromModel(wrapModel(core)); + + expect(profile.detected.has("mouthOpen")).toBe(true); + expect(profile.detected.get("mouthOpen")).toBe("PARAM_MOUTH_OPEN_Y"); + expect(layer.hasSemantic("mouthOpen")).toBe(true); + }); + + it("reports missing parameters", () => { + const layer = new SemanticParameterLayer(); + const core = createCubism2MockModel(["PARAM_ANGLE_X"]); + const profile = layer.detectFromModel(wrapModel(core)); + + expect(profile.detected.has("angleX")).toBe(true); + expect(profile.missing).toContain("mouthOpen"); + expect(profile.missing).toContain("eyeLOpen"); + }); + }); + + describe("Cubism 4/5 model detection", () => { + it("resolves mouthOpen for Cubism 4/5 model (PARAM_MOUTH_A)", () => { + const layer = new SemanticParameterLayer(); + const core = createCubism4MockModel([ + "PARAM_MOUTH_A", + "PARAM_ANGLE_X", + "PARAM_EYE_L_OPEN", + ]); + const profile = layer.detectFromModel(wrapModel(core)); + + expect(profile.detected.has("mouthOpen")).toBe(true); + expect(profile.detected.get("mouthOpen")).toBe("PARAM_MOUTH_A"); + expect(layer.hasSemantic("mouthOpen")).toBe(true); + }); + + it("resolves multiple semantics correctly", () => { + const layer = new SemanticParameterLayer(); + const core = createCubism4MockModel([ + "ParamAngleX", + "ParamEyeBallX", + "ParamBreath", + "ParamEyeLOpen", + "ParamEyeROpen", + ]); + const profile = layer.detectFromModel(wrapModel(core)); + + expect(profile.detected.has("angleX")).toBe(true); + expect(profile.detected.has("eyeBallX")).toBe(true); + expect(profile.detected.has("breath")).toBe(true); + expect(profile.detected.has("eyeLOpen")).toBe(true); + expect(profile.detected.has("eyeROpen")).toBe(true); + }); + }); + + describe("setSemantic", () => { + it("override mode replaces parameter value", () => { + const layer = new SemanticParameterLayer(); + const core = createCubism4MockModel(["PARAM_ANGLE_X"]); + layer.detectFromModel(wrapModel(core)); + + layer.setSemantic("angleX", 15, "override"); + expect(layer.getSemantic("angleX")).toBe(15); + + layer.setSemantic("angleX", -10, "override"); + expect(layer.getSemantic("angleX")).toBe(-10); + }); + + it("add mode adds to parameter value", () => { + const layer = new SemanticParameterLayer(); + const core = createCubism4MockModel(["PARAM_ANGLE_X"]); + layer.detectFromModel(wrapModel(core)); + + layer.setSemantic("angleX", 10, "override"); + expect(layer.getSemantic("angleX")).toBe(10); + + layer.setSemantic("angleX", 5, "add"); + expect(layer.getSemantic("angleX")).toBe(15); + }); + + it("clamps out-of-bounds values", () => { + const layer = new SemanticParameterLayer(); + const core = createCubism4MockModel(["PARAM_ANGLE_X"]); + layer.detectFromModel(wrapModel(core)); + + layer.setSemantic("angleX", 100, "override"); + expect(layer.getSemantic("angleX")).toBe(30); // clamped to max + + layer.setSemantic("angleX", -100, "override"); + expect(layer.getSemantic("angleX")).toBe(-30); // clamped to min + }); + + it("silently ignores unmapped semantic", () => { + const layer = new SemanticParameterLayer(); + const core = createCubism4MockModel(["PARAM_ANGLE_X"]); + layer.detectFromModel(wrapModel(core)); + + // Should not throw + layer.setSemantic("nonExistent", 10); + expect(layer.getSemantic("nonExistent")).toBeUndefined(); + }); + }); + + describe("registerSemantic", () => { + it("adds custom mapping before detection", () => { + const layer = new SemanticParameterLayer(); + layer.registerSemantic("customEar", ["PARAM_EAR_WIGGLE", "CUSTOM_EAR"]); + + const core = createCubism4MockModel([ + "PARAM_ANGLE_X", + "CUSTOM_EAR", + ]); + const profile = layer.detectFromModel(wrapModel(core)); + + expect(profile.detected.has("customEar")).toBe(true); + expect(profile.detected.get("customEar")).toBe("CUSTOM_EAR"); + }); + + it("custom mapping overrides default", () => { + const layer = new SemanticParameterLayer(); + layer.registerSemantic("mouthOpen", ["CUSTOM_MOUTH"]); + + const core = createCubism4MockModel([ + "PARAM_MOUTH_A", + "CUSTOM_MOUTH", + ]); + const profile = layer.detectFromModel(wrapModel(core)); + + // Should resolve to CUSTOM_MOUTH because custom mapping takes precedence + expect(profile.detected.get("mouthOpen")).toBe("CUSTOM_MOUTH"); + }); + }); + + describe("CapabilityProfile", () => { + it("correctly categorizes detected and missing parameters", () => { + const layer = new SemanticParameterLayer(); + const core = createCubism4MockModel([ + "PARAM_MOUTH_A", + "PARAM_ANGLE_X", + ]); + const profile = layer.detectFromModel(wrapModel(core)); + + expect(profile.detected.size).toBe(2); + expect(profile.detected.has("mouthOpen")).toBe(true); + expect(profile.detected.has("angleX")).toBe(true); + expect(profile.missing.length).toBeGreaterThan(0); + expect(profile.missing).toContain("eyeLOpen"); + }); + + it("is accessible via getCapabilityProfile", () => { + const layer = new SemanticParameterLayer(); + const core = createCubism4MockModel(["PARAM_ANGLE_X"]); + layer.detectFromModel(wrapModel(core)); + + const profile = layer.getCapabilityProfile(); + expect(profile.detected.has("angleX")).toBe(true); + }); + }); + + describe("hit area lookup", () => { + function createModelWithHitAreas( + hitAreas: Record, + ): object { + return { + internalModel: { + coreModel: { + _model: { + parameters: { ids: [], values: [], minimumValues: [], maximumValues: [], defaultValues: [] }, + }, + getParameterValueByIndex: () => 0, + setParameterValueByIndex: () => {}, + }, + hitAreas, + getDrawableBounds: (index: number) => { + return { x: 0, y: index * 10, width: 50, height: 50 }; + }, + }, + }; + } + + it("finds hit area index by name pattern", () => { + const layer = new SemanticParameterLayer(); + const model = createModelWithHitAreas({ + head: { name: "Head", index: 5 }, + body: { name: "Body", index: 3 }, + }); + layer.detectFromModel(model); + + const index = layer.getHitAreaIndex(/head/i); + expect(index).toBe(5); + }); + + it("returns undefined when no hit area matches", () => { + const layer = new SemanticParameterLayer(); + const model = createModelWithHitAreas({ + body: { name: "Body", index: 3 }, + }); + layer.detectFromModel(model); + + const index = layer.getHitAreaIndex(/head/i); + expect(index).toBeUndefined(); + }); + + it("returns undefined when model has no hitAreas", () => { + const layer = new SemanticParameterLayer(); + const model = { + internalModel: { + coreModel: { + _model: { + parameters: { ids: [], values: [], minimumValues: [], maximumValues: [], defaultValues: [] }, + }, + getParameterValueByIndex: () => 0, + setParameterValueByIndex: () => {}, + }, + }, + }; + layer.detectFromModel(model); + + const index = layer.getHitAreaIndex(/head/i); + expect(index).toBeUndefined(); + }); + + it("gets drawable bounds by index", () => { + const layer = new SemanticParameterLayer(); + const model = createModelWithHitAreas({ + head: { name: "Head", index: 5 }, + }); + layer.detectFromModel(model); + + const bounds = layer.getDrawableBounds(5); + expect(bounds).toEqual({ x: 0, y: 50, width: 50, height: 50 }); + }); + + it("returns null for invalid drawable bounds", () => { + const layer = new SemanticParameterLayer(); + const model = { + internalModel: { + coreModel: { + _model: { + parameters: { ids: [], values: [], minimumValues: [], maximumValues: [], defaultValues: [] }, + }, + getParameterValueByIndex: () => 0, + setParameterValueByIndex: () => {}, + }, + getDrawableBounds: () => ({ x: NaN, y: NaN, width: 0, height: 0 }), + }, + }; + layer.detectFromModel(model); + + const bounds = layer.getDrawableBounds(0); + expect(bounds).toBeNull(); + }); + }); +}); diff --git a/packages/live2d/src/runtime/semantic/default-mappings.ts b/packages/live2d/src/runtime/semantic/default-mappings.ts new file mode 100644 index 0000000..d205337 --- /dev/null +++ b/packages/live2d/src/runtime/semantic/default-mappings.ts @@ -0,0 +1,145 @@ +import type { SemanticName, ParameterId } from "./types"; + +export const DEFAULT_SEMANTIC_MAPPINGS: Record = { + // Mouth + mouthOpen: [ + "PARAM_MOUTH_OPEN_Y", + "PARAM_MOUTH_A", + "ParamMouthA", + "MOUTH_OPEN", + ], + mouthForm: [ + "PARAM_MOUTH_FORM", + "PARAM_MOUTH_FORM_01", + "ParamMouthForm01", + ], + mouthSmile: [ + "PARAM_MOUTH_SMILE", + "ParamMouthSmile", + "PARAM_MOUTH_SMILE_01", + "PARAM_MOUTH_OPEN_SMILE", + ], + + // Eyes + eyeLOpen: [ + "PARAM_EYE_L_OPEN", + "ParamEyeLOpen", + "EYE_L_OPEN", + "PARAM_EYE_OPEN_L", + ], + eyeROpen: [ + "PARAM_EYE_R_OPEN", + "ParamEyeROpen", + "EYE_R_OPEN", + "PARAM_EYE_OPEN_R", + ], + eyeLSmile: [ + "PARAM_EYE_L_SMILE", + "ParamEyeLSmile", + "PARAM_EYE_SMILE_L", + "PARAM_EYE_OPEN_L_SMILE", + ], + eyeRSmile: [ + "PARAM_EYE_R_SMILE", + "ParamEyeRSmile", + "PARAM_EYE_SMILE_R", + "PARAM_EYE_OPEN_R_SMILE", + ], + eyeBallX: [ + "PARAM_EYE_BALL_X", + "ParamEyeBallX", + "EYE_BALL_X", + "PARAM_EYE_BALL_L_X", + "PARAM_EYE_BALL_R_X", + ], + eyeBallY: [ + "PARAM_EYE_BALL_Y", + "ParamEyeBallY", + "EYE_BALL_Y", + "PARAM_EYE_BALL_L_Y", + "PARAM_EYE_BALL_R_Y", + ], + + // Head angles + angleX: ["PARAM_ANGLE_X", "ParamAngleX", "ANGLE_X", "HeadX"], + angleY: ["PARAM_ANGLE_Y", "ParamAngleY", "ANGLE_Y", "HeadY"], + angleZ: ["PARAM_ANGLE_Z", "ParamAngleZ", "ANGLE_Z", "HeadZ"], + + // Body + bodyAngleX: [ + "PARAM_BODY_ANGLE_X", + "ParamBodyAngleX", + "BODY_ANGLE_X", + ], + bodyAngleY: [ + "PARAM_BODY_ANGLE_Y", + "ParamBodyAngleY", + "BODY_ANGLE_Y", + ], + bodyAngleZ: [ + "PARAM_BODY_ANGLE_Z", + "ParamBodyAngleZ", + "BODY_ANGLE_Z", + ], + + // Breathing + breath: ["PARAM_BREATH", "ParamBreath", "BREATH", "PARAM_BREATHING"], + + // Brows + browLY: [ + "PARAM_BROW_L_Y", + "ParamBrowLY", + "BROW_L_Y", + "PARAM_BROW_LY", + "PARAM_BROW_LEFT_Y", + ], + browRY: [ + "PARAM_BROW_R_Y", + "ParamBrowRY", + "BROW_R_Y", + "PARAM_BROW_RY", + "PARAM_BROW_RIGHT_Y", + ], + browLAngle: [ + "PARAM_BROW_L_ANGLE", + "ParamBrowLAngle", + "BROW_L_ANGLE", + "PARAM_BROW_L_ANGLE_Z", + "PARAM_BROW_LEFT_ANGLE", + ], + browRAngle: [ + "PARAM_BROW_R_ANGLE", + "ParamBrowRAngle", + "BROW_R_ANGLE", + "PARAM_BROW_R_ANGLE_Z", + "PARAM_BROW_RIGHT_ANGLE", + ], + browLForm: ["PARAM_BROW_L_FORM", "ParamBrowLForm", "BROW_L_FORM"], + browRForm: ["PARAM_BROW_R_FORM", "ParamBrowRForm", "BROW_R_FORM"], + + // Cheeks / blush + cheek: [ + "PARAM_CHEEK", + "ParamCheek", + "CHEEK", + "PARAM_BLUSH", + "ParamBlush", + "PARAM_CHEEK_R", + "PARAM_CHEEK_L", + "PARAM_CHEEK_01", + "PARAM_CHEEK_02", + ], + + // Arms + armLA: ["PARAM_ARM_L_A", "ParamArmL"], + armRA: ["PARAM_ARM_R_A", "ParamArmR"], + + // Hair + hairFront: ["PARAM_HAIR_FRONT", "ParamHairFront"], + hairSide: ["PARAM_HAIR_SIDE", "ParamHairSide"], + hairBack: ["PARAM_HAIR_BACK", "ParamHairBack"], + + // Physics-like + skirt: ["PARAM_SKIRT", "ParamSkirt"], + ribbon: ["PARAM_RIBBON", "ParamRibbon"], +}; diff --git a/packages/live2d/src/runtime/semantic/index.ts b/packages/live2d/src/runtime/semantic/index.ts new file mode 100644 index 0000000..961dae4 --- /dev/null +++ b/packages/live2d/src/runtime/semantic/index.ts @@ -0,0 +1,12 @@ +export { SemanticParameterLayer } from "./semantic-parameter-layer"; +export { DEFAULT_SEMANTIC_MAPPINGS } from "./default-mappings"; +export { createParameterAccessor } from "./parameter-accessor"; +export type { + BlendMode, + CapabilityProfile, + ParameterAccessor, + SemanticName, + ParameterId, + ResolvedParameter, + ParameterAvailability, +} from "./types"; diff --git a/packages/live2d/src/runtime/semantic/parameter-accessor.ts b/packages/live2d/src/runtime/semantic/parameter-accessor.ts new file mode 100644 index 0000000..029722d --- /dev/null +++ b/packages/live2d/src/runtime/semantic/parameter-accessor.ts @@ -0,0 +1,114 @@ +import type { ParameterAccessor } from "./types"; + +function isCubism2CoreModel(model: object): model is { + getParamFloat: (id: string | number) => number; + setParamFloat: (id: string | number, value: number, weight?: number) => void; + getParamIndex: (id: string) => number; +} { + return ( + typeof (model as Record).getParamFloat === "function" && + typeof (model as Record).setParamFloat === "function" + ); +} + +function isCubism4CoreModel(model: object): model is { + _model: { + parameters: { + ids: string[]; + values: Float32Array; + minimumValues: Float32Array; + maximumValues: Float32Array; + defaultValues: Float32Array; + }; + }; + getParameterValueByIndex: (index: number) => number; + setParameterValueByIndex: ( + index: number, + value: number, + weight?: number, + ) => void; +} { + return ( + typeof (model as Record).setParameterValueByIndex === + "function" && + (model as Record)._model !== undefined + ); +} + +export function createParameterAccessor( + coreModel: object, +): ParameterAccessor | null { + if (isCubism2CoreModel(coreModel)) { + // Some Cubism 2 implementations only accept string parameter IDs, + // not numeric indices. Store the resolved ID so getValue/setValue + // can use the string form safely. + const indexToId = new Map(); + + return { + getValue(index: number): number { + const id = indexToId.get(index); + return id !== undefined ? coreModel.getParamFloat(id) : 0; + }, + setValue(index: number, value: number): void { + const id = indexToId.get(index); + if (id !== undefined) { + coreModel.setParamFloat(id, value); + } + }, + getMin(_index: number): number { + return -30; + }, + getMax(_index: number): number { + return 30; + }, + getDefault(_index: number): number { + return 0; + }, + getAllIds(): string[] { + return Array.from(indexToId.values()); + }, + findIndex(id: string): number { + const index = coreModel.getParamIndex(id); + if (index >= 0) { + indexToId.set(index, id); + } + return index; + }, + }; + } + + if (isCubism4CoreModel(coreModel)) { + const params = coreModel._model.parameters; + const ids = params.ids; + const values = params.values; + const mins = params.minimumValues; + const maxs = params.maximumValues; + const defaults = params.defaultValues; + + return { + getValue(index: number): number { + return values[index] ?? 0; + }, + setValue(index: number, value: number): void { + coreModel.setParameterValueByIndex(index, value); + }, + getMin(index: number): number { + return mins[index] ?? -30; + }, + getMax(index: number): number { + return maxs[index] ?? 30; + }, + getDefault(index: number): number { + return defaults[index] ?? 0; + }, + getAllIds(): string[] { + return [...ids]; + }, + findIndex(id: string): number { + return ids.indexOf(id); + }, + }; + } + + return null; +} diff --git a/packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts b/packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts new file mode 100644 index 0000000..41d5403 --- /dev/null +++ b/packages/live2d/src/runtime/semantic/semantic-parameter-layer.ts @@ -0,0 +1,245 @@ +import type { + BlendMode, + CapabilityProfile, + ParameterAccessor, + SemanticName, + ParameterId, +} from "./types"; +import { DEFAULT_SEMANTIC_MAPPINGS } from "./default-mappings"; +import { createParameterAccessor } from "./parameter-accessor"; +import type { ParameterCoordinator } from "../controller/coordinator"; +import type { SystemPriority } from "../controller/types"; + +export class SemanticParameterLayer { + private accessor: ParameterAccessor | null = null; + private resolved = new Map(); + private customMappings = new Map(); + private capabilityProfile: CapabilityProfile = { + detected: new Map(), + missing: [], + notApplicable: [], + }; + private sourceModel: object | null = null; + private coordinator?: ParameterCoordinator; + + /** + * Register a custom semantic mapping before model detection. + */ + registerSemantic(name: SemanticName, candidates: ParameterId[]): void { + this.customMappings.set(name, candidates); + } + + /** + * Detect available semantic parameters from a loaded Live2D model. + */ + detectFromModel(model: object): CapabilityProfile { + this.sourceModel = model; + + const coreModel = this.extractCoreModel(model); + if (!coreModel) { + throw new Error("SemanticParameterLayer: unable to extract core model"); + } + + this.accessor = createParameterAccessor(coreModel); + if (!this.accessor) { + throw new Error( + "SemanticParameterLayer: unsupported core model type", + ); + } + + this.resolved.clear(); + const detected = new Map(); + const missing: SemanticName[] = []; + const notApplicable: SemanticName[] = []; + + // Merge default and custom mappings (custom takes precedence) + const allMappings = new Map(); + for (const [name, candidates] of Object.entries(DEFAULT_SEMANTIC_MAPPINGS)) { + allMappings.set(name, candidates); + } + for (const [name, candidates] of this.customMappings) { + allMappings.set(name, candidates); + } + + for (const [semanticName, candidates] of allMappings) { + const found = this.findParameter(candidates); + if (found !== null) { + this.resolved.set(semanticName, found); + detected.set(semanticName, found.id); + } else { + missing.push(semanticName); + } + } + + this.capabilityProfile = { detected, missing, notApplicable }; + return this.capabilityProfile; + } + + /** + * Set an optional write coordinator. When set, setSemantic calls + * are redirected to the coordinator's queue instead of being applied + * immediately. The coordinator is responsible for conflict resolution + * and flushing. + */ + setCoordinator(coordinator: ParameterCoordinator | undefined): void { + this.coordinator = coordinator; + } + + /** + * Check if a semantic parameter is available. + */ + hasSemantic(name: SemanticName): boolean { + return this.resolved.has(name); + } + + /** + * Get the current value of a semantic parameter. + */ + getSemantic(name: SemanticName): number | undefined { + const param = this.resolved.get(name); + if (!param || !this.accessor) return undefined; + return this.accessor.getValue(param.index); + } + + /** + * Set the value of a semantic parameter. + * blendMode: 'override' replaces the value, 'add' adds to the current value. + */ + setSemantic( + name: SemanticName, + value: number, + blendMode: BlendMode = "override", + source?: string, + priority?: SystemPriority, + ): void { + const param = this.resolved.get(name); + if (!param || !this.accessor) return; + + // If a coordinator is active, delegate to it for conflict resolution. + if (this.coordinator) { + this.coordinator.queueWrite( + name, + value, + blendMode, + source ?? "direct", + priority ?? 5, + ); + return; + } + + let targetValue: number; + if (blendMode === "add") { + const current = this.accessor.getValue(param.index); + targetValue = current + value; + } else { + targetValue = value; + } + + // Clamp to parameter bounds + const min = this.accessor.getMin(param.index); + const max = this.accessor.getMax(param.index); + targetValue = Math.max(min, Math.min(max, targetValue)); + + this.accessor.setValue(param.index, targetValue); + } + + /** + * Get the capability profile from the last detection. + */ + getCapabilityProfile(): CapabilityProfile { + return this.capabilityProfile; + } + + private extractCoreModel(model: object): object | null { + // Live2DModel from untitled-pixi-live2d-engine has internalModel.coreModel + const internalModel = (model as Record) + .internalModel as Record | undefined; + if (internalModel?.coreModel) { + return internalModel.coreModel as object; + } + + // Direct core model + if ( + typeof (model as Record).getParamFloat === "function" || + typeof (model as Record).setParameterValueByIndex === + "function" + ) { + return model; + } + + return null; + } + + /** + * Find a hit area index by name pattern match. + * Returns the index of the first hit area whose name matches the pattern. + */ + getHitAreaIndex(namePattern: RegExp): number | undefined { + if (!this.sourceModel) return undefined; + + const internalModel = (this.sourceModel as Record) + .internalModel as Record | undefined; + if (!internalModel?.hitAreas) return undefined; + + const hitAreas = internalModel.hitAreas as Record< + string, + { name: string; index: number } + >; + const hitArea = Object.values(hitAreas).find(({ name }) => + namePattern.test(name), + ); + return hitArea?.index; + } + + /** + * Get the bounding box of a drawable by its index. + * Returns null if the model or drawable is not available. + */ + getDrawableBounds(index: number): { + x: number; + y: number; + width: number; + height: number; + } | null { + if (!this.sourceModel) return null; + + const internalModel = (this.sourceModel as Record) + .internalModel as Record | undefined; + if (typeof internalModel?.getDrawableBounds !== "function") return null; + + const bounds = ( + internalModel.getDrawableBounds as (index: number) => { + x: number; + y: number; + width: number; + height: number; + } + )(index); + + if ( + !Number.isFinite(bounds.x) || + !Number.isFinite(bounds.y) || + bounds.width <= 0 || + bounds.height <= 0 + ) { + return null; + } + + return bounds; + } + + private findParameter( + candidates: ParameterId[], + ): { id: ParameterId; index: number } | null { + if (!this.accessor) return null; + + for (const id of candidates) { + const index = this.accessor.findIndex(id); + if (index >= 0) { + return { id, index }; + } + } + + return null; + } +} diff --git a/packages/live2d/src/runtime/semantic/types.ts b/packages/live2d/src/runtime/semantic/types.ts new file mode 100644 index 0000000..0713025 --- /dev/null +++ b/packages/live2d/src/runtime/semantic/types.ts @@ -0,0 +1,35 @@ +export type BlendMode = "override" | "add"; + +export type SemanticName = string; + +export type ParameterId = string; + +export interface SemanticMapping { + candidates: ParameterId[]; +} + +export type ParameterAvailability = "detected" | "missing" | "not-applicable"; + +export interface CapabilityProfile { + detected: Map; + missing: SemanticName[]; + notApplicable: SemanticName[]; +} + +export interface ResolvedParameter { + id: ParameterId; + index: number; + min: number; + max: number; + defaultValue: number; +} + +export interface ParameterAccessor { + getValue(index: number): number; + setValue(index: number, value: number): void; + getMin(index: number): number; + getMax(index: number): number; + getDefault(index: number): number; + getAllIds(): string[]; + findIndex(id: string): number; +} diff --git a/packages/live2d/src/utils/unoMixin.ts b/packages/live2d/src/utils/unoMixin.ts index 607a8a8..20cfd11 100644 --- a/packages/live2d/src/utils/unoMixin.ts +++ b/packages/live2d/src/utils/unoMixin.ts @@ -1,9 +1,8 @@ -// @ts-ignore import style from "@/live2d/styles/unocss.global.css?inline"; -import { type LitElement, adoptStyles, unsafeCSS } from "lit"; +import { type LitElement, unsafeCSS, type CSSResult } from "lit"; declare global { - // biome-ignore lint/suspicious/noExplicitAny: any is needed to define a mixin + // biome-ignore lint/suspicious/noExplicitAny: mixin constructors require any[] rest parameter per TS2545 export type LitMixin = new (...args: any[]) => T & LitElement; } @@ -14,7 +13,11 @@ export const UNO = (superClass: T): T => connectedCallback() { super.connectedCallback(); if (this.shadowRoot) { - adoptStyles(this.shadowRoot, [stylesheet]); + const existing = this.shadowRoot.adoptedStyleSheets ?? []; + const sheet = (stylesheet as CSSResult).styleSheet; + if (sheet && !existing.includes(sheet)) { + this.shadowRoot.adoptedStyleSheets = [...existing, sheet]; + } } } }; diff --git a/packages/live2d/vite.config.ts b/packages/live2d/vite.config.ts index cac1d7a..1ae787b 100644 --- a/packages/live2d/vite.config.ts +++ b/packages/live2d/vite.config.ts @@ -3,6 +3,10 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + }, plugins: [ react({ babel: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3f4bee..0dedc40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.2 version: 6.0.2(vite@8.0.13(@types/node@22.13.1)(jiti@2.7.0)(terser@5.38.0)(tsx@4.19.2)) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 ts-lit-plugin: specifier: ^2.0.2 version: 2.0.2 @@ -69,12 +72,30 @@ importers: vite: specifier: ^8.0.13 version: 8.0.13(@types/node@22.13.1)(jiti@2.7.0)(terser@5.38.0)(tsx@4.19.2) + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@22.13.1)(jsdom@29.1.1)(vite@8.0.13(@types/node@22.13.1)(jiti@2.7.0)(terser@5.38.0)(tsx@4.19.2)) packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/runtime@7.26.7': resolution: {integrity: sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==} engines: {node: '>=6.9.0'} @@ -136,6 +157,46 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4': + resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -289,6 +350,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -591,9 +661,18 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/earcut@3.0.0': resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} @@ -702,6 +781,35 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + '@vscode/web-custom-data@0.4.13': resolution: {integrity: sha512-2ZUIRfhofZ/npLlf872EBnPmn27Kt4M2UssmQIfnJvgGgMYZJ5fvtHEDnttBBf2hnVtBgNCqZMVHJA+wsFVqTA==} @@ -729,6 +837,13 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -740,6 +855,10 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -778,6 +897,9 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -785,6 +907,13 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-uri-component@0.4.1: resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} engines: {node: '>=14.16'} @@ -812,6 +941,13 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.23.1: resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==} engines: {node: '>=18'} @@ -831,6 +967,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -882,6 +1022,10 @@ packages: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + iconify-icon@3.0.2: resolution: {integrity: sha512-DYPAumiUeUeT/GHT8x2wrAVKn1FqZJqFH0Y5pBefapWRreV1BBvqBVMb0020YQ2njmbR59r/IathL2d2OrDrxA==} @@ -904,6 +1048,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + ismobilejs@1.1.1: resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==} @@ -914,6 +1061,15 @@ packages: js-binary-schema-parser@2.0.3: resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==} + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -1008,6 +1164,10 @@ packages: lodash.deburr@4.1.0: resolution: {integrity: sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==} + lru-cache@11.5.0: + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + engines: {node: 20 || >=22} + magic-regexp@0.10.0: resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} @@ -1040,6 +1200,9 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -1061,6 +1224,9 @@ packages: parse5@5.1.0: resolution: {integrity: sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1088,6 +1254,10 @@ packages: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -1122,6 +1292,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -1137,9 +1311,16 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -1159,6 +1340,12 @@ packages: resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} engines: {node: '>=12'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1171,6 +1358,9 @@ packages: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + terser@5.38.0: resolution: {integrity: sha512-a4GD5R1TjEeuCT6ZRiYMHmIf7okbCPEuhQET8bczV6FrQMMlFXA1n+G0KKjdlFCm3TEHV77GxfZB3vZSUQGFpg==} engines: {node: '>=10'} @@ -1180,6 +1370,9 @@ packages: resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==} engines: {node: '>=12'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.1.2: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} @@ -1188,6 +1381,17 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} + + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1196,6 +1400,14 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-lit-plugin@2.0.2: resolution: {integrity: sha512-DPXlVxhjWHxg8AyBLcfSYt2JXgpANV1ssxxwjY98o26gD8MzeiM68HFW9c2VeDd1CjoR3w7B/6/uKxwBQe+ioA==} @@ -1235,6 +1447,10 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unocss@66.6.8: resolution: {integrity: sha512-stq9FbxedTDkoWrxnNQNnPQXOaM6L2Lobq8HzjXdR2tMc55gtfqDArqL7TESfnN7qeZsIocNYCHLNA4DXq50YQ==} engines: {node: '>=14'} @@ -1307,6 +1523,47 @@ packages: yaml: optional: true + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-css-languageservice@4.3.0: resolution: {integrity: sha512-BkQAMz4oVHjr0oOAz5PdeE72txlLQK7NIwzmclfr+b6fj6I8POwB+VoXvrZLTbWt9hWRgfvgiQRkh5JwrjPJ5A==} @@ -1325,17 +1582,45 @@ packages: vscode-uri@2.1.2: resolution: {integrity: sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-component-analyzer@2.0.0: resolution: {integrity: sha512-UEvwfpD+XQw99sLKiH5B1T4QwpwNyWJxp59cnlRwFfhUW6JsQpw5jMeMwi7580sNou8YL3kYoS7BWLm+yJ/jVQ==} hasBin: true + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1355,6 +1640,26 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.2 + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/runtime@7.26.7': dependencies: regenerator-runtime: 0.14.1 @@ -1394,6 +1699,34 @@ snapshots: '@biomejs/cli-win32-x64@1.9.4': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1482,6 +1815,8 @@ snapshots: '@esbuild/win32-x64@0.23.1': optional: true + '@exodus/bytes@1.15.0': {} + '@iconify/types@2.0.0': {} '@iconify/utils@3.1.3': @@ -1680,11 +2015,20 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@standard-schema/spec@1.1.0': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/earcut@3.0.0': {} '@types/estree@1.0.9': {} @@ -1850,6 +2194,47 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.0.13(@types/node@22.13.1)(jiti@2.7.0)(terser@5.38.0)(tsx@4.19.2) + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@22.13.1)(jiti@2.7.0)(terser@5.38.0)(tsx@4.19.2))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.13(@types/node@22.13.1)(jiti@2.7.0)(terser@5.38.0)(tsx@4.19.2) + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@vscode/web-custom-data@0.4.13': {} '@webgpu/types@0.1.70': {} @@ -1868,6 +2253,12 @@ snapshots: dependencies: color-convert: 2.0.1 + assertion-error@2.0.1: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1877,6 +2268,8 @@ snapshots: cac@7.0.0: {} + chai@6.2.2: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -1914,6 +2307,8 @@ snapshots: consola@3.4.2: {} + convert-source-map@2.0.0: {} + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -1921,6 +2316,15 @@ snapshots: csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + decimal.js@10.6.0: {} + decode-uri-component@0.4.1: {} defu@6.1.7: {} @@ -1941,6 +2345,10 @@ snapshots: emoji-regex@8.0.0: {} + entities@8.0.0: {} + + es-module-lexer@2.1.0: {} + esbuild@0.23.1: optionalDependencies: '@esbuild/aix-ppc64': 0.23.1 @@ -1979,6 +2387,8 @@ snapshots: eventemitter3@5.0.4: {} + expect-type@1.3.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2025,6 +2435,12 @@ snapshots: has-flag@3.0.0: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + iconify-icon@3.0.2: dependencies: '@iconify/types': 2.0.0 @@ -2041,12 +2457,40 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + ismobilejs@1.1.1: {} jiti@2.7.0: {} js-binary-schema-parser@2.0.3: {} + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.0 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + leven@3.1.0: {} lightningcss-android-arm64@1.32.0: @@ -2128,6 +2572,8 @@ snapshots: lodash.deburr@4.1.0: {} + lru-cache@11.5.0: {} + magic-regexp@0.10.0: dependencies: estree-walker: 3.0.3 @@ -2164,6 +2610,8 @@ snapshots: node-fetch-native@1.6.7: {} + obug@2.1.1: {} + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -2209,6 +2657,10 @@ snapshots: parse5@5.1.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + pathe@2.0.3: {} perfect-debounce@2.1.0: {} @@ -2244,6 +2696,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + punycode@2.3.1: {} + quansync@1.0.0: {} query-string@9.3.1: @@ -2269,6 +2723,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: optional: true @@ -2299,8 +2755,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} + siginfo@2.0.0: {} + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -2320,6 +2782,10 @@ snapshots: split-on-first@3.0.0: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -2334,6 +2800,8 @@ snapshots: dependencies: has-flag: 3.0.0 + symbol-tree@3.2.4: {} + terser@5.38.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -2344,6 +2812,8 @@ snapshots: tiny-lru@11.4.7: {} + tinybench@2.9.0: {} + tinyexec@1.1.2: {} tinyglobby@0.2.16: @@ -2351,12 +2821,28 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + + tldts-core@7.0.30: {} + + tldts@7.0.30: + dependencies: + tldts-core: 7.0.30 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 totalist@3.0.1: {} + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.30 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-lit-plugin@2.0.2: dependencies: lit-analyzer: 2.0.3 @@ -2399,6 +2885,8 @@ snapshots: undici-types@6.20.0: optional: true + undici@7.25.0: {} + unocss@66.6.8(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@unocss/postcss@66.6.8(postcss@8.5.14))(vite@8.0.13(@types/node@22.13.1)(jiti@2.7.0)(terser@5.38.0)(tsx@4.19.2)): dependencies: '@unocss/cli': 66.6.8 @@ -2456,6 +2944,34 @@ snapshots: terser: 5.38.0 tsx: 4.19.2 + vitest@4.1.6(@types/node@22.13.1)(jsdom@29.1.1)(vite@8.0.13(@types/node@22.13.1)(jiti@2.7.0)(terser@5.38.0)(tsx@4.19.2)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@22.13.1)(jiti@2.7.0)(terser@5.38.0)(tsx@4.19.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.13(@types/node@22.13.1)(jiti@2.7.0)(terser@5.38.0)(tsx@4.19.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.13.1 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + vscode-css-languageservice@4.3.0: dependencies: vscode-languageserver-textdocument: 1.0.12 @@ -2478,6 +2994,10 @@ snapshots: vscode-uri@2.1.2: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-component-analyzer@2.0.0: dependencies: fast-glob: 3.3.3 @@ -2485,14 +3005,35 @@ snapshots: typescript: 5.2.2 yargs: 17.7.2 + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} yargs-parser@21.1.1: {} From 90866695b4c2c7cde8e02e9a34b5bf167a6f9d52 Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Fri, 22 May 2026 15:33:49 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20Live2D=20=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E7=A6=81=E7=94=A8=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=20blink=E3=80=81=E4=BC=98=E5=8C=96=20DevTool?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交包含以下内容: 1. Live2dDevTools 使用 unocss 原子 CSS 重写 - 移除传统 CSS 和 icon 字段 - 修复无效 unocss 类名(scale-108、duration-400 等) - 修复 biome 格式化和 lint 错误 - 修复展开区域标题与内容间距(添加 pt-3) 2. 默认禁用自定义 BlinkModule - 避免抢占 Live2D 引擎自带的 EyeBlink 系统 - 修复闭眼时眉毛跟着消失的问题 3. 从行为状态机内置状态中移除 eyeLOpen/eyeROpen 固定覆盖 - sad / embarrassed / thinking / talking / sleepy 不再强制覆盖眼睛开合度 - 让引擎自主管理眨眼动画 Signed-off-by: LIlGG --- .../Live2dDevTools/Live2dDevTools.ts | 1852 +++++------------ packages/live2d/src/config/default-config.ts | 154 +- .../src/runtime/behavior/built-in-states.ts | 20 +- 3 files changed, 651 insertions(+), 1375 deletions(-) diff --git a/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts b/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts index f504511..f3f0c31 100644 --- a/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts +++ b/packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts @@ -1,19 +1,21 @@ import { UnoLitElement } from "@/live2d/common/UnoLitElement"; -import { html, type TemplateResult, unsafeCSS } from "lit"; -import { unsafeSVG } from "lit/directives/unsafe-svg.js"; -import { state } from "lit/decorators.js"; import type { Live2dRuntimeController } from "@/live2d/runtime/controller"; -import type { ControllerState, ConflictEntry } from "@/live2d/runtime/controller/types"; +import type { + ConflictEntry, + ControllerState, +} from "@/live2d/runtime/controller/types"; import type { EffectPreset } from "@/live2d/runtime/filters/types"; +import { type TemplateResult, html, unsafeCSS } from "lit"; +import { state } from "lit/decorators.js"; +import { unsafeSVG } from "lit/directives/unsafe-svg.js"; interface Section { id: string; title: string; - icon: string; expanded: boolean; } -const FSM_STATE_LABELS: Record = { +const FSM_LABELS: Record = { idle: "空闲", happy: "开心", thinking: "思考", @@ -44,12 +46,36 @@ const FILTER_LABELS: Record = { "angry-red": "愤怒赤红", }; -export class Live2dDevTools extends UnoLitElement { - @state() - private _visible = false; +const STATE_COLORS: Record = { + idle: "text-slate-400 bg-slate-400/10", + neutral: "text-slate-400 bg-slate-400/10", + happy: "text-amber-400 bg-amber-400/10", + sad: "text-blue-400 bg-blue-400/10", + angry: "text-red-400 bg-red-400/10", + embarrassed: "text-pink-400 bg-pink-400/10", + thinking: "text-violet-400 bg-violet-400/10", + talking: "text-emerald-400 bg-emerald-400/10", + sleepy: "text-indigo-400 bg-indigo-400/10", + surprised: "text-orange-400 bg-orange-400/10", + none: "text-gray-500 bg-white/5", +}; + +const DOT_COLORS: Record = { + idle: "bg-slate-500", + neutral: "bg-slate-500", + happy: "bg-amber-400", + sad: "bg-blue-400", + angry: "bg-red-400", + embarrassed: "bg-pink-400", + thinking: "bg-violet-400", + talking: "bg-emerald-400", + sleepy: "bg-indigo-400", + surprised: "bg-orange-400", +}; - @state() - private _state: ControllerState = { +export class Live2dDevTools extends UnoLitElement { + @state() private _visible = false; + @state() private _state: ControllerState = { fsmState: null, emotion: null, isTransitioning: false, @@ -58,25 +84,20 @@ export class Live2dDevTools extends UnoLitElement { motionLayers: [], proceduralModules: [], }; - - @state() - private _conflicts: ConflictEntry[] = []; - - @state() - private _sections: Section[] = [ - { id: "perf", title: "性能监控", icon: "📈", expanded: true }, - { id: "fsm", title: "行为状态机", icon: "🎭", expanded: false }, - { id: "emotion", title: "情感时间线", icon: "💫", expanded: false }, - { id: "motion", title: "动作层级", icon: "🎬", expanded: false }, - { id: "filter", title: "滤镜效果", icon: "🎨", expanded: false }, - { id: "params", title: "语义参数", icon: "📊", expanded: false }, - { id: "procedural", title: "程序化动画", icon: "✨", expanded: false }, - { id: "conflicts", title: "冲突日志", icon: "⚡", expanded: false }, + @state() private _conflicts: ConflictEntry[] = []; + @state() private _sections: Section[] = [ + { id: "perf", title: "性能监控", expanded: true }, + { id: "fsm", title: "行为状态机", expanded: false }, + { id: "emotion", title: "情感时间线", expanded: false }, + { id: "motion", title: "动作层级", expanded: false }, + { id: "filter", title: "滤镜效果", expanded: false }, + { id: "params", title: "语义参数", expanded: false }, + { id: "procedural", title: "程序化动画", expanded: false }, + { id: "conflicts", title: "冲突日志", expanded: false }, ]; - // ── Performance metrics ── - private _perfSamples: Array<{ fps: number; frameTime: number; timestamp: number }> = []; - private readonly _MAX_PERF_SAMPLES = 120; + private _perfSamples: Array<{ fps: number; frameTime: number }> = []; + private readonly _MAX_PERF = 120; private _lastRafTime = 0; private _rafId = 0; private _modelStats = { @@ -85,7 +106,6 @@ export class Live2dDevTools extends UnoLitElement { partCount: 0, textureCount: 0, }; - private _controller?: Live2dRuntimeController; private _updateInterval?: ReturnType; private _dragging = false; @@ -93,8 +113,8 @@ export class Live2dDevTools extends UnoLitElement { private _panelX = 16; private _panelY = 16; - setController(controller: Live2dRuntimeController): void { - this._controller = controller; + setController(c: Live2dRuntimeController): void { + this._controller = c; } connectedCallback(): void { @@ -103,10 +123,10 @@ export class Live2dDevTools extends UnoLitElement { const saved = localStorage.getItem("live2d-devtools-position"); if (saved) { try { - const pos = JSON.parse(saved); - this._panelX = pos.x ?? 16; - this._panelY = pos.y ?? 16; - } catch { /* ignore */ } + const p = JSON.parse(saved); + this._panelX = p.x ?? 16; + this._panelY = p.y ?? 16; + } catch {} } this._updateInterval = setInterval(() => this._pollState(), 80); this._rafId = requestAnimationFrame(this._collectPerf); @@ -122,14 +142,10 @@ export class Live2dDevTools extends UnoLitElement { private _handleKeyDown = (e: KeyboardEvent): void => { if (e.ctrlKey && e.shiftKey && e.key === "D") { e.preventDefault(); - this._toggleVisible(); + this._visible = !this._visible; } }; - private _toggleVisible(): void { - this._visible = !this._visible; - } - private _pollState(): void { if (!this._controller) return; this._state = this._controller.getState(); @@ -137,92 +153,74 @@ export class Live2dDevTools extends UnoLitElement { this._updateModelStats(); } - private _collectPerf = (timestamp: number): void => { + private _collectPerf = (t: number): void => { if (this._lastRafTime > 0) { - const frameTime = timestamp - this._lastRafTime; - const fps = frameTime > 0 ? 1000 / frameTime : 60; - this._perfSamples.push({ fps: Math.min(fps, 120), frameTime, timestamp }); - if (this._perfSamples.length > this._MAX_PERF_SAMPLES) { - this._perfSamples.shift(); - } - // Request re-render for live chart when perf section is expanded - const perfExpanded = this._sections.find((s) => s.id === "perf")?.expanded; - if (perfExpanded && this._visible) { - this.requestUpdate(); - } - } - this._lastRafTime = timestamp; + const ft = t - this._lastRafTime; + const fps = ft > 0 ? 1000 / ft : 60; + this._perfSamples.push({ fps: Math.min(fps, 120), frameTime: ft }); + if (this._perfSamples.length > this._MAX_PERF) this._perfSamples.shift(); + const perfExpanded = this._sections.find( + (s) => s.id === "perf", + )?.expanded; + if (perfExpanded && this._visible) this.requestUpdate(); + } + this._lastRafTime = t; this._rafId = requestAnimationFrame(this._collectPerf); }; private _updateModelStats(): void { const semanticLayer = this._controller?.getSemanticLayer(); if (!semanticLayer) return; - - // Parameter count from detected semantic profile - const profile = semanticLayer.getCapabilityProfile(); - const paramCount = profile.detected.size; - - // Try to get detailed model stats from internal model + const paramCount = semanticLayer.getCapabilityProfile().detected.size; let drawableCount = 0; let partCount = 0; let textureCount = 0; - try { - type ModelWithInternal = { - internalModel?: { - coreModel?: { - getDrawableCount?(): number; - getPartCount?(): number; - _drawableCount?: number; - drawableCount?: number; - _partCount?: number; - partCount?: number; - }; - parts?: unknown[]; - drawables?: unknown[]; - drawDataList?: unknown[]; - textures?: unknown[]; - }; - }; - - const model = (semanticLayer as unknown as Record)["sourceModel"] as - | ModelWithInternal - | undefined; - if (model) { - const internal = model.internalModel; - if (internal) { - // Try various property names used by different Cubism versions - const core = internal.coreModel; - if (core) { - // Cubism 4/5 style - drawableCount = - (typeof core.getDrawableCount === "function" ? core.getDrawableCount() : undefined) ?? - core._drawableCount ?? - core.drawableCount ?? - 0; - partCount = - (typeof core.getPartCount === "function" ? core.getPartCount() : undefined) ?? - core._partCount ?? - core.partCount ?? - 0; - } else { - // Cubism 2.1 style: internalModel itself may have the data - if (Array.isArray(internal.parts)) { - partCount = internal.parts.length; - } - const drawables = internal.drawables ?? internal.drawDataList; - if (Array.isArray(drawables)) { - drawableCount = drawables.length; - } + const model = (semanticLayer as unknown as Record) + .sourceModel as + | { + internalModel?: { + coreModel?: { + getDrawableCount?(): number; + getPartCount?(): number; + _drawableCount?: number; + drawableCount?: number; + _partCount?: number; + partCount?: number; + }; + parts?: unknown[]; + drawables?: unknown[]; + drawDataList?: unknown[]; + textures?: unknown[]; + }; } - textureCount = Array.isArray(internal.textures) ? internal.textures.length : 0; + | undefined; + if (model?.internalModel) { + const im = model.internalModel; + const core = im.coreModel; + if (core) { + drawableCount = + (typeof core.getDrawableCount === "function" + ? core.getDrawableCount() + : undefined) ?? + core._drawableCount ?? + core.drawableCount ?? + 0; + partCount = + (typeof core.getPartCount === "function" + ? core.getPartCount() + : undefined) ?? + core._partCount ?? + core.partCount ?? + 0; + } else { + if (Array.isArray(im.parts)) partCount = im.parts.length; + const ds = im.drawables ?? im.drawDataList; + if (Array.isArray(ds)) drawableCount = ds.length; } + textureCount = Array.isArray(im.textures) ? im.textures.length : 0; } - } catch { - // Silently ignore reflection errors - } - + } catch {} this._modelStats = { parameterCount: paramCount, drawableCount, @@ -239,10 +237,11 @@ export class Live2dDevTools extends UnoLitElement { private _startDrag(e: MouseEvent): void { this._dragging = true; - const rect = this.shadowRoot?.querySelector(".l2d-panel")?.getBoundingClientRect(); - if (rect) { + const rect = this.shadowRoot + ?.querySelector("[data-devtools-panel]") + ?.getBoundingClientRect(); + if (rect) this._dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; - } document.addEventListener("mousemove", this._onDrag); document.addEventListener("mouseup", this._stopDrag); } @@ -258,57 +257,99 @@ export class Live2dDevTools extends UnoLitElement { this._dragging = false; document.removeEventListener("mousemove", this._onDrag); document.removeEventListener("mouseup", this._stopDrag); - localStorage.setItem("live2d-devtools-position", JSON.stringify({ x: this._panelX, y: this._panelY })); + localStorage.setItem( + "live2d-devtools-position", + JSON.stringify({ x: this._panelX, y: this._panelY }), + ); }; private _transitionFSM(state: string): void { this._controller?.transitionTo({ fsm: state }); } - private _transitionEmotion(emotion: string): void { this._controller?.transitionTo({ emotion }); } - private _applyFilter(preset: EffectPreset): void { this._controller?.getFilterPipeline().applyPreset(preset); } - private _clearFilters(): void { this._controller?.getFilterPipeline().clear(); } + private _setFilterIntensity(id: string, v: number): void { + this._controller?.getFilterPipeline().setIntensity(id, v); + } + private _setParamValue(p: string, v: number): void { + this._controller + ?.getSemanticLayer() + .setSemantic(p, v, "override", "manual", 1); + } - private _setFilterIntensity(id: string, value: number): void { - this._controller?.getFilterPipeline().setIntensity(id, value); + private _sectionHeader( + id: string, + current: string, + extra?: TemplateResult, + ): TemplateResult { + const s = this._sections.find((sec) => sec.id === id); + if (!s) return html``; + return html` +
this._toggleSection(id)}> + ${s.title} + + ${FSM_LABELS[current] ?? EMOTION_LABELS[current] ?? current ?? "—"} + + ${extra} + ${s.expanded ? "▼" : "▶"} +
+ `; } - private _setParamValue(param: string, value: number): void { - this._controller?.getSemanticLayer().setSemantic(param, value, "override", "manual", 1); + private _sectionWrap( + id: string, + current: string, + body: TemplateResult, + extra?: TemplateResult, + ): TemplateResult { + const s = this._sections.find((sec) => sec.id === id); + if (!s) return html``; + return html` +
+ ${this._sectionHeader(id, current, extra)} + ${s.expanded ? html`
${body}
` : ""} +
+ `; } render(): TemplateResult { - if (!this._visible) { + if (!this._visible) return html` -
this._toggleVisible()} title="Live2D 调试面板 (Ctrl+Shift+D)"> -
🎛️
-
-
- `; - } +
{ + this._visible = true; + }} title="Live2D 调试面板 (Ctrl+Shift+D)"> +
🎛️
+
+
+ `; return html` -
-
-
- 🎛️ - Live2D 实时调试台 - DEV +
+ +
+
+ 🎛️ + Live2D 实时调试台 + DEV
- +
-
+
${this._renderPerfSection()} ${this._renderStatusBar()} ${this._renderFSMSection()} @@ -324,1249 +365,502 @@ export class Live2dDevTools extends UnoLitElement { } private _renderPerfSection(): TemplateResult { - const section = this._sections.find((s) => s.id === "perf"); - if (!section) return html``; - const samples = this._perfSamples; const recent = samples.slice(-60); - const avgFps = recent.length > 0 - ? recent.reduce((s, d) => s + d.fps, 0) / recent.length - : 0; - const avgFrameTime = recent.length > 0 - ? recent.reduce((s, d) => s + d.frameTime, 0) / recent.length - : 0; - const maxFps = recent.length > 0 ? Math.max(...recent.map((d) => d.fps)) : 60; - const fpsMin = 0; + const avgFps = + recent.length > 0 + ? recent.reduce((s, d) => s + d.fps, 0) / recent.length + : 0; + const avgFt = + recent.length > 0 + ? recent.reduce((s, d) => s + d.frameTime, 0) / recent.length + : 0; + const maxFps = + recent.length > 0 ? Math.max(...recent.map((d) => d.fps)) : 60; const fpsMax = Math.max(66, maxFps * 1.1); const w = 320; const h = 80; const pad = 4; - // Build SVG area path for FPS (filled area under the line) - const fpsLineParts: string[] = []; - const fpsAreaParts: string[] = []; + const fpsLine: string[] = []; + const fpsArea: string[] = []; const step = recent.length > 1 ? (w - pad * 2) / (recent.length - 1) : 0; for (let i = 0; i < recent.length; i++) { const x = pad + i * step; - const y = pad + (1 - (recent[i].fps - fpsMin) / (fpsMax - fpsMin)) * (h - pad * 2); + const y = pad + (1 - recent[i].fps / fpsMax) * (h - pad * 2); const cmd = i === 0 ? "M" : "L"; - const point = `${cmd}${x.toFixed(1)},${y.toFixed(1)}`; - fpsLineParts.push(point); - fpsAreaParts.push(point); + fpsLine.push(`${cmd}${x.toFixed(1)},${y.toFixed(1)}`); + fpsArea.push(`${cmd}${x.toFixed(1)},${y.toFixed(1)}`); } - // Close the area path down to bottom if (recent.length > 0) { const lastX = pad + (recent.length - 1) * step; - fpsAreaParts.push(`L${lastX.toFixed(1)},${h - pad}L${pad},${h - pad}Z`); + fpsArea.push(`L${lastX.toFixed(1)},${h - pad}L${pad},${h - pad}Z`); } - const fpsLinePath = fpsLineParts.join(""); - const fpsAreaPath = fpsAreaParts.join(""); - // Build SVG area path for frame time const ftMax = Math.max(33, ...recent.map((d) => d.frameTime)); - const ftAreaParts: string[] = []; + const ftArea: string[] = []; for (let i = 0; i < recent.length; i++) { const x = pad + i * step; const y = pad + (1 - recent[i].frameTime / ftMax) * (h - pad * 2); - const cmd = i === 0 ? "M" : "L"; - ftAreaParts.push(`${cmd}${x.toFixed(1)},${y.toFixed(1)}`); + ftArea.push(`${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`); } if (recent.length > 0) { const lastX = pad + (recent.length - 1) * step; - ftAreaParts.push(`L${lastX.toFixed(1)},${h - pad}L${pad},${h - pad}Z`); + ftArea.push(`L${lastX.toFixed(1)},${h - pad}L${pad},${h - pad}Z`); } - const ftAreaPath = ftAreaParts.join(""); - - // Build SVG content as a string (using unsafeSVG to preserve SVG namespace) - let svgContent = ``; - // Grid lines + let svg = ``; for (let g = 0; g <= 4; g++) { const gy = pad + (g / 4) * (h - pad * 2); - svgContent += ``; - } - - svgContent += ` - - - - - - - - - - - `; - - if (ftAreaPath) { - svgContent += ``; - } - - if (fpsAreaPath) { - svgContent += ``; - svgContent += ``; + svg += ``; } - + svg += ``; + if (ftArea.length) + svg += ``; + if (fpsArea.length) + svg += ``; if (fpsMax >= 60 && recent.length > 0) { - const targetY = pad + (1 - 60 / fpsMax) * (h - pad * 2); - svgContent += ``; + const ty = pad + (1 - 60 / fpsMax) * (h - pad * 2); + svg += ``; } + svg += ""; - svgContent += ``; - - // Subsystem active flags const hasFSM = this._state.fsmState !== null; - const hasEmotion = this._state.emotion !== null && this._state.emotion !== "neutral"; + const hasEmotion = + this._state.emotion !== null && this._state.emotion !== "neutral"; const hasFilters = this._state.activeFilters.length > 0; - const activeLayers = this._state.motionLayers.filter((l) => l.state !== "idle").length; - const activeModules = this._state.proceduralModules.filter((m) => m.enabled).length; - - return html` -
-
this._toggleSection("perf")}> - ${section.icon} - ${section.title} - = 30 ? "thinking" : "angry"}"> - ${avgFps.toFixed(1)} FPS - - ${section.expanded ? "▼" : "▶"} + const activeLayers = this._state.motionLayers.filter( + (l) => l.state !== "idle", + ).length; + const activeMods = this._state.proceduralModules.filter( + (m) => m.enabled, + ).length; + + const fpsColor = + avgFps >= 55 + ? "text-emerald-400" + : avgFps >= 30 + ? "text-amber-400" + : "text-red-400"; + + const extra = html`${avgFps.toFixed(1)} FPS`; + + const body = html` +
+ ${unsafeSVG(svg)} +
+ ${fpsMax.toFixed(0)} + ${(fpsMax / 2).toFixed(0)} + 0
- ${section.expanded ? html` -
- -
- ${unsafeSVG(svgContent)} -
- ${fpsMax.toFixed(0)} - ${(fpsMax / 2).toFixed(0)} - 0 -
-
+
- -
-
- ${avgFrameTime.toFixed(1)} - ms - 帧时间 -
-
- ${this._modelStats.parameterCount} - - 参数 -
-
- ${this._modelStats.drawableCount} - - Drawables -
-
- ${this._modelStats.partCount} - - 部件 -
+
+ ${[ + { + v: avgFt.toFixed(1), + u: "ms", + l: "帧时间", + c: + avgFt < 16.7 + ? "text-emerald-400" + : avgFt < 33 + ? "text-amber-400" + : "text-red-400", + }, + { + v: String(this._modelStats.parameterCount), + u: "个", + l: "参数", + c: "", + }, + { + v: String(this._modelStats.drawableCount), + u: "个", + l: "Drawables", + c: "", + }, + { v: String(this._modelStats.partCount), u: "个", l: "部件", c: "" }, + ].map( + (s) => html` +
+ ${s.v} + ${s.u} + ${s.l}
+ `, + )} +
- -
-
- 行为状态机 -
-
-
- ${hasFSM ? "运行" : "待机"} -
-
- 情感时间线 -
-
-
- ${hasEmotion ? "运行" : "待机"} -
-
- 动作层级 -
-
-
- ${activeLayers}/${this._state.motionLayers.length || 0} -
-
- 滤镜管线 -
-
-
- ${this._state.activeFilters.length} 个 -
-
- 程序化动画 -
-
-
- ${activeModules}/${this._state.proceduralModules.length || 0} +
+ ${[ + { n: "行为状态机", a: hasFSM, v: hasFSM ? "运行" : "待机" }, + { n: "情感时间线", a: hasEmotion, v: hasEmotion ? "运行" : "待机" }, + { + n: "动作层级", + a: activeLayers > 0, + v: `${activeLayers}/${this._state.motionLayers.length || 0}`, + }, + { + n: "滤镜管线", + a: hasFilters, + v: `${this._state.activeFilters.length} 个`, + }, + { + n: "程序化动画", + a: activeMods > 0, + v: `${activeMods}/${this._state.proceduralModules.length || 0}`, + }, + ].map( + (s) => html` +
+ ${s.n} +
+
+ ${s.v}
-
- ` : ""} + `, + )}
`; + + return this._sectionWrap("perf", "", body, extra); } private _renderStatusBar(): TemplateResult { - const fsmActive = this._state.fsmState !== null; - const emotionActive = this._state.emotion !== null && this._state.emotion !== "neutral"; + const fsmState = this._state.fsmState; + const fsmActive = fsmState !== null; + const emotionActive = + this._state.emotion !== null && this._state.emotion !== "neutral"; const hasFilters = this._state.activeFilters.length > 0; + const items = [ + { + label: "状态机", + active: fsmActive, + value: fsmActive ? (FSM_LABELS[fsmState] ?? fsmState) : "未激活", + }, + { + label: "情感", + active: emotionActive, + value: this._state.emotion + ? (EMOTION_LABELS[this._state.emotion] ?? this._state.emotion) + : "平静", + }, + { + label: "滤镜", + active: hasFilters, + value: hasFilters ? `${this._state.activeFilters.length} 个` : "无", + }, + ]; + return html` -
-
-
- 状态机 - ${fsmActive ? FSM_STATE_LABELS[this._state.fsmState!] ?? this._state.fsmState : "未激活"} -
-
-
- 情感 - ${this._state.emotion ? EMOTION_LABELS[this._state.emotion] ?? this._state.emotion : "平静"} -
-
-
- 滤镜 - ${hasFilters ? `${this._state.activeFilters.length} 个` : "无"} -
+
+ ${items.map( + (item) => html` +
+
+ ${item.label} + ${item.value} +
+ `, + )}
`; } private _renderFSMSection(): TemplateResult { - const section = this._sections.find((s) => s.id === "fsm"); - if (!section) return html``; - const states = ["idle", "happy", "thinking", "talking", "embarrassed", "angry", "sleepy", "sad"]; + const states = [ + "idle", + "happy", + "thinking", + "talking", + "embarrassed", + "angry", + "sleepy", + "sad", + ]; const fsm = this._controller?.getBehaviorFSM(); - - return html` -
-
this._toggleSection("fsm")}> - ${section.icon} - ${section.title} - - ${this._state.fsmState ? (FSM_STATE_LABELS[this._state.fsmState] ?? this._state.fsmState) : "—"} - - ${section.expanded ? "▼" : "▶"} -
- ${section.expanded ? html` -
-
- ${states.map((s) => html` - - `)} -
-
- ` : ""} + ?disabled=${fsm ? !fsm.canTransitionTo(s) : true}> + + ${FSM_LABELS[s] ?? s} + + `, + )}
`; + return this._sectionWrap("fsm", this._state.fsmState ?? "", body); } private _renderEmotionSection(): TemplateResult { - const section = this._sections.find((s) => s.id === "emotion"); - if (!section) return html``; - const emotions = ["neutral", "happy", "sad", "angry", "embarrassed", "surprised", "sleepy", "thinking"]; - - return html` -
-
this._toggleSection("emotion")}> - ${section.icon} - ${section.title} - - ${this._state.emotion ? (EMOTION_LABELS[this._state.emotion] ?? this._state.emotion) : "—"} - - ${section.expanded ? "▼" : "▶"} -
- ${section.expanded ? html` -
- ${this._state.isTransitioning ? html` -
-
- 过渡中 - ${Math.round(this._state.transitionProgress * 100)}% -
-
-
-
-
- ` : ""} -
- ${emotions.map((e) => html` - - `)} -
+ const emotions = [ + "neutral", + "happy", + "sad", + "angry", + "embarrassed", + "surprised", + "sleepy", + "thinking", + ]; + const body = html` + ${ + this._state.isTransitioning + ? html` +
+
+ 过渡中${Math.round(this._state.transitionProgress * 100)}%
- ` : ""} +
+
+
+
+ ` + : "" + } +
+ ${emotions.map( + (e) => html` + + `, + )}
`; + return this._sectionWrap("emotion", this._state.emotion ?? "", body); } private _renderMotionSection(): TemplateResult { - const section = this._sections.find((s) => s.id === "motion"); - if (!section) return html``; const layerNames: Record = { - physics: "物理", idle: "待机", expression: "表情", talk: "说话", gesture: "手势", + physics: "物理", + idle: "待机", + expression: "表情", + talk: "说话", + gesture: "手势", }; - - return html` -
-
this._toggleSection("motion")}> - ${section.icon} - ${section.title} - ${this._state.motionLayers.filter((l) => l.state !== "idle").length} / ${this._state.motionLayers.length} - ${section.expanded ? "▼" : "▶"} -
- ${section.expanded ? html` -
- ${this._state.motionLayers.length === 0 ? html` -
暂无动作层数据
- ` : html` -
- ${this._state.motionLayers.map((l) => html` -
-
- ${layerNames[l.name] ?? l.name} - ${this._layerStateLabel(l.state)} -
-
-
-
-
- ${l.weight.toFixed(2)} -
-
- `)} + const stateLabels: Record = { + idle: "空闲", + fadingIn: "淡入", + active: "活跃", + fadingOut: "淡出", + stopped: "停止", + }; + const activeCount = this._state.motionLayers.filter( + (l) => l.state !== "idle", + ).length; + + const body = + this._state.motionLayers.length === 0 + ? html`
暂无动作层数据
` + : html` +
+ ${this._state.motionLayers.map( + (l) => html` +
+
+ ${layerNames[l.name] ?? l.name} + ${stateLabels[l.state] ?? l.state}
- `} -
- ` : ""} -
- `; - } +
+
+
+
+ ${l.weight.toFixed(2)} +
+
+ `, + )} +
+ `; - private _layerStateLabel(state: string): string { - const labels: Record = { - idle: "空闲", fadingIn: "淡入", active: "活跃", fadingOut: "淡出", stopped: "停止", - }; - return labels[state] ?? state; + return this._sectionWrap( + "motion", + "", + body, + html`${activeCount}/${this._state.motionLayers.length}`, + ); } private _renderFilterSection(): TemplateResult { - const section = this._sections.find((s) => s.id === "filter"); - if (!section) return html``; - const presets: EffectPreset[] = ["evening-warm", "morning-cool", "neutral", "happy-glow", "shy-blush", "angry-red"]; + const presets: EffectPreset[] = [ + "evening-warm", + "morning-cool", + "neutral", + "happy-glow", + "shy-blush", + "angry-red", + ]; + const previewGradients: Record = { + "evening-warm": "from-amber-600 to-amber-800", + "morning-cool": "from-blue-400 to-blue-800", + neutral: "from-gray-400 to-gray-600", + "happy-glow": "from-amber-300 to-amber-500", + "shy-blush": "from-pink-400 to-pink-600", + "angry-red": "from-red-400 to-red-600", + }; - return html` -
-
this._toggleSection("filter")}> - ${section.icon} - ${section.title} - ${this._state.activeFilters.length} - ${section.expanded ? "▼" : "▶"} -
- ${section.expanded ? html` -
- - ${this._state.activeFilters.length > 0 ? html` -
- ${this._state.activeFilters.map((fx) => html` -
- ${fx.name} - this._setFilterIntensity(fx.id, Number((e.target as HTMLInputElement).value))} - /> - ${(fx.intensity * 100).toFixed(0)}% -
- `)} -
- ` : ""} -
- ${presets.map((p) => html` - - `)} + const body = html` + ${ + this._state.activeFilters.length > 0 + ? html` +
+ ${this._state.activeFilters.map( + (fx) => html` +
+ ${fx.name} + this._setFilterIntensity(fx.id, Number((e.target as HTMLInputElement).value))}/> + ${(fx.intensity * 100).toFixed(0)}%
- -
- ` : ""} + `, + )} +
+ ` + : "" + } +
+ ${presets.map( + (p) => html` + + `, + )}
+ `; + + return this._sectionWrap( + "filter", + "", + body, + html`${this._state.activeFilters.length}`, + ); } private _renderParamSection(): TemplateResult { - const section = this._sections.find((s) => s.id === "params"); - if (!section) return html``; const params = this._controller?.getSemanticParameters() ?? []; - - return html` -
-
this._toggleSection("params")}> - ${section.icon} - ${section.title} - ${params.length} - ${section.expanded ? "▼" : "▶"} -
- ${section.expanded ? html` -
-
- ${params.slice(0, 20).map((p) => html` -
- ${p.name} - this._setParamValue(p.name, Number((e.target as HTMLInputElement).value))} - /> - ${(p.value ?? 0).toFixed(2)} -
- `)} -
+ const valueColor = (v: number) => + v > 5 ? "text-emerald-400" : v < -5 ? "text-red-400" : "text-gray-400"; + + const body = html` +
+ ${params.slice(0, 20).map( + (p) => html` +
+ ${p.name} + this._setParamValue(p.name, Number((e.target as HTMLInputElement).value))}/> + ${(p.value ?? 0).toFixed(2)}
- ` : ""} + `, + )}
`; - } - private _valueColor(v: number): string { - if (v > 5) return "positive"; - if (v < -5) return "negative"; - return "neutral"; + return this._sectionWrap( + "params", + "", + body, + html`${params.length}`, + ); } private _renderProceduralSection(): TemplateResult { - const section = this._sections.find((s) => s.id === "procedural"); - if (!section) return html``; const moduleLabels: Record = { - Breathing: "呼吸动画", Blink: "眨眼动画", EyeTracking: "视线追踪", + Breathing: "呼吸动画", + Blink: "眨眼动画", + EyeTracking: "视线追踪", }; - - return html` -
-
this._toggleSection("procedural")}> - ${section.icon} - ${section.title} - ${section.expanded ? "▼" : "▶"} -
- ${section.expanded ? html` -
-
- ${this._state.proceduralModules.map((m) => html` -
-
-
- ${moduleLabels[m.name] ?? m.name} -
- ${m.enabled ? "运行中" : "已停止"} -
- `)} + const body = html` +
+ ${this._state.proceduralModules.map( + (m) => html` +
+
+
+ ${moduleLabels[m.name] ?? m.name}
+ ${m.enabled ? "运行中" : "已停止"}
- ` : ""} + `, + )}
`; + return this._sectionWrap("procedural", "", body); } private _renderConflictSection(): TemplateResult { - const section = this._sections.find((s) => s.id === "conflicts"); - if (!section) return html``; - - return html` -
-
this._toggleSection("conflicts")}> - ${section.icon} - ${section.title} - ${this._conflicts.length} - ${section.expanded ? "▼" : "▶"} -
- ${section.expanded ? html` -
- ${this._conflicts.length === 0 ? html` -
暂无系统间冲突
- ` : html` -
- ${this._conflicts.slice(-10).reverse().map((c) => html` -
-
- ${c.parameter} - ${new Date(c.timestamp).toLocaleTimeString("zh-CN")} -
-
- ${c.losingSystem} ${c.losingValue.toFixed(2)} - - ${c.winningSystem} ${c.winningValue.toFixed(2)} -
-
- `)} + const hasConflicts = this._conflicts.length > 0; + const body = !hasConflicts + ? html`
暂无系统间冲突
` + : html` +
+ ${this._conflicts + .slice(-10) + .reverse() + .map( + (c) => html` +
+
+ ${c.parameter} + ${new Date(c.timestamp).toLocaleTimeString("zh-CN")}
- - `} -
- ` : ""} -
- `; +
+ ${c.losingSystem} ${c.losingValue.toFixed(2)} + + ${c.winningSystem} ${c.winningValue.toFixed(2)} +
+
+ `, + )} +
+ + `; + + return this._sectionWrap( + "conflicts", + "", + body, + html`${this._conflicts.length}`, + ); } static styles = unsafeCSS(` - :host { - position: fixed; - z-index: 9999; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; - font-size: 13px; - line-height: 1.5; - } - - /* ── Indicator ── */ - .l2d-indicator { - position: fixed; - bottom: 16px; - right: 16px; - width: 44px; - height: 44px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - } - .l2d-indicator-icon { - width: 40px; - height: 40px; - background: rgba(30, 30, 40, 0.9); - backdrop-filter: blur(12px); - border: 1px solid rgba(255,255,255,0.1); - border-radius: 12px; - display: flex; - align-items: center; - justify-content: center; - font-size: 20px; - box-shadow: 0 4px 20px rgba(0,0,0,0.3); - transition: transform 0.2s, box-shadow 0.2s; - position: relative; - z-index: 2; - } - .l2d-indicator:hover .l2d-indicator-icon { - transform: scale(1.08); - box-shadow: 0 6px 24px rgba(0,0,0,0.4); - } - .l2d-indicator-pulse { - position: absolute; - width: 40px; - height: 40px; - border-radius: 12px; - background: rgba(13, 115, 119, 0.3); - animation: l2d-pulse 2s ease-out infinite; - z-index: 1; - } - @keyframes l2d-pulse { - 0% { transform: scale(1); opacity: 0.6; } - 100% { transform: scale(1.6); opacity: 0; } - } - - /* ── Panel ── */ - .l2d-panel { - position: fixed; - width: 360px; - max-height: 85vh; - background: rgba(22, 22, 30, 0.95); - backdrop-filter: blur(20px); - border: 1px solid rgba(255,255,255,0.06); - border-radius: 16px; - box-shadow: 0 24px 64px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.03); - overflow: hidden; - display: flex; - flex-direction: column; - color: #e2e2e8; - } - - /* ── Header ── */ - .l2d-header { - padding: 14px 18px; - background: rgba(255,255,255,0.03); - border-bottom: 1px solid rgba(255,255,255,0.05); - display: flex; - justify-content: space-between; - align-items: center; - cursor: grab; - user-select: none; - } - .l2d-header:active { cursor: grabbing; } - .l2d-header-left { - display: flex; - align-items: center; - gap: 10px; - } - .l2d-header-icon { font-size: 18px; } - .l2d-header-title { - font-weight: 600; - font-size: 14px; - letter-spacing: 0.3px; - } - .l2d-header-badge { - font-size: 10px; - font-weight: 700; - padding: 2px 7px; - background: rgba(13, 115, 119, 0.3); - color: #5eead4; - border-radius: 6px; - border: 1px solid rgba(94, 234, 212, 0.15); - } - .l2d-header-close { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - background: rgba(255,255,255,0.05); - border: none; - border-radius: 8px; - color: #888; - cursor: pointer; - transition: all 0.15s; - } - .l2d-header-close:hover { - background: rgba(239, 68, 68, 0.2); - color: #ef4444; - } - - /* ── Status Bar ── */ - .l2d-status-bar { - display: flex; - gap: 8px; - padding: 12px 16px; - border-bottom: 1px solid rgba(255,255,255,0.04); - } - .l2d-status-item { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - gap: 5px; - padding: 8px 4px; - border-radius: 10px; - background: rgba(255,255,255,0.02); - transition: background 0.2s; - } - .l2d-status-item.active { background: rgba(13, 115, 119, 0.08); } - .l2d-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #444; - box-shadow: 0 0 0 2px rgba(68,68,68,0.2); - transition: all 0.3s; - } - .l2d-status-dot.on { - background: #10b981; - box-shadow: 0 0 6px rgba(16,185,129,0.4), 0 0 0 2px rgba(16,185,129,0.15); - } - .l2d-status-label { - font-size: 11px; - color: #777; - } - .l2d-status-value { - font-size: 12px; - font-weight: 600; - color: #bbb; - } - .l2d-status-item.active .l2d-status-value { color: #5eead4; } - - /* ── Body ── */ - .l2d-body { - overflow-y: auto; - padding: 4px; - } - - /* ── Section ── */ - .l2d-section { - margin: 4px 6px; - border-radius: 12px; - overflow: hidden; - background: rgba(255,255,255,0.015); - border: 1px solid rgba(255,255,255,0.03); - } - .l2d-section-header { - padding: 10px 14px; - display: flex; - align-items: center; - gap: 10px; - cursor: pointer; - user-select: none; - transition: background 0.15s; - } - .l2d-section-header:hover { background: rgba(255,255,255,0.03); } - .l2d-section-icon { font-size: 15px; opacity: 0.85; } - .l2d-section-title { - font-weight: 500; - font-size: 13px; - color: #c4c4ce; - } - .l2d-section-current { - margin-left: auto; - font-size: 12px; - font-weight: 600; - padding: 2px 10px; - border-radius: 8px; - background: rgba(255,255,255,0.06); - color: #aaa; - } - .l2d-section-current.idle, .l2d-section-current.neutral { color: #94a3b8; } - .l2d-section-current.happy { color: #fbbf24; background: rgba(251,191,36,0.1); } - .l2d-section-current.sad { color: #60a5fa; background: rgba(96,165,250,0.1); } - .l2d-section-current.angry { color: #f87171; background: rgba(248,113,113,0.1); } - .l2d-section-current.embarrassed { color: #f472b6; background: rgba(244,114,182,0.1); } - .l2d-section-current.thinking { color: #a78bfa; background: rgba(167,139,250,0.1); } - .l2d-section-current.talking { color: #34d399; background: rgba(52,211,153,0.1); } - .l2d-section-current.sleepy { color: #818cf8; background: rgba(129,140,248,0.1); } - .l2d-section-current.surprised { color: #fb923c; background: rgba(251,146,60,0.1); } - .l2d-section-badge { - margin-left: auto; - font-size: 11px; - font-weight: 600; - padding: 2px 8px; - border-radius: 8px; - background: rgba(255,255,255,0.06); - color: #888; - } - .l2d-section-badge.warn { - background: rgba(234,179,8,0.12); - color: #eab308; - } - .l2d-section-arrow { - font-size: 10px; - color: #555; - margin-left: 6px; - } - .l2d-section-body { - padding: 0 14px 14px; - } - - /* ── Chips ── */ - .l2d-chip-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 8px; - } - .l2d-chip { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 8px 4px; - background: rgba(255,255,255,0.04); - border: 1px solid rgba(255,255,255,0.06); - border-radius: 10px; - color: #b0b0bc; - cursor: pointer; - font-size: 12px; - transition: all 0.15s; - } - .l2d-chip:hover:not(:disabled) { - background: rgba(255,255,255,0.08); - border-color: rgba(255,255,255,0.12); - transform: translateY(-1px); - } - .l2d-chip:active:not(:disabled) { transform: translateY(0); } - .l2d-chip.active { - background: rgba(13, 115, 119, 0.2); - border-color: rgba(94, 234, 212, 0.3); - color: #5eead4; - box-shadow: 0 0 12px rgba(94,234,212,0.08); - } - .l2d-chip:disabled { - opacity: 0.35; - cursor: not-allowed; - } - .l2d-chip-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: #555; - } - .l2d-chip.active .l2d-chip-dot { background: #5eead4; } - .l2d-chip-dot.idle, .l2d-chip-dot.neutral { background: #64748b; } - .l2d-chip-dot.happy { background: #fbbf24; } - .l2d-chip-dot.sad { background: #60a5fa; } - .l2d-chip-dot.angry { background: #f87171; } - .l2d-chip-dot.embarrassed { background: #f472b6; } - .l2d-chip-dot.thinking { background: #a78bfa; } - .l2d-chip-dot.talking { background: #34d399; } - .l2d-chip-dot.sleepy { background: #818cf8; } - .l2d-chip-dot.surprised { background: #fb923c; } - - /* ── Progress ── */ - .l2d-progress-wrap { - margin-bottom: 12px; - } - .l2d-progress-label { - display: flex; - justify-content: space-between; - font-size: 11px; - color: #888; - margin-bottom: 6px; - } - .l2d-progress-track { - height: 6px; - background: rgba(255,255,255,0.06); - border-radius: 3px; - overflow: hidden; - } - .l2d-progress-fill { - height: 100%; - background: rgba(94, 234, 212, 0.6); - border-radius: 3px; - transition: width 0.1s linear; - box-shadow: 0 0 8px rgba(94,234,212,0.2); - } - - /* ── Layers ── */ - .l2d-layer-list { display: flex; flex-direction: column; gap: 8px; } - .l2d-layer-item { - display: flex; - flex-direction: column; - gap: 6px; - padding: 10px 12px; - background: rgba(255,255,255,0.02); - border-radius: 10px; - border: 1px solid rgba(255,255,255,0.03); - } - .l2d-layer-info { - display: flex; - justify-content: space-between; - align-items: center; - } - .l2d-layer-name { font-weight: 500; font-size: 12px; color: #ccc; } - .l2d-layer-state { - font-size: 11px; - padding: 2px 8px; - border-radius: 6px; - background: rgba(255,255,255,0.05); - color: #777; - } - .l2d-layer-state.active { - background: rgba(16,185,129,0.1); - color: #34d399; - } - .l2d-layer-state.fadingIn, .l2d-layer-state.fadingOut { - background: rgba(251,191,36,0.1); - color: #fbbf24; - } - .l2d-layer-metrics { - display: flex; - align-items: center; - gap: 10px; - } - .l2d-layer-bar-wrap { - flex: 1; - height: 4px; - background: rgba(255,255,255,0.05); - border-radius: 2px; - overflow: hidden; - } - .l2d-layer-bar { - height: 100%; - background: rgba(94,234,212,0.5); - border-radius: 2px; - transition: width 0.3s; - } - .l2d-layer-value { - font-size: 11px; - font-family: 'SF Mono', monospace; - color: #5eead4; - min-width: 36px; - text-align: right; - } - - /* ── Active Filters ── */ - .l2d-active-filters { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 12px; - } - .l2d-active-filter-row { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - border-radius: 10px; - background: rgba(255,255,255,0.02); - border: 1px solid rgba(255,255,255,0.04); - } - .l2d-active-filter-name { - width: 70px; - font-size: 11px; - color: #bbb; - font-weight: 500; - } - - /* ── Filters ── */ - .l2d-filter-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; - } - .l2d-filter-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - padding: 10px 4px; - background: rgba(255,255,255,0.03); - border: 1px solid rgba(255,255,255,0.05); - border-radius: 10px; - cursor: pointer; - transition: all 0.15s; - } - .l2d-filter-card:hover { - background: rgba(255,255,255,0.06); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0,0,0,0.2); - } - .l2d-filter-preview { - width: 32px; - height: 32px; - border-radius: 50%; - border: 2px solid rgba(255,255,255,0.1); - } - .l2d-filter-preview.evening-warm { background: linear-gradient(135deg, #d97706, #92400e); } - .l2d-filter-preview.morning-cool { background: linear-gradient(135deg, #60a5fa, #1e40af); } - .l2d-filter-preview.neutral { background: linear-gradient(135deg, #9ca3af, #4b5563); } - .l2d-filter-preview.happy-glow { background: linear-gradient(135deg, #fbbf24, #f59e0b); } - .l2d-filter-preview.shy-blush { background: linear-gradient(135deg, #f472b6, #db2777); } - .l2d-filter-preview.angry-red { background: linear-gradient(135deg, #f87171, #dc2626); } - .l2d-filter-name { font-size: 11px; color: #aaa; } - - /* ── Params ── */ - .l2d-param-list { display: flex; flex-direction: column; gap: 6px; } - .l2d-param-row { - display: flex; - align-items: center; - gap: 10px; - padding: 5px 8px; - border-radius: 8px; - background: rgba(255,255,255,0.015); - } - .l2d-param-name { - width: 80px; - font-size: 11px; - color: #999; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: 'SF Mono', monospace; - } - .l2d-param-slider { - flex: 1; - height: 4px; - -webkit-appearance: none; - appearance: none; - background: rgba(255,255,255,0.06); - border-radius: 2px; - outline: none; - } - .l2d-param-slider::-webkit-slider-thumb { - -webkit-appearance: none; - width: 14px; - height: 14px; - border-radius: 50%; - background: #5eead4; - cursor: pointer; + input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; appearance: none; + width: 14px; height: 14px; border-radius: 50%; + background: #5eead4; cursor: pointer; box-shadow: 0 0 8px rgba(94,234,212,0.3); border: 2px solid rgba(22,22,30,0.8); } - .l2d-param-value { - width: 44px; - text-align: right; - font-family: 'SF Mono', monospace; - font-size: 11px; - font-weight: 600; - } - .l2d-param-value.positive { color: #34d399; } - .l2d-param-value.negative { color: #f87171; } - .l2d-param-value.neutral { color: #aaa; } - - /* ── Modules ── */ - .l2d-module-list { display: flex; flex-direction: column; gap: 8px; } - .l2d-module-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 12px; - background: rgba(255,255,255,0.02); - border-radius: 10px; - } - .l2d-module-left { - display: flex; - align-items: center; - gap: 10px; - } - .l2d-module-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #444; - transition: all 0.3s; - } - .l2d-module-dot.on { - background: #10b981; - box-shadow: 0 0 8px rgba(16,185,129,0.4); - } - .l2d-module-dot.off { background: #444; } - .l2d-module-name { font-size: 12px; color: #bbb; } - .l2d-module-status { - font-size: 11px; - padding: 3px 10px; - border-radius: 6px; - font-weight: 500; - } - .l2d-module-status.on { - background: rgba(16,185,129,0.1); - color: #34d399; - } - .l2d-module-status.off { - background: rgba(255,255,255,0.04); - color: #666; - } - - /* ── Conflicts ── */ - .l2d-conflict-list { - display: flex; - flex-direction: column; - gap: 8px; - max-height: 160px; - overflow-y: auto; - } - .l2d-conflict-item { - padding: 10px 12px; - background: rgba(255,255,255,0.02); - border-radius: 10px; - border-left: 3px solid rgba(234,179,8,0.5); - } - .l2d-conflict-meta { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 4px; - } - .l2d-conflict-param { - font-weight: 600; - font-size: 12px; - color: #e8a87c; - } - .l2d-conflict-time { - font-size: 10px; - color: #555; - } - .l2d-conflict-detail { - display: flex; - align-items: center; - gap: 8px; - font-size: 11px; - color: #777; - } - .l2d-conflict-loser code { - color: #f87171; - background: rgba(248,113,113,0.08); - padding: 1px 5px; - border-radius: 4px; - font-family: 'SF Mono', monospace; - } - .l2d-conflict-winner code { - color: #34d399; - background: rgba(52,211,153,0.08); - padding: 1px 5px; - border-radius: 4px; - font-family: 'SF Mono', monospace; - } - .l2d-conflict-arrow { color: #555; } - - /* ── Performance Chart ── */ - .l2d-chart-wrap { - position: relative; - height: 90px; - margin-bottom: 14px; - background: rgba(0,0,0,0.2); - border-radius: 10px; - overflow: hidden; - border: 1px solid rgba(255,255,255,0.04); - } - .l2d-chart { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - } - .l2d-chart-labels { - position: absolute; - right: 6px; - top: 4px; - bottom: 4px; - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: flex-end; - pointer-events: none; - } - .l2d-chart-label { - font-size: 9px; - color: #555; - font-family: 'SF Mono', monospace; - } - - /* ── Performance Grid ── */ - .l2d-perf-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 8px; - margin-bottom: 14px; - } - .l2d-perf-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - padding: 10px 4px; - background: rgba(255,255,255,0.02); - border-radius: 10px; - border: 1px solid rgba(255,255,255,0.04); - } - .l2d-perf-value { - font-size: 18px; - font-weight: 700; - font-family: 'SF Mono', monospace; - color: #e2e2e8; - line-height: 1; - } - .l2d-perf-value.positive { color: #34d399; } - .l2d-perf-value.negative { color: #f87171; } - .l2d-perf-value.neutral { color: #fbbf24; } - .l2d-perf-unit { - font-size: 9px; - color: #666; - font-family: 'SF Mono', monospace; - } - .l2d-perf-label { - font-size: 10px; - color: #777; - margin-top: 2px; - } - - /* ── Subsystem Load Bars ── */ - .l2d-subsys-list { - display: flex; - flex-direction: column; - gap: 8px; - } - .l2d-subsys-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - background: rgba(255,255,255,0.015); - border-radius: 8px; - } - .l2d-subsys-name { - width: 72px; - font-size: 11px; - color: #999; - } - .l2d-subsys-bar-wrap { - flex: 1; - height: 5px; - background: rgba(255,255,255,0.05); - border-radius: 3px; - overflow: hidden; - } - .l2d-subsys-bar { - height: 100%; - width: 0%; - background: #555; - border-radius: 3px; - transition: width 0.4s ease; - } - .l2d-subsys-bar.active { - background: linear-gradient(90deg, #0d7377, #5eead4); - box-shadow: 0 0 8px rgba(94,234,212,0.15); - } - .l2d-subsys-status { - width: 44px; - text-align: right; - font-size: 10px; - color: #666; - font-family: 'SF Mono', monospace; - } - - /* ── Buttons ── */ - .l2d-btn { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - width: 100%; - padding: 10px; - margin-top: 10px; - background: rgba(255,255,255,0.05); - border: 1px solid rgba(255,255,255,0.08); - border-radius: 10px; - color: #aaa; - cursor: pointer; - font-size: 12px; - transition: all 0.15s; - } - .l2d-btn:hover { - background: rgba(255,255,255,0.08); - color: #ddd; - } - .l2d-btn-secondary { - background: rgba(239,68,68,0.06); - border-color: rgba(239,68,68,0.12); - color: #f87171; - } - .l2d-btn-secondary:hover { - background: rgba(239,68,68,0.1); - } - - /* ── Empty ── */ - .l2d-empty { - text-align: center; - padding: 20px; - color: #555; - font-size: 12px; + input[type="range"]::-moz-range-thumb { + width: 14px; height: 14px; border-radius: 50%; + background: #5eead4; cursor: pointer; + box-shadow: 0 0 8px rgba(94,234,212,0.3); + border: 2px solid rgba(22,22,30,0.8); } + input[type="range"] { -webkit-appearance: none; appearance: none; background: transparent; } + input[type="range"]::-webkit-slider-runnable-track { height: 4px; background: rgba(255,255,255,0.06); border-radius: 2px; } + input[type="range"]::-moz-range-track { height: 4px; background: rgba(255,255,255,0.06); border-radius: 2px; } `); } diff --git a/packages/live2d/src/config/default-config.ts b/packages/live2d/src/config/default-config.ts index 6f8dd38..5353f73 100644 --- a/packages/live2d/src/config/default-config.ts +++ b/packages/live2d/src/config/default-config.ts @@ -1,84 +1,84 @@ import type { Live2dConfig } from "@/live2d/context/config-context"; export const DEFAULT_TOOL_NAMES = [ - "hitokoto", - "asteroids", - "switch-model", - "switch-texture", - "photo", - "info", - "quit", + "hitokoto", + "asteroids", + "switch-model", + "switch-texture", + "photo", + "info", + "quit", ] as const; export const createDefaultLive2dConfig = (): Live2dConfig => ({ - apiPath: "https://live2d.fghrsh.net/api/", - live2dLocation: "left", - consoleShowStatus: import.meta.env.DEV ?? false, - isForceUseDefaultConfig: false, - modelId: 1, - modelTexturesId: 53, - tipsPath: "", - selectorTips: [], - backSite: true, - backSiteTip: "", - copyContent: true, - copyContentTip: "", - openConsole: true, - openConsoleTip: "", - firstOpenSite: true, - isTools: true, - tools: [...DEFAULT_TOOL_NAMES], - isAiChat: true, - chunkTimeout: 10, - showChatMessageTimeout: 10, - screenshotName: "live2d", - filterQuality: "high", - proceduralAnimation: { - enabled: true, - breathing: { - enabled: true, - period: 3000, - amplitude: 0.15, - }, - blink: { - enabled: true, - minInterval: 2000, - maxInterval: 6000, - duration: 150, - }, - eyeTracking: { - enabled: true, - maxAngleX: 15, - maxAngleY: 10, - maxEyeBallX: 1.5, - maxEyeBallY: 1.5, - smoothing: 0.15, - }, - }, - motionLayers: { - enabled: true, - layers: { - idle: { priority: 1 }, - expression: { priority: 2 }, - talk: { priority: 3 }, - gesture: { priority: 4 }, - physics: { priority: 5 }, - }, - defaultCrossfadeDuration: 300, - }, - behaviorFSM: { - enabled: true, - initialState: "idle", - defaultDebounceMs: 300, - }, - emotionTimeline: { - enabled: true, - defaultDuration: 800, - minDuration: 200, - defaultEasing: "easeOut", - idleReturnDelay: 3000, - }, - devTools: { - enabled: false, - }, + apiPath: "https://live2d.fghrsh.net/api/", + live2dLocation: "left", + consoleShowStatus: import.meta.env.DEV ?? false, + isForceUseDefaultConfig: false, + modelId: 1, + modelTexturesId: 53, + tipsPath: "", + selectorTips: [], + backSite: true, + backSiteTip: "", + copyContent: true, + copyContentTip: "", + openConsole: true, + openConsoleTip: "", + firstOpenSite: true, + isTools: true, + tools: [...DEFAULT_TOOL_NAMES], + isAiChat: true, + chunkTimeout: 10, + showChatMessageTimeout: 10, + screenshotName: "live2d", + filterQuality: "high", + proceduralAnimation: { + enabled: true, + breathing: { + enabled: true, + period: 3000, + amplitude: 0.15, + }, + blink: { + enabled: false, + minInterval: 2000, + maxInterval: 6000, + duration: 150, + }, + eyeTracking: { + enabled: true, + maxAngleX: 15, + maxAngleY: 10, + maxEyeBallX: 1.5, + maxEyeBallY: 1.5, + smoothing: 0.15, + }, + }, + motionLayers: { + enabled: true, + layers: { + idle: { priority: 1 }, + expression: { priority: 2 }, + talk: { priority: 3 }, + gesture: { priority: 4 }, + physics: { priority: 5 }, + }, + defaultCrossfadeDuration: 300, + }, + behaviorFSM: { + enabled: true, + initialState: "idle", + defaultDebounceMs: 300, + }, + emotionTimeline: { + enabled: true, + defaultDuration: 800, + minDuration: 200, + defaultEasing: "easeOut", + idleReturnDelay: 3000, + }, + devTools: { + enabled: false, + }, }); diff --git a/packages/live2d/src/runtime/behavior/built-in-states.ts b/packages/live2d/src/runtime/behavior/built-in-states.ts index c654f86..4f03e5e 100644 --- a/packages/live2d/src/runtime/behavior/built-in-states.ts +++ b/packages/live2d/src/runtime/behavior/built-in-states.ts @@ -1,5 +1,5 @@ -import type { BehaviorState, BehaviorProfile } from "./types"; import { buildProfile } from "./profile"; +import type { BehaviorProfile, BehaviorState } from "./types"; // ── Base profile ────────────────────────────────────────────── @@ -51,8 +51,6 @@ const sadProfile: BehaviorProfile = buildProfile(baseProfile, { browLY: { value: 0.3, blendMode: "override" }, browRY: { value: 0.3, blendMode: "override" }, mouthForm: { value: -0.3, blendMode: "override" }, - eyeLOpen: { value: 0.8, blendMode: "override" }, - eyeROpen: { value: 0.8, blendMode: "override" }, }, fadeIn: 600, }, @@ -82,8 +80,6 @@ const embarrassedProfile: BehaviorProfile = buildProfile(baseProfile, { expression: { parameters: { cheek: { value: 0.5, blendMode: "override" }, - eyeLOpen: { value: 0.7, blendMode: "override" }, - eyeROpen: { value: 0.7, blendMode: "override" }, browLY: { value: 0.1, blendMode: "override" }, browRY: { value: 0.1, blendMode: "override" }, mouthSmile: { value: 0.2, blendMode: "override" }, @@ -100,8 +96,6 @@ const thinkingProfile: BehaviorProfile = buildProfile(baseProfile, { parameters: { browLY: { value: -0.2, blendMode: "override" }, browRY: { value: -0.2, blendMode: "override" }, - eyeLOpen: { value: 0.85, blendMode: "override" }, - eyeROpen: { value: 0.85, blendMode: "override" }, mouthForm: { value: 0.1, blendMode: "override" }, }, fadeIn: 600, @@ -124,13 +118,6 @@ const talkingProfile: BehaviorProfile = buildProfile(baseProfile, { }, fadeIn: 200, }, - expression: { - parameters: { - eyeLOpen: { value: 0.9, blendMode: "override" }, - eyeROpen: { value: 0.9, blendMode: "override" }, - }, - fadeIn: 300, - }, }, }); @@ -138,8 +125,6 @@ const sleepyProfile: BehaviorProfile = buildProfile(baseProfile, { motionLayers: { expression: { parameters: { - eyeLOpen: { value: 0.4, blendMode: "override" }, - eyeROpen: { value: 0.4, blendMode: "override" }, browLY: { value: 0.15, blendMode: "override" }, browRY: { value: 0.15, blendMode: "override" }, mouthForm: { value: 0.05, blendMode: "override" }, @@ -147,9 +132,6 @@ const sleepyProfile: BehaviorProfile = buildProfile(baseProfile, { fadeIn: 1000, }, }, - proceduralOverrides: { - Blink: false, - }, }); // ── Built-in state definitions ────────────────────────────────