diff --git a/apps/stage-tamagotchi/src/renderer/pages/index.vue b/apps/stage-tamagotchi/src/renderer/pages/index.vue index d7a2889c0..d1f1b1b73 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/index.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/index.vue @@ -116,7 +116,7 @@ const isTransparent = computed(() => { if (stageModelRenderer.value === 'vrm') return shouldUseThreeTransparencyHitTest.value ? isTransparentByThree.value : true - if (stageModelRenderer.value === 'live2d') + if (stageModelRenderer.value === 'live2d' || stageModelRenderer.value === 'tachie') return isTransparentByPixels.value return true @@ -128,7 +128,7 @@ const isTransparentForMouseEvents = computed(() => { if (stageModelRenderer.value === 'vrm') return shouldUseThreeTransparencyHitTest.value ? isTransparentByThreeExact.value : true - if (stageModelRenderer.value === 'live2d') + if (stageModelRenderer.value === 'live2d' || stageModelRenderer.value === 'tachie') return isTransparentByPixelsExact.value return true @@ -190,6 +190,20 @@ const modelSettingsRuntimeSnapshot = computed(() = }) } + if (stageModelRenderer.value === 'tachie') { + const phase = resolveComponentStateToRuntimePhase(componentStateStage.value, { hasModel }) + + return createEmptyModelSettingsRuntimeSnapshot({ + ownerInstanceId: modelSettingsRuntimeOwnerInstanceId, + renderer: 'tachie', + phase, + controlsLocked: hasModel ? phase !== 'mounted' : false, + previewAvailable: hasModel, + canCapturePreview: false, + updatedAt: Date.now(), + }) + } + if (stageModelRenderer.value === 'mmd') { const phase = resolveComponentStateToRuntimePhase(componentStateStage.value, { hasModel }) diff --git a/cspell.config.yaml b/cspell.config.yaml index e47849013..ae601a00f 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -305,6 +305,7 @@ words: - supergroup - superjson - systempreferences + - tachie - tamagotchi - tauri - taze diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 2569f17f8..6d91d0d04 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -1809,6 +1809,22 @@ mmd: description: >- Adjust the opacity of individual model parts (face, hair, clothing, accessories). Set a part to 0 to hide it. +tachie: + title: Tachie Settings + archive-description: Tachie models use .tachie.zip archives containing emotion-named images. + scale-and-position: + title: Scale And Position + scale: Scale + x: X + 'y': 'Y' + theme-color-from-model: + title: Extract colors from model + button-extract: + title: Extract + rendering: + title: Rendering + render-scale: Render Scale + shadow: Theme-colored drop shadow websocket-secure-enabled: title: Enable Secure WebSocket (WSS) description: >- diff --git a/packages/i18n/src/locales/es/settings.yaml b/packages/i18n/src/locales/es/settings.yaml index 119c06cda..5956a172f 100644 --- a/packages/i18n/src/locales/es/settings.yaml +++ b/packages/i18n/src/locales/es/settings.yaml @@ -1738,6 +1738,22 @@ mmd: no-materials: This model exposes no materials. description: >- Adjust the opacity of individual model parts (face, hair, clothing, accessories). Set a part to 0 to hide it. +tachie: + title: Tachie Settings + archive-description: Tachie models use .tachie.zip archives containing emotion-named images. + scale-and-position: + title: Scale And Position + scale: Scale + x: X + 'y': 'Y' + theme-color-from-model: + title: Extract colors from model + button-extract: + title: Extract + rendering: + title: Rendering + render-scale: Render Scale + shadow: Theme-colored drop shadow websocket-secure-enabled: title: Habilitar WebSocket Seguro (WSS) description: >- diff --git a/packages/i18n/src/locales/fr/settings.yaml b/packages/i18n/src/locales/fr/settings.yaml index ec1542690..94461ada4 100644 --- a/packages/i18n/src/locales/fr/settings.yaml +++ b/packages/i18n/src/locales/fr/settings.yaml @@ -1738,6 +1738,22 @@ mmd: no-materials: This model exposes no materials. description: >- Adjust the opacity of individual model parts (face, hair, clothing, accessories). Set a part to 0 to hide it. +tachie: + title: Tachie Settings + archive-description: Tachie models use .tachie.zip archives containing emotion-named images. + scale-and-position: + title: Scale And Position + scale: Scale + x: X + 'y': 'Y' + theme-color-from-model: + title: Extract colors from model + button-extract: + title: Extract + rendering: + title: Rendering + render-scale: Render Scale + shadow: Theme-colored drop shadow websocket-secure-enabled: title: Activer WebSocket Sécurisé (WSS) description: >- diff --git a/packages/i18n/src/locales/ja/settings.yaml b/packages/i18n/src/locales/ja/settings.yaml index b82f9d330..f0a0502c3 100644 --- a/packages/i18n/src/locales/ja/settings.yaml +++ b/packages/i18n/src/locales/ja/settings.yaml @@ -1738,6 +1738,22 @@ mmd: no-materials: This model exposes no materials. description: >- Adjust the opacity of individual model parts (face, hair, clothing, accessories). Set a part to 0 to hide it. +tachie: + title: Tachie Settings + archive-description: Tachie models use .tachie.zip archives containing emotion-named images. + scale-and-position: + title: Scale And Position + scale: Scale + x: X + 'y': 'Y' + theme-color-from-model: + title: Extract colors from model + button-extract: + title: Extract + rendering: + title: Rendering + render-scale: Render Scale + shadow: Theme-colored drop shadow websocket-secure-enabled: title: セキュア WebSocket (WSS) を有効にする description: >- diff --git a/packages/i18n/src/locales/ko/settings.yaml b/packages/i18n/src/locales/ko/settings.yaml index 48852afa6..4e9f88d6a 100644 --- a/packages/i18n/src/locales/ko/settings.yaml +++ b/packages/i18n/src/locales/ko/settings.yaml @@ -1738,6 +1738,22 @@ mmd: no-materials: This model exposes no materials. description: >- Adjust the opacity of individual model parts (face, hair, clothing, accessories). Set a part to 0 to hide it. +tachie: + title: Tachie Settings + archive-description: Tachie models use .tachie.zip archives containing emotion-named images. + scale-and-position: + title: Scale And Position + scale: Scale + x: X + 'y': 'Y' + theme-color-from-model: + title: Extract colors from model + button-extract: + title: Extract + rendering: + title: Rendering + render-scale: Render Scale + shadow: Theme-colored drop shadow websocket-secure-enabled: title: 보안 웹소켓 (WSS) 활성화 description: >- diff --git a/packages/i18n/src/locales/ru/settings.yaml b/packages/i18n/src/locales/ru/settings.yaml index d9c3e407e..42a71d2ad 100644 --- a/packages/i18n/src/locales/ru/settings.yaml +++ b/packages/i18n/src/locales/ru/settings.yaml @@ -1738,6 +1738,22 @@ mmd: no-materials: This model exposes no materials. description: >- Adjust the opacity of individual model parts (face, hair, clothing, accessories). Set a part to 0 to hide it. +tachie: + title: Tachie Settings + archive-description: Tachie models use .tachie.zip archives containing emotion-named images. + scale-and-position: + title: Scale And Position + scale: Scale + x: X + 'y': 'Y' + theme-color-from-model: + title: Extract colors from model + button-extract: + title: Extract + rendering: + title: Rendering + render-scale: Render Scale + shadow: Theme-colored drop shadow websocket-secure-enabled: title: Включить безопасный WebSocket (WSS) description: >- diff --git a/packages/i18n/src/locales/vi/settings.yaml b/packages/i18n/src/locales/vi/settings.yaml index d1b86a700..614d35049 100644 --- a/packages/i18n/src/locales/vi/settings.yaml +++ b/packages/i18n/src/locales/vi/settings.yaml @@ -1738,6 +1738,22 @@ mmd: no-materials: This model exposes no materials. description: >- Adjust the opacity of individual model parts (face, hair, clothing, accessories). Set a part to 0 to hide it. +tachie: + title: Tachie Settings + archive-description: Tachie models use .tachie.zip archives containing emotion-named images. + scale-and-position: + title: Scale And Position + scale: Scale + x: X + 'y': 'Y' + theme-color-from-model: + title: Extract colors from model + button-extract: + title: Extract + rendering: + title: Rendering + render-scale: Render Scale + shadow: Theme-colored drop shadow websocket-secure-enabled: title: Bật WebSocket bảo mật (WSS) description: >- diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 9e1c7719d..ec236e14a 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -1738,6 +1738,22 @@ mmd: no-materials: This model exposes no materials. description: >- Adjust the opacity of individual model parts (face, hair, clothing, accessories). Set a part to 0 to hide it. +tachie: + title: Tachie 设置 + archive-description: Tachie 模型使用包含情绪命名图片的 .tachie.zip 压缩包。 + scale-and-position: + title: 缩放与位置 + scale: 缩放 + x: X + 'y': 'Y' + theme-color-from-model: + title: 从模型提取颜色 + button-extract: + title: 提取 + rendering: + title: 渲染 + render-scale: 渲染缩放 + shadow: 主题色动态阴影 websocket-secure-enabled: title: 启用安全WebSocket (WSS) description: >- diff --git a/packages/i18n/src/locales/zh-Hant/settings.yaml b/packages/i18n/src/locales/zh-Hant/settings.yaml index 8b10ff4fa..2e36c952e 100644 --- a/packages/i18n/src/locales/zh-Hant/settings.yaml +++ b/packages/i18n/src/locales/zh-Hant/settings.yaml @@ -1738,6 +1738,22 @@ mmd: no-materials: This model exposes no materials. description: >- Adjust the opacity of individual model parts (face, hair, clothing, accessories). Set a part to 0 to hide it. +tachie: + title: Tachie 設定 + archive-description: Tachie 模型使用包含情緒命名圖片的 .tachie.zip 壓縮檔。 + scale-and-position: + title: 縮放與位置 + scale: 縮放 + x: X + 'y': 'Y' + theme-color-from-model: + title: 從模型提取顏色 + button-extract: + title: 提取 + rendering: + title: 渲染 + render-scale: 渲染縮放 + shadow: 主題色動態陰影 websocket-secure-enabled: title: 啟用安全 WebSocket (WSS) description: >- diff --git a/packages/stage-ui-tachie/README.md b/packages/stage-ui-tachie/README.md new file mode 100644 index 000000000..e5315df0b --- /dev/null +++ b/packages/stage-ui-tachie/README.md @@ -0,0 +1,58 @@ +# `@proj-airi/stage-ui-tachie` + +Tachie scene rendering for characters represented by a small set of complete, +static emotion images. + +## What it does + +- Loads `.tachie.zip` archives from local files, blob URLs, or HTTP(S) URLs. +- Selects images through AIRI's shared emotion vocabulary. +- Renders the active image with PixiJS on a transparent, responsive canvas. +- Persists scale, position, render scale, and theme-colored shadow settings. +- Exposes canvas capture for stage screenshots and theme-color extraction. + +## Archive format + +`neutral` is required. Every other image is optional, and missing emotions fall +back to `neutral`. File extensions are not part of the protocol; image content +only needs to be decodable by the browser. + +```text +character.tachie.zip +├── neutral.png +├── happy.webp +└── surprised.jpg +``` + +A single wrapping directory is also accepted: + +```text +character.tachie.zip +└── character/ + ├── neutral.png + └── happy.webp +``` + +Supported names are `neutral`, `happy`, `sad`, `angry`, `think`, `surprised`, +`awkward`, `question`, and `curious`. Matching is case-insensitive. Every +recognized image must use the same pixel dimensions. + +## How to use + +```vue + + + +``` + +## When to use it + +Use Tachie for low-asset characters whose visual state can be represented by +one complete image per emotion. + +Do not use it for layered sprites, rigged animation, lip-sync, or parameterized +eye and body tracking. Use Live2D, Spine, VRM, or MMD for those models. diff --git a/packages/stage-ui-tachie/package.json b/packages/stage-ui-tachie/package.json new file mode 100644 index 000000000..cd1156110 --- /dev/null +++ b/packages/stage-ui-tachie/package.json @@ -0,0 +1,51 @@ +{ + "name": "@proj-airi/stage-ui-tachie", + "type": "module", + "private": true, + "description": "Tachie static image scene components and stores for Project AIRI", + "author": { + "name": "Moeru AI Project AIRI Team", + "email": "airi@moeru.ai", + "url": "https://github.com/moeru-ai" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/moeru-ai/airi.git", + "directory": "packages/stage-ui-tachie" + }, + "exports": { + ".": "./src/index.ts", + "./components": "./src/components/index.ts", + "./components/scenes": "./src/components/scenes/index.ts", + "./components/scenes/Tachie.vue": "./src/components/scenes/tachie.vue", + "./components/scenes/tachie": "./src/components/scenes/tachie/index.ts", + "./constants/emotions": "./src/constants/emotions.ts", + "./stores": "./src/stores/index.ts", + "./stores/tachie": "./src/stores/tachie.ts", + "./utils/tachie-archive": "./src/utils/tachieArchive.ts", + "./utils/tachie-preview": "./src/utils/tachiePreview.ts" + }, + "scripts": { + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@moeru/std": "catalog:", + "@pixi/app": "catalog:", + "@pixi/core": "catalog:", + "@pixi/extensions": "catalog:", + "@pixi/sprite": "catalog:", + "@proj-airi/stage-shared": "workspace:^", + "@proj-airi/ui": "workspace:^", + "culori": "catalog:", + "jszip": "catalog:", + "pinia": "catalog:", + "pixi-filters": "catalog:", + "vue": "catalog:" + }, + "devDependencies": { + "@types/culori": "catalog:", + "vitest": "catalog:vitest", + "vue-tsc": "catalog:" + } +} diff --git a/packages/stage-ui-tachie/src/components/index.ts b/packages/stage-ui-tachie/src/components/index.ts new file mode 100644 index 000000000..ab6b53306 --- /dev/null +++ b/packages/stage-ui-tachie/src/components/index.ts @@ -0,0 +1 @@ +export * from './scenes' diff --git a/packages/stage-ui-tachie/src/components/scenes/index.ts b/packages/stage-ui-tachie/src/components/scenes/index.ts new file mode 100644 index 000000000..94f958394 --- /dev/null +++ b/packages/stage-ui-tachie/src/components/scenes/index.ts @@ -0,0 +1,2 @@ +export { TachieCanvas, TachieModel } from './tachie' +export { default as TachieScene } from './tachie.vue' diff --git a/packages/stage-ui-tachie/src/components/scenes/tachie.vue b/packages/stage-ui-tachie/src/components/scenes/tachie.vue new file mode 100644 index 000000000..d19b8b431 --- /dev/null +++ b/packages/stage-ui-tachie/src/components/scenes/tachie.vue @@ -0,0 +1,78 @@ + + + diff --git a/packages/stage-ui-tachie/src/components/scenes/tachie/canvas.vue b/packages/stage-ui-tachie/src/components/scenes/tachie/canvas.vue new file mode 100644 index 000000000..f743941d2 --- /dev/null +++ b/packages/stage-ui-tachie/src/components/scenes/tachie/canvas.vue @@ -0,0 +1,118 @@ + + + diff --git a/packages/stage-ui-tachie/src/components/scenes/tachie/index.ts b/packages/stage-ui-tachie/src/components/scenes/tachie/index.ts new file mode 100644 index 000000000..0be2ee58f --- /dev/null +++ b/packages/stage-ui-tachie/src/components/scenes/tachie/index.ts @@ -0,0 +1,2 @@ +export { default as TachieCanvas } from './canvas.vue' +export { default as TachieModel } from './model.vue' diff --git a/packages/stage-ui-tachie/src/components/scenes/tachie/model.vue b/packages/stage-ui-tachie/src/components/scenes/tachie/model.vue new file mode 100644 index 000000000..448815a53 --- /dev/null +++ b/packages/stage-ui-tachie/src/components/scenes/tachie/model.vue @@ -0,0 +1,296 @@ + + + diff --git a/packages/stage-ui-tachie/src/constants/emotions.ts b/packages/stage-ui-tachie/src/constants/emotions.ts new file mode 100644 index 000000000..d58cb182a --- /dev/null +++ b/packages/stage-ui-tachie/src/constants/emotions.ts @@ -0,0 +1,28 @@ +/** + * Emotion image names recognized inside a Tachie archive. + * + * The values intentionally match AIRI's stage emotion contract so the renderer + * can consume the same events as Live2D, VRM, Spine, and MMD. + */ +export const TACHIE_EMOTIONS = [ + 'happy', + 'sad', + 'angry', + 'think', + 'surprised', + 'awkward', + 'question', + 'curious', + 'neutral', +] as const + +/** Emotion names accepted by the Tachie archive and scene APIs. */ +export type TachieEmotion = typeof TACHIE_EMOTIONS[number] + +/** Required archive state and fallback for unavailable emotions. */ +export const DEFAULT_TACHIE_EMOTION: TachieEmotion = 'neutral' + +/** Returns whether an arbitrary stage emotion has a Tachie filename mapping. */ +export function isTachieEmotion(value: string): value is TachieEmotion { + return TACHIE_EMOTIONS.includes(value as TachieEmotion) +} diff --git a/packages/stage-ui-tachie/src/index.ts b/packages/stage-ui-tachie/src/index.ts new file mode 100644 index 000000000..99d49259c --- /dev/null +++ b/packages/stage-ui-tachie/src/index.ts @@ -0,0 +1,6 @@ +export { TachieCanvas, TachieModel } from './components/scenes/tachie' +export { default as TachieScene } from './components/scenes/tachie.vue' +export * from './constants/emotions' +export * from './stores' +export * from './utils/tachieArchive' +export * from './utils/tachiePreview' diff --git a/packages/stage-ui-tachie/src/stores/index.ts b/packages/stage-ui-tachie/src/stores/index.ts new file mode 100644 index 000000000..d88ba0094 --- /dev/null +++ b/packages/stage-ui-tachie/src/stores/index.ts @@ -0,0 +1 @@ +export * from './tachie' diff --git a/packages/stage-ui-tachie/src/stores/tachie.ts b/packages/stage-ui-tachie/src/stores/tachie.ts new file mode 100644 index 000000000..aaa78a7a1 --- /dev/null +++ b/packages/stage-ui-tachie/src/stores/tachie.ts @@ -0,0 +1,38 @@ +import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables' +import { defineStore } from 'pinia' + +/** Supported ranges and defaults for persisted Tachie appearance controls. */ +export const tachieControlConfig = { + x: { min: -3000, max: 3000, step: 1, default: 0 }, + y: { min: -3000, max: 3000, step: 1, default: 0 }, + scale: { min: 0.1, max: 3, step: 0.01, default: 1 }, + renderScale: { min: 0.5, max: 2, step: 0.25, default: 2 }, +} as const + +/** + * Persisted appearance and viewport calibration for the Tachie renderer. + */ +export const useTachie = defineStore('tachie', () => { + const position = useLocalStorageManualReset('settings/tachie/position', { + x: tachieControlConfig.x.default, + y: tachieControlConfig.y.default, + }) + const scale = useLocalStorageManualReset('settings/tachie/scale', tachieControlConfig.scale.default) + const renderScale = useLocalStorageManualReset('settings/tachie/render-scale', tachieControlConfig.renderScale.default) + const shadowEnabled = useLocalStorageManualReset('settings/tachie/shadow-enabled', true) + + function resetState() { + position.reset() + scale.reset() + renderScale.reset() + shadowEnabled.reset() + } + + return { + position, + scale, + renderScale, + shadowEnabled, + resetState, + } +}) diff --git a/packages/stage-ui-tachie/src/utils/tachieArchive.test.ts b/packages/stage-ui-tachie/src/utils/tachieArchive.test.ts new file mode 100644 index 000000000..557cdf5e1 --- /dev/null +++ b/packages/stage-ui-tachie/src/utils/tachieArchive.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' + +import { resolveTachieArchiveLayout } from './tachieArchive' + +describe('resolveTachieArchiveLayout', () => { + it('maps root images case-insensitively while ignoring metadata', () => { + const layout = resolveTachieArchiveLayout([ + 'Neutral.PNG', + 'HAPPY.webp', + '__MACOSX/._Neutral.PNG', + ]) + + expect(layout.errors).toEqual([]) + expect(layout.wrappingDirectory).toBeUndefined() + expect(layout.entries).toEqual([ + { emotion: 'happy', path: 'HAPPY.webp' }, + { emotion: 'neutral', path: 'Neutral.PNG' }, + ]) + expect(layout.ignoredEntries).toEqual([]) + }) + + it('accepts exactly one wrapping directory', () => { + const layout = resolveTachieArchiveLayout([ + 'Character/neutral.custom', + 'Character/surprised.avif', + 'Character/readme.txt', + 'license.txt', + ]) + + expect(layout.errors).toEqual([]) + expect(layout.wrappingDirectory).toBe('Character') + expect(layout.entries).toEqual([ + { emotion: 'surprised', path: 'Character/surprised.avif' }, + { emotion: 'neutral', path: 'Character/neutral.custom' }, + ]) + expect(layout.ignoredEntries).toEqual(['Character/readme.txt', 'license.txt']) + }) + + it('rejects duplicate normalized emotion names', () => { + const layout = resolveTachieArchiveLayout([ + 'neutral.png', + 'Neutral.webp', + ]) + + expect(layout.errors).toEqual([ + 'Emotion "neutral" is provided more than once: "neutral.png" and "Neutral.webp".', + ]) + }) + + it('rejects mixed root and wrapped emotion images', () => { + const layout = resolveTachieArchiveLayout([ + 'neutral.png', + 'Character/happy.png', + ]) + + expect(layout.errors).toContain('Emotion images cannot be mixed between the archive root and a wrapping directory.') + expect(layout.errors).toContain('Emotion image "Character/happy.png" is nested; only root-level files are allowed.') + }) + + it('requires a neutral image', () => { + const layout = resolveTachieArchiveLayout(['happy.png']) + + expect(layout.errors).toEqual(['Tachie archive must contain a neutral image.']) + }) + + it('rejects deeper arbitrary nesting', () => { + const layout = resolveTachieArchiveLayout([ + 'Character/images/neutral.png', + ]) + + expect(layout.errors).toContain('Emotion images must be stored at the archive root or inside one wrapping directory.') + }) +}) diff --git a/packages/stage-ui-tachie/src/utils/tachieArchive.ts b/packages/stage-ui-tachie/src/utils/tachieArchive.ts new file mode 100644 index 000000000..b381089ee --- /dev/null +++ b/packages/stage-ui-tachie/src/utils/tachieArchive.ts @@ -0,0 +1,507 @@ +import type JSZipType from 'jszip' + +import JSZip from 'jszip' + +import { errorMessageFrom } from '@moeru/std' + +import { DEFAULT_TACHIE_EMOTION, isTachieEmotion, TACHIE_EMOTIONS } from '../constants/emotions' + +/** Required compound extension for locally imported Tachie archives. */ +export const TACHIE_ARCHIVE_SUFFIX = '.tachie.zip' +/** Maximum compressed ZIP size accepted by the loader. */ +export const MAX_TACHIE_ARCHIVE_BYTES = 100 * 1024 * 1024 +/** Maximum combined RGBA texture memory accepted after image decoding. */ +export const MAX_TACHIE_IMAGE_BYTES = 300 * 1024 * 1024 + +/** One recognized emotion image in its original archive location. */ +export interface TachieArchiveEntry { + /** AIRI emotion selected by the filename stem. */ + emotion: typeof TACHIE_EMOTIONS[number] + /** Normalized path used to read the image from the ZIP. */ + path: string +} + +/** + * The normalized, deterministic file mapping derived from a Tachie archive. + */ +export interface TachieArchiveLayout { + /** Recognized images in deterministic emotion order. */ + entries: TachieArchiveEntry[] + /** Structural errors that make the archive unusable. */ + errors: string[] + /** Non-protocol files retained for the import audit report. */ + ignoredEntries: string[] + /** Single directory containing all emotion images, when present. */ + wrappingDirectory?: string +} + +/** Browser-decoded source accepted by Pixi and the preview canvas. */ +export type TachieImageSource = ImageBitmap | HTMLImageElement + +/** A decoded emotion image owned by a loaded Tachie asset set. */ +export interface TachieDecodedImage { + /** Emotion represented by this complete character image. */ + emotion: typeof TACHIE_EMOTIONS[number] + /** Source ZIP path for diagnostics. */ + path: string + /** Decoded browser image, valid until `dispose()` is called. */ + source: TachieImageSource + /** Decoded width in physical pixels. */ + width: number + /** Decoded height in physical pixels. */ + height: number + /** Releases the decoded browser resource. */ + dispose: () => void +} + +/** Fully decoded, dimension-checked images ready for an atomic model switch. */ +export interface TachieLoadedAssets { + /** Recognized images keyed by AIRI emotion. */ + images: Map + /** Shared width of every recognized image in physical pixels. */ + width: number + /** Shared height of every recognized image in physical pixels. */ + height: number + /** Non-protocol files found during archive inspection. */ + ignoredEntries: string[] + /** Non-blocking import audit messages. */ + warnings: string[] + /** Combined decoded RGBA texture footprint in bytes. */ + decodedImageBytes: number + /** Releases every decoded image owned by this asset set. */ + dispose: () => void +} + +/** Outcome shown before a local Tachie archive is imported. */ +export type TachieValidationStatus = 'VALID' | 'WARNING' | 'INVALID' + +/** Complete local import audit result without retained image resources. */ +export interface TachieValidationReport { + /** Whether import is direct, needs confirmation, or is blocked. */ + status: TachieValidationStatus + /** Original local filename. */ + fileName: string + /** Compressed archive size in bytes. */ + archiveBytes: number + /** Combined decoded RGBA texture footprint in bytes. */ + decodedImageBytes: number + /** Shared image dimensions when every recognized image decoded successfully. */ + canvas?: { + /** Shared decoded width in physical pixels. */ + width: number + /** Shared decoded height in physical pixels. */ + height: number + } + /** Recognized emotion images. */ + detected: TachieArchiveEntry[] + /** Non-protocol archive entries that will not be rendered. */ + ignoredEntries: string[] + /** Blocking archive or image errors. */ + errors: string[] + /** Non-blocking findings that require import confirmation. */ + warnings: string[] +} + +interface JSZipObjectWithCompressedData extends JSZipType.JSZipObject { + _data?: { + uncompressedSize?: number + } +} + +/** Optional policy overrides for loading Tachie archives. */ +export interface TachieArchiveLoadOptions { + /** Filename used for suffix validation and diagnostics. */ + fileName?: string + /** + * Whether `fileName` must end in `.tachie.zip`. + * + * @default false + */ + requireTachieSuffix?: boolean + /** + * Device texture dimension cap; omitted values are queried from WebGL. + * + * @default Current browser WebGL `MAX_TEXTURE_SIZE` + */ + maxTextureSize?: number +} + +interface TachieArchiveInspection { + assets?: TachieLoadedAssets + archiveBytes: number + detected: TachieArchiveEntry[] + errors: string[] + ignoredEntries: string[] + warnings: string[] +} + +function normalizedArchivePath(path: string): string { + return path.replaceAll('\\', '/').replace(/^\/+/, '').replace(/\/+$/, '') +} + +function archivePathSegments(path: string): string[] { + return normalizedArchivePath(path).split('/').filter(Boolean) +} + +function shouldIgnoreArchiveMetadata(path: string): boolean { + return archivePathSegments(path).some(segment => + segment === '__MACOSX' + || segment === '.DS_Store' + || segment.startsWith('._'), + ) +} + +function emotionFromFileName(fileName: string) { + const extensionSeparator = fileName.lastIndexOf('.') + const stem = (extensionSeparator > 0 ? fileName.slice(0, extensionSeparator) : fileName).toLowerCase() + return isTachieEmotion(stem) ? stem : undefined +} + +/** + * Resolves emotion images and the optional single wrapping directory. + * + * Before: + * - `["Character/Neutral.PNG", "Character/happy.webp"]` + * + * After: + * - `{ wrappingDirectory: "Character", entries: [{ emotion: "happy", ... }, { emotion: "neutral", ... }] }` + */ +export function resolveTachieArchiveLayout(paths: string[]): TachieArchiveLayout { + const errors: string[] = [] + const meaningfulPaths = paths + .map(normalizedArchivePath) + .filter(path => path && !shouldIgnoreArchiveMetadata(path)) + const candidates = meaningfulPaths + .map((path) => { + const segments = archivePathSegments(path) + const emotion = emotionFromFileName(segments.at(-1) ?? '') + return emotion ? { emotion, path, segments } : undefined + }) + .filter(candidate => candidate !== undefined) + + const rootCandidates = candidates.filter(candidate => candidate.segments.length === 1) + const wrappedCandidates = candidates.filter(candidate => candidate.segments.length === 2) + const deeplyNestedCandidates = candidates.filter(candidate => candidate.segments.length > 2) + + if (deeplyNestedCandidates.length > 0) + errors.push('Emotion images must be stored at the archive root or inside one wrapping directory.') + + if (rootCandidates.length > 0 && wrappedCandidates.length > 0) + errors.push('Emotion images cannot be mixed between the archive root and a wrapping directory.') + + const wrappingDirectories = new Set(wrappedCandidates.map(candidate => candidate.segments[0])) + if (wrappingDirectories.size > 1) + errors.push('All emotion images must use the same wrapping directory.') + + const wrappingDirectory = rootCandidates.length === 0 && wrappingDirectories.size === 1 + ? [...wrappingDirectories][0] + : undefined + + for (const candidate of candidates) { + const segments = candidate.segments + const matchesLayout = wrappingDirectory + ? segments.length === 2 && segments[0] === wrappingDirectory + : segments.length === 1 + if (!matchesLayout) { + errors.push(wrappingDirectory + ? `Emotion image "${candidate.path}" is outside the "${wrappingDirectory}" wrapping directory.` + : `Emotion image "${candidate.path}" is nested; only root-level files are allowed.`) + } + } + + const entryByEmotion = new Map() + for (const candidate of candidates) { + const existing = entryByEmotion.get(candidate.emotion) + if (existing) { + errors.push(`Emotion "${candidate.emotion}" is provided more than once: "${existing.path}" and "${candidate.path}".`) + continue + } + entryByEmotion.set(candidate.emotion, { emotion: candidate.emotion, path: candidate.path }) + } + + if (!entryByEmotion.has(DEFAULT_TACHIE_EMOTION)) + errors.push('Tachie archive must contain a neutral image.') + + const entries = TACHIE_EMOTIONS + .map(emotion => entryByEmotion.get(emotion)) + .filter(entry => entry !== undefined) + const recognizedPaths = new Set(entries.map(entry => entry.path)) + const ignoredEntries = meaningfulPaths.filter(path => !recognizedPaths.has(path)) + + return { + entries, + errors: [...new Set(errors)], + ignoredEntries, + wrappingDirectory, + } +} + +function imageDimensions(source: TachieImageSource) { + return source instanceof HTMLImageElement + ? { width: source.naturalWidth, height: source.naturalHeight } + : { width: source.width, height: source.height } +} + +async function decodeImage(blob: Blob): Promise<{ source: TachieImageSource, dispose: () => void }> { + if (typeof createImageBitmap === 'function') { + const source = await createImageBitmap(blob) + return { + source, + dispose: () => source.close(), + } + } + + const objectUrl = URL.createObjectURL(blob) + const source = new Image() + try { + source.src = objectUrl + await source.decode() + } + finally { + URL.revokeObjectURL(objectUrl) + } + + return { + source, + dispose: () => { + source.src = '' + }, + } +} + +function maxTextureSizeFromBrowser(): number { + if (typeof document === 'undefined') + return Number.MAX_SAFE_INTEGER + + const canvas = document.createElement('canvas') + const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl') + if (!gl) + return 0 + + return gl.getParameter(gl.MAX_TEXTURE_SIZE) as number +} + +function disposeDecodedImages(images: Iterable) { + for (const image of images) + image.dispose() +} + +async function inspectTachieArchive( + input: Blob | ArrayBuffer, + options: TachieArchiveLoadOptions = {}, +): Promise { + const errors: string[] = [] + const warnings: string[] = [] + const archiveBytes = input instanceof Blob ? input.size : input.byteLength + + if (options.requireTachieSuffix && options.fileName && !options.fileName.toLowerCase().endsWith(TACHIE_ARCHIVE_SUFFIX)) + errors.push(`Tachie archives must use the ${TACHIE_ARCHIVE_SUFFIX} file extension.`) + if (archiveBytes > MAX_TACHIE_ARCHIVE_BYTES) + errors.push('Tachie archive exceeds the 100 MiB compressed size limit.') + if (errors.length > 0) + return { archiveBytes, detected: [], errors, ignoredEntries: [], warnings } + + let zip: JSZipType + try { + zip = await JSZip.loadAsync(input) + } + catch (error) { + return { + archiveBytes, + detected: [], + errors: [`Failed to read Tachie archive: ${errorMessageFrom(error) ?? 'invalid ZIP data'}`], + ignoredEntries: [], + warnings, + } + } + + const archiveFiles = Object.values(zip.files).filter(file => !file.dir) + const layout = resolveTachieArchiveLayout(archiveFiles.map(file => file.name)) + errors.push(...layout.errors) + if (layout.ignoredEntries.length > 0) + warnings.push(`Ignored ${layout.ignoredEntries.length} unrecognized archive entr${layout.ignoredEntries.length === 1 ? 'y' : 'ies'}.`) + if (errors.length > 0) { + return { + archiveBytes, + detected: layout.entries, + errors, + ignoredEntries: layout.ignoredEntries, + warnings, + } + } + + let declaredImageBytes = 0 + for (const entry of layout.entries) { + const file = zip.file(entry.path) + if (!file) + continue + + // NOTICE: + // Preflight the declared uncompressed size before JSZip allocates a complete entry. + // JSZip keeps central-directory sizes on the private `_data` object but does not expose + // them in its public types. Source/context: `jszip/lib/compressedObject.js` and + // `jszip/index.d.ts` (the commented `CompressedObject` declaration). + // Removal condition: JSZip exposes uncompressed entry sizes through its public API. + const declaredSize = (file as JSZipObjectWithCompressedData)._data?.uncompressedSize + if (typeof declaredSize === 'number') + declaredImageBytes += declaredSize + } + if (declaredImageBytes > MAX_TACHIE_IMAGE_BYTES) { + errors.push('Tachie images exceed the 300 MiB uncompressed size limit.') + return { + archiveBytes, + detected: layout.entries, + errors, + ignoredEntries: layout.ignoredEntries, + warnings, + } + } + + const maxTextureSize = options.maxTextureSize ?? maxTextureSizeFromBrowser() + if (maxTextureSize <= 0) { + errors.push('WebGL is unavailable, so Tachie textures cannot be rendered.') + return { + archiveBytes, + detected: layout.entries, + errors, + ignoredEntries: layout.ignoredEntries, + warnings, + } + } + + const images = new Map() + let width = 0 + let height = 0 + let decodedImageBytes = 0 + + for (const entry of layout.entries) { + const file = zip.file(entry.path) + if (!file) { + errors.push(`Missing archive entry "${entry.path}".`) + continue + } + + try { + const bytes = await file.async('uint8array') + const blob = new Blob([Uint8Array.from(bytes).buffer]) + const decoded = await decodeImage(blob) + const dimensions = imageDimensions(decoded.source) + if (dimensions.width <= 0 || dimensions.height <= 0) { + decoded.dispose() + errors.push(`Image "${entry.path}" has invalid dimensions.`) + continue + } + if (dimensions.width > maxTextureSize || dimensions.height > maxTextureSize) { + decoded.dispose() + errors.push(`Image "${entry.path}" is ${dimensions.width}×${dimensions.height}, exceeding this device's ${maxTextureSize}px texture limit.`) + continue + } + + // Browser textures use four 8-bit channels regardless of the source + // image's compression or alpha usage. Count this decoded footprint so + // high-resolution PNG/WebP inputs cannot bypass the memory budget. + decodedImageBytes += dimensions.width * dimensions.height * 4 + if (decodedImageBytes > MAX_TACHIE_IMAGE_BYTES) { + decoded.dispose() + errors.push('Tachie images exceed the 300 MiB decoded texture memory limit.') + break + } + + if (width === 0 && height === 0) { + width = dimensions.width + height = dimensions.height + } + else if (dimensions.width !== width || dimensions.height !== height) { + decoded.dispose() + errors.push(`Image "${entry.path}" is ${dimensions.width}×${dimensions.height}; every Tachie image must match ${width}×${height}.`) + continue + } + + images.set(entry.emotion, { + ...entry, + ...decoded, + ...dimensions, + }) + } + catch (error) { + errors.push(`Failed to decode image "${entry.path}": ${errorMessageFrom(error) ?? 'unsupported image data'}.`) + } + } + + if (!images.has(DEFAULT_TACHIE_EMOTION)) + errors.push('The neutral image could not be decoded.') + + if (errors.length > 0) { + disposeDecodedImages(images.values()) + return { + archiveBytes, + detected: layout.entries, + errors: [...new Set(errors)], + ignoredEntries: layout.ignoredEntries, + warnings, + } + } + + const assets: TachieLoadedAssets = { + images, + width, + height, + ignoredEntries: layout.ignoredEntries, + warnings, + decodedImageBytes, + dispose: () => disposeDecodedImages(images.values()), + } + + return { + assets, + archiveBytes, + detected: layout.entries, + errors, + ignoredEntries: layout.ignoredEntries, + warnings, + } +} + +/** + * Loads and fully decodes every recognized emotion image in a Tachie ZIP. + * + * The returned image sources remain owned by the caller until `dispose()` is + * called. A failed archive never returns a partially usable model. + */ +export async function loadTachieZip( + input: Blob | ArrayBuffer, + options: TachieArchiveLoadOptions = {}, +): Promise { + const inspection = await inspectTachieArchive(input, options) + if (!inspection.assets) + throw new Error(inspection.errors.join(' ')) + return inspection.assets +} + +/** + * Validates a local `.tachie.zip` without retaining decoded image resources. + */ +export async function validateTachieZip(file: File): Promise { + const inspection = await inspectTachieArchive(file, { + fileName: file.name, + requireTachieSuffix: true, + }) + const assets = inspection.assets + const report: TachieValidationReport = { + status: inspection.errors.length > 0 + ? 'INVALID' + : inspection.warnings.length > 0 + ? 'WARNING' + : 'VALID', + fileName: file.name, + archiveBytes: inspection.archiveBytes, + decodedImageBytes: assets?.decodedImageBytes ?? 0, + canvas: assets ? { width: assets.width, height: assets.height } : undefined, + detected: inspection.detected, + ignoredEntries: inspection.ignoredEntries, + errors: inspection.errors, + warnings: inspection.warnings, + } + assets?.dispose() + return report +} diff --git a/packages/stage-ui-tachie/src/utils/tachiePreview.ts b/packages/stage-ui-tachie/src/utils/tachiePreview.ts new file mode 100644 index 000000000..25975f9ba --- /dev/null +++ b/packages/stage-ui-tachie/src/utils/tachiePreview.ts @@ -0,0 +1,34 @@ +import { DEFAULT_TACHIE_EMOTION } from '../constants/emotions' +import { loadTachieZip } from './tachieArchive' + +/** + * Creates a compact, aspect-preserving preview from the required neutral image. + */ +export async function loadTachieModelPreview(file: File): Promise { + const assets = await loadTachieZip(file, { + fileName: file.name, + requireTachieSuffix: true, + }) + + try { + const neutral = assets.images.get(DEFAULT_TACHIE_EMOTION) + if (!neutral) + return undefined + + const maxPreviewDimension = 512 + const scale = Math.min(1, maxPreviewDimension / Math.max(neutral.width, neutral.height)) + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, Math.round(neutral.width * scale)) + canvas.height = Math.max(1, Math.round(neutral.height * scale)) + + const context = canvas.getContext('2d') + if (!context) + return undefined + + context.drawImage(neutral.source, 0, 0, canvas.width, canvas.height) + return canvas.toDataURL('image/png') + } + finally { + assets.dispose() + } +} diff --git a/packages/stage-ui-tachie/tsconfig.json b/packages/stage-ui-tachie/tsconfig.json new file mode 100644 index 000000000..2365daacf --- /dev/null +++ b/packages/stage-ui-tachie/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ESNext", + "jsx": "preserve", + "lib": [ + "DOM", + "DOM.AsyncIterable", + "ESNext" + ], + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "types": [ + "vite/client" + ], + "allowJs": true, + "strict": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noEmit": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.vue" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/packages/stage-ui-tachie/vitest.config.ts b/packages/stage-ui-tachie/vitest.config.ts new file mode 100644 index 000000000..ceafc2412 --- /dev/null +++ b/packages/stage-ui-tachie/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}) diff --git a/packages/stage-ui/package.json b/packages/stage-ui/package.json index b7531d232..6003117b1 100644 --- a/packages/stage-ui/package.json +++ b/packages/stage-ui/package.json @@ -90,6 +90,7 @@ "@proj-airi/stage-ui-live2d": "workspace:^", "@proj-airi/stage-ui-mmd": "workspace:^", "@proj-airi/stage-ui-spine": "workspace:^", + "@proj-airi/stage-ui-tachie": "workspace:^", "@proj-airi/stage-ui-three": "workspace:^", "@proj-airi/ui": "workspace:^", "@ricky0123/vad-web": "catalog:", diff --git a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue index d4b3daf34..8f83a9987 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue @@ -1,9 +1,11 @@ @@ -186,6 +225,11 @@ mmdDialog.onChange(handleAddMMDModel) @confirm="confirmImport" @fix-error="handleFixError" /> +
@@ -257,6 +301,18 @@ mmdDialog.onChange(handleAddMMDModel) > MMD + + Tachie + diff --git a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/tachieReportModal.vue b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/tachieReportModal.vue new file mode 100644 index 000000000..f0f3c1c48 --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/tachieReportModal.vue @@ -0,0 +1,210 @@ + + + diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/index.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/index.vue index 34009f213..83787c549 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/index.vue +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/index.vue @@ -15,6 +15,7 @@ withDefaults(defineProps<{ live2dSceneClass?: string | string[] vrmSceneClass?: string | string[] spineSceneClass?: string | string[] + tachieSceneClass?: string | string[] mmdSceneClass?: string | string[] }>(), { allowExtractColors: true, @@ -53,6 +54,7 @@ defineExpose({ :live2d-scene-class="live2dSceneClass" :vrm-scene-class="vrmSceneClass" :spine-scene-class="spineSceneClass" + :tachie-scene-class="tachieSceneClass" :mmd-scene-class="mmdSceneClass" @runtime-snapshot-changed="handleRuntimeSnapshotChanged" /> diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue index 65f72b957..72c744671 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue @@ -17,6 +17,7 @@ import Godot from './godot.vue' import Live2D from './live2d.vue' import MMD from './mmd.vue' import Spine from './spine.vue' +import Tachie from './tachie.vue' import VRM from './vrm.vue' import { useAiriCardStore } from '../../../../stores/modules/airi-card' @@ -91,6 +92,9 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {

{{ t('settings.model-select.panel-callout.model-type-example') }}

+

+ {{ t('settings.tachie.archive-description') }} +

@@ -128,6 +132,13 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) { :runtime-snapshot="runtimeSnapshot" @extract-colors-from-model="emit('extractColorsFromModel')" /> + () @@ -31,9 +33,11 @@ const modelStore = useModelStore() const live2dSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>() const vrmSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>() const spineSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>() +const tachieSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>() const mmdSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>() const live2dComponentState = ref<'pending' | 'loading' | 'mounted'>('pending') const spineComponentState = ref<'pending' | 'loading' | 'mounted'>('pending') +const tachieComponentState = ref<'pending' | 'loading' | 'mounted'>('pending') const mmdComponentState = ref<'pending' | 'loading' | 'mounted'>('pending') const vrmPreviewStageInstanceId = `model-settings-preview-stage:${Math.random().toString(36).slice(2, 10)}` @@ -57,6 +61,7 @@ const { sceneMutationLocked, scenePhase } = storeToRefs(modelStore) const live2dSceneClassList = computed(() => normalizeClassList(props.live2dSceneClass)) const vrmSceneClassList = computed(() => normalizeClassList(props.vrmSceneClass)) const spineSceneClassList = computed(() => normalizeClassList(props.spineSceneClass)) +const tachieSceneClassList = computed(() => normalizeClassList(props.tachieSceneClass)) const mmdSceneClassList = computed(() => normalizeClassList(props.mmdSceneClass)) function normalizeClassList(value?: string | string[]) { @@ -85,6 +90,9 @@ async function capturePreviewFrame() { if (stageModelRenderer.value === 'spine') return captureCanvasFrame(spineSceneRef.value?.canvasElement()) + if (stageModelRenderer.value === 'tachie') + return captureCanvasFrame(tachieSceneRef.value?.canvasElement()) + if (stageModelRenderer.value === 'mmd') return captureCanvasFrame(mmdSceneRef.value?.canvasElement()) @@ -134,6 +142,20 @@ const runtimeSnapshot = computed(() => { }) } + if (stageModelRenderer.value === 'tachie') { + const phase = resolveComponentStateToRuntimePhase(tachieComponentState.value, { hasModel }) + + return createEmptyModelSettingsRuntimeSnapshot({ + ownerInstanceId: vrmPreviewStageInstanceId, + renderer: 'tachie', + phase, + controlsLocked: hasModel ? phase !== 'mounted' : false, + previewAvailable: hasModel, + canCapturePreview: !!tachieSceneRef.value?.canvasElement(), + updatedAt: Date.now(), + }) + } + if (stageModelRenderer.value === 'mmd') { const phase = resolveComponentStateToRuntimePhase(mmdComponentState.value, { hasModel }) @@ -225,4 +247,16 @@ const cursorPosition = computed(() => ({ />
+ diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts b/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts index 441cfb548..8347ede03 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts @@ -2,7 +2,7 @@ import type { StageAvatarBoundsPayload, StageViewState } from '@proj-airi/stage- import type { StageModelRenderer } from '../../../../stores/settings/stage-model' -export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm' | 'spine' | 'mmd' | 'godot' +export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm' | 'spine' | 'tachie' | 'mmd' | 'godot' export type ModelSettingsRuntimePhase = 'pending' | 'loading' | 'binding' | 'mounted' | 'no-model' | 'error' export interface ModelSettingsRuntimeSnapshot { diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/tachie.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/tachie.vue new file mode 100644 index 000000000..71c35626d --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/tachie.vue @@ -0,0 +1,116 @@ + + + diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index 2e34a02de..764bc8ca7 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -16,6 +16,7 @@ import { createPlaybackManager, createSpeechPipeline, normalizeActPayload } from import { Live2DScene, useLive2dParams } from '@proj-airi/stage-ui-live2d' import { MMDScene } from '@proj-airi/stage-ui-mmd' import { SpineScene } from '@proj-airi/stage-ui-spine' +import { TachieScene } from '@proj-airi/stage-ui-tachie' import { ThreeScene } from '@proj-airi/stage-ui-three' import { animations } from '@proj-airi/stage-ui-three/assets/vrm' import { createQueue } from '@proj-airi/stream-kit' @@ -68,6 +69,7 @@ const { getDb } = useDuckDb() const vrmViewerRef = ref>() const live2dSceneRef = ref>() const spineSceneRef = ref>() +const tachieSceneRef = ref>() const mmdSceneRef = ref>() const settingsStore = useSettings() @@ -208,6 +210,9 @@ const emotionsQueue = createQueue({ else if (stageModelRenderer.value === 'spine') { spineSceneRef.value?.setEmotion(ctx.data.name, ctx.data.intensity) } + else if (stageModelRenderer.value === 'tachie') { + tachieSceneRef.value?.setEmotion(ctx.data.name, ctx.data.intensity) + } else if (stageModelRenderer.value === 'mmd') { mmdSceneRef.value?.setEmotion(ctx.data.name, ctx.data.intensity) } @@ -938,6 +943,9 @@ function canvasElement() { else if (stageModelRenderer.value === 'spine') return spineSceneRef.value?.canvasElement() + else if (stageModelRenderer.value === 'tachie') + return tachieSceneRef.value?.canvasElement() + else if (stageModelRenderer.value === 'mmd') return mmdSceneRef.value?.canvasElement() } @@ -949,14 +957,21 @@ function readRenderTargetRegionAtClientPoint(clientX: number, clientY: number, r return vrmViewerRef.value?.readRenderTargetRegionAtClientPoint?.(clientX, clientY, radius) ?? null } +async function captureCharacterFrame() { + if (stageModelRenderer.value === 'live2d') + return live2dSceneRef.value?.captureFrame() + if (stageModelRenderer.value === 'vrm') + return vrmViewerRef.value?.captureFrame() + if (stageModelRenderer.value === 'spine') + return spineSceneRef.value?.captureFrame() + if (stageModelRenderer.value === 'tachie') + return tachieSceneRef.value?.captureFrame() + if (stageModelRenderer.value === 'mmd') + return mmdSceneRef.value?.captureFrame() +} + async function captureFrame() { - const charBlob = await (stageModelRenderer.value === 'live2d' - ? live2dSceneRef.value?.captureFrame() - : stageModelRenderer.value === 'vrm' - ? vrmViewerRef.value?.captureFrame() - : stageModelRenderer.value === 'mmd' - ? mmdSceneRef.value?.captureFrame() - : spineSceneRef.value?.captureFrame()) + const charBlob = await captureCharacterFrame() if (!activeBackgroundUrl.value || !charBlob) return charBlob @@ -1091,6 +1106,19 @@ defineExpose({ :max-fps="spineMaxFps" :render-scale="spineRenderScale" /> + { for (const [file, expected] of cases) await expect(importAiriCardPackage({ file, displayModelsStore })).rejects.toMatchObject(expected) }) + + it('preserves Tachie archives and their compound extension', async () => { + const displayModelsStore = useDisplayModelsStore() + vi.spyOn(displayModelsStore, 'getDisplayModel').mockResolvedValue({ + id: 'tachie-model', + format: DisplayModelFormat.TachieZip, + type: 'file', + file: new File(['tachie-model'], 'character.tachie.zip'), + name: 'character.tachie.zip', + importedAt: 1, + }) + mockAddDisplayModel(displayModelsStore, 'imported-tachie') + + const exported = await exportAiriCardPackage({ + card: createCard('tachie-model'), + displayModelsStore, + }) + const zip = await JSZip.loadAsync(await exported.arrayBuffer()) + const imported = await importAiriCardPackage({ + file: new File([exported], 'card.zip'), + displayModelsStore, + }) + + expect(await readJson(zip, 'manifest.json')).toMatchObject({ + resources: { + displayModel: { + path: 'models/body-model.tachie.zip', + format: DisplayModelFormat.TachieZip, + name: 'character.tachie.zip', + }, + }, + }) + expect(await zip.file('models/body-model.tachie.zip')?.async('string')).toBe('tachie-model') + expect(displayModelsStore.addDisplayModel).toHaveBeenCalledWith( + DisplayModelFormat.TachieZip, + expect.objectContaining({ name: 'character.tachie.zip' }), + ) + expect(airiFrom(imported).modules.displayModelId).toBe('imported-tachie') + }) }) function mockAddDisplayModel(store: ReturnType, id = 'unused') { diff --git a/packages/stage-ui/src/services/airi-card-import-export.ts b/packages/stage-ui/src/services/airi-card-import-export.ts index 237281734..102a95bb4 100644 --- a/packages/stage-ui/src/services/airi-card-import-export.ts +++ b/packages/stage-ui/src/services/airi-card-import-export.ts @@ -18,6 +18,7 @@ const MANIFEST_PATH = 'manifest.json' const MODEL_EXT: Partial> = { [DisplayModelFormat.Live2dZip]: 'zip', [DisplayModelFormat.SpineZip]: 'zip', + [DisplayModelFormat.TachieZip]: 'tachie.zip', [DisplayModelFormat.VRM]: 'vrm', } @@ -31,7 +32,7 @@ const manifestSchema = object({ resources: optional(object({ displayModel: object({ path: string(), - format: picklist([DisplayModelFormat.Live2dZip, DisplayModelFormat.SpineZip, DisplayModelFormat.VRM]), + format: picklist([DisplayModelFormat.Live2dZip, DisplayModelFormat.SpineZip, DisplayModelFormat.TachieZip, DisplayModelFormat.VRM]), name: string(), }), })), diff --git a/packages/stage-ui/src/stores/display-models.ts b/packages/stage-ui/src/stores/display-models.ts index fa012d92d..59d7f1407 100644 --- a/packages/stage-ui/src/stores/display-models.ts +++ b/packages/stage-ui/src/stores/display-models.ts @@ -10,6 +10,7 @@ export enum DisplayModelFormat { Live2dDirectory = 'live2d-directory', VRM = 'vrm', SpineZip = 'spine-zip', + TachieZip = 'tachie-zip', PMXZip = 'pmx-zip', PMXDirectory = 'pmx-directory', PMD = 'pmd', @@ -60,6 +61,7 @@ export const useDisplayModelsStore = defineStore('display-models', () => { let generateLive2DPreview: (file: File) => Promise let generateVrmPreview: (file: File) => Promise let generateSpinePreview: (file: File) => Promise + let generateTachiePreview: (file: File) => Promise let generateMMDPreview: (file: File) => Promise const displayModelsFromIndexedDBLoading = ref(false) @@ -109,6 +111,7 @@ export const useDisplayModelsStore = defineStore('display-models', () => { const loadLive2DModelPreview = (file: File) => generateLive2DPreview(file) const loadVrmModelPreview = (file: File) => generateVrmPreview(file) const loadSpineModelPreview = (file: File) => generateSpinePreview(file) + const loadTachieModelPreview = (file: File) => generateTachiePreview(file) const loadMMDModelPreview = (file: File) => generateMMDPreview(file) async function addDisplayModel(format: DisplayModelFormat, file: File) { @@ -127,6 +130,10 @@ export const useDisplayModelsStore = defineStore('display-models', () => { const previewImage = await loadSpineModelPreview(file) newDisplayModel.previewImage = previewImage } + else if (format === DisplayModelFormat.TachieZip) { + const previewImage = await loadTachieModelPreview(file) + newDisplayModel.previewImage = previewImage + } else if (format === DisplayModelFormat.PMXZip || format === DisplayModelFormat.PMXDirectory || format === DisplayModelFormat.PMD) { // NOTICE: // Preview generation is best-effort and must not block the import. @@ -205,10 +212,12 @@ export const useDisplayModelsStore = defineStore('display-models', () => { const { loadLive2DModelPreview } = await import('@proj-airi/stage-ui-live2d/utils/live2d-preview') const { loadVrmModelPreview } = await import('@proj-airi/stage-ui-three/utils/vrm-preview') const { loadSpineModelPreview } = await import('@proj-airi/stage-ui-spine/utils/spine-preview') + const { loadTachieModelPreview } = await import('@proj-airi/stage-ui-tachie/utils/tachie-preview') generateLive2DPreview = loadLive2DModelPreview generateVrmPreview = loadVrmModelPreview generateSpinePreview = loadSpineModelPreview + generateTachiePreview = loadTachieModelPreview // NOTICE: // Isolate the MMD preview import. It pulls in three-stdlib's MMD modules, diff --git a/packages/stage-ui/src/stores/settings/index.ts b/packages/stage-ui/src/stores/settings/index.ts index da2edc5ba..a539698f5 100644 --- a/packages/stage-ui/src/stores/settings/index.ts +++ b/packages/stage-ui/src/stores/settings/index.ts @@ -1,3 +1,4 @@ +import { useTachie } from '@proj-airi/stage-ui-tachie' import { defineStore, storeToRefs } from 'pinia' import { useSettingsAnalytics } from './analytics' @@ -34,6 +35,7 @@ export const useSettings = defineStore('settings', () => { const stageModel = useSettingsStageModel() const spine = useSettingsSpine() const theme = useSettingsTheme() + const tachie = useTachie() const controlsIsland = useSettingsControlsIsland() const developer = useSettingsDeveloper() @@ -42,6 +44,7 @@ export const useSettings = defineStore('settings', () => { analytics.resetState() general.resetState() spine.resetState() + tachie.resetState() theme.resetState() controlsIsland.resetState() developer.resetState() diff --git a/packages/stage-ui/src/stores/settings/stage-model.test.ts b/packages/stage-ui/src/stores/settings/stage-model.test.ts index f35d3be36..966690c60 100644 --- a/packages/stage-ui/src/stores/settings/stage-model.test.ts +++ b/packages/stage-ui/src/stores/settings/stage-model.test.ts @@ -60,4 +60,26 @@ describe('settings stage model store', () => { expect(getDisplayModelSpy).toHaveBeenCalledWith('display-model-missing') expect(getDisplayModelSpy).toHaveBeenCalledWith(fallbackModel.id) }) + + it('routes Tachie archives to the Tachie renderer', async () => { + const tachieModel: DisplayModelURL = { + id: 'tachie-model', + format: DisplayModelFormat.TachieZip, + type: 'url', + url: 'https://example.com/character.tachie.zip', + name: 'Tachie character', + importedAt: 1, + } + const displayModelsStore = useDisplayModelsStore() + vi.spyOn(displayModelsStore, 'getDisplayModel').mockResolvedValue(tachieModel) + + const store = useSettingsStageModel() + store.stageModelSelected = tachieModel.id + + await store.initializeStageModel() + + expect(store.stageModelSelectedDisplayModel).toEqual(tachieModel) + expect(store.stageModelSelectedUrl).toBe(tachieModel.url) + expect(store.stageModelRenderer).toBe('tachie') + }) }) diff --git a/packages/stage-ui/src/stores/settings/stage-model.ts b/packages/stage-ui/src/stores/settings/stage-model.ts index a0043b61f..8e1f4691f 100644 --- a/packages/stage-ui/src/stores/settings/stage-model.ts +++ b/packages/stage-ui/src/stores/settings/stage-model.ts @@ -7,7 +7,7 @@ import { computed, watch } from 'vue' import { DisplayModelFormat, useDisplayModelsStore } from '../display-models' -export type StageModelRenderer = 'live2d' | 'vrm' | 'spine' | 'mmd' | 'godot' | 'disabled' | undefined +export type StageModelRenderer = 'live2d' | 'vrm' | 'spine' | 'tachie' | 'mmd' | 'godot' | 'disabled' | undefined type BuiltInStageModelRenderer = Exclude export const useSettingsStageModel = defineStore('settings-stage-model', () => { @@ -55,6 +55,8 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => { return 'vrm' case DisplayModelFormat.SpineZip: return 'spine' + case DisplayModelFormat.TachieZip: + return 'tachie' case DisplayModelFormat.PMXZip: case DisplayModelFormat.PMXDirectory: case DisplayModelFormat.PMD: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ccbabbfb..f705ade06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4097,6 +4097,9 @@ importers: '@proj-airi/stage-ui-spine': specifier: workspace:^ version: link:../stage-ui-spine + '@proj-airi/stage-ui-tachie': + specifier: workspace:^ + version: link:../stage-ui-tachie '@proj-airi/stage-ui-three': specifier: workspace:^ version: link:../stage-ui-three @@ -4654,6 +4657,55 @@ importers: specifier: 'catalog:' version: 3.2.6(typescript@5.9.3) + packages/stage-ui-tachie: + dependencies: + '@moeru/std': + specifier: 'catalog:' + version: 0.1.0-beta.17 + '@pixi/app': + specifier: 'catalog:' + version: 6.5.10(@pixi/core@6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/display@6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/math@6.5.10)(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))) + '@pixi/core': + specifier: 'catalog:' + version: 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))) + '@pixi/extensions': + specifier: 'catalog:' + version: 6.5.10 + '@pixi/sprite': + specifier: 'catalog:' + version: 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/display@6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))) + '@proj-airi/stage-shared': + specifier: workspace:^ + version: link:../stage-shared + '@proj-airi/ui': + specifier: workspace:^ + version: link:../ui + culori: + specifier: 'catalog:' + version: 4.0.2 + jszip: + specifier: 'catalog:' + version: 3.10.1 + pinia: + specifier: 'catalog:' + version: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)) + pixi-filters: + specifier: 'catalog:' + version: 4.2.0(5af797a0b5bd919a06578c24a7479046) + vue: + specifier: 'catalog:' + version: 3.5.32(typescript@5.9.3) + devDependencies: + '@types/culori': + specifier: 'catalog:' + version: 4.0.1 + vitest: + specifier: catalog:vitest + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vue-tsc: + specifier: 'catalog:' + version: 3.2.6(typescript@5.9.3) + packages/stage-ui-three: dependencies: '@moeru/eventa':