feat(stage-ui-tachie): add tachie support (#2115)

This commit is contained in:
藍+85CD
2026-07-28 23:35:30 +08:00
committed by GitHub
parent acbbab0bd7
commit 880d1bf7a9
45 changed files with 2095 additions and 12 deletions
@@ -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<ModelSettingsRuntimeSnapshot>(() =
})
}
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 })
+1
View File
@@ -305,6 +305,7 @@ words:
- supergroup
- superjson
- systempreferences
- tachie
- tamagotchi
- tauri
- taze
@@ -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: >-
@@ -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: >-
@@ -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: >-
@@ -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: >-
@@ -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: >-
@@ -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: >-
@@ -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: >-
@@ -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: >-
@@ -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: >-
+58
View File
@@ -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
<script setup lang="ts">
import { TachieScene } from '@proj-airi/stage-ui-tachie'
</script>
<template>
<TachieScene :model-src="modelUrl" />
</template>
```
## 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.
+51
View File
@@ -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:"
}
}
@@ -0,0 +1 @@
export * from './scenes'
@@ -0,0 +1,2 @@
export { TachieCanvas, TachieModel } from './tachie'
export { default as TachieScene } from './tachie.vue'
@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { TachieEmotion } from '../../constants/emotions'
import { Screen } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, useTemplateRef, watch } from 'vue'
import TachieCanvas from './tachie/canvas.vue'
import TachieModel from './tachie/model.vue'
import { useTachie } from '../../stores/tachie'
const props = defineProps<{
modelSrc?: string
modelId?: string
paused?: boolean
renderScale?: number
shadowEnabled?: boolean
themeColorsHue?: number
themeColorsHueDynamic?: boolean
}>()
const emit = defineEmits<{
error: [value: unknown]
}>()
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
const componentStateCanvas = defineModel<'pending' | 'loading' | 'mounted'>('canvasState', { default: 'pending' })
const componentStateModel = defineModel<'pending' | 'loading' | 'mounted'>('modelState', { default: 'pending' })
const canvasRef = useTemplateRef<InstanceType<typeof TachieCanvas>>('canvas')
const modelRef = useTemplateRef<InstanceType<typeof TachieModel>>('model')
const tachieStore = useTachie()
const { renderScale: storedRenderScale, shadowEnabled: storedShadowEnabled } = storeToRefs(tachieStore)
const effectiveRenderScale = computed(() => props.renderScale ?? storedRenderScale.value)
const effectiveShadowEnabled = computed(() => props.shadowEnabled ?? storedShadowEnabled.value)
watch([componentStateModel, componentStateCanvas], () => {
componentState.value = componentStateModel.value === 'mounted' && componentStateCanvas.value === 'mounted'
? 'mounted'
: 'loading'
})
defineExpose({
canvasElement: () => canvasRef.value?.canvasElement(),
captureFrame: () => canvasRef.value?.captureFrame(),
setEmotion: (emotion: TachieEmotion, intensity?: number) => modelRef.value?.setEmotion(emotion, intensity),
})
</script>
<template>
<Screen v-slot="{ width, height }" relative>
<TachieCanvas
ref="canvas"
v-slot="{ app }"
v-model:state="componentStateCanvas"
:width="width"
:height="height"
:resolution="effectiveRenderScale"
max-h="100dvh"
>
<TachieModel
ref="model"
v-model:state="componentStateModel"
:app="app"
:model-src="modelSrc"
:model-id="modelId"
:width="width"
:height="height"
:paused="paused"
:shadow-enabled="effectiveShadowEnabled"
:theme-colors-hue="themeColorsHue"
:theme-colors-hue-dynamic="themeColorsHueDynamic"
@error="emit('error', $event)"
/>
</TachieCanvas>
</Screen>
</template>
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { Application } from '@pixi/app'
import { BatchRenderer } from '@pixi/core'
import { extensions } from '@pixi/extensions'
import { onMounted, onUnmounted, shallowRef, useTemplateRef, watch } from 'vue'
const props = withDefaults(defineProps<{
width: number
height: number
resolution?: number
}>(), {
resolution: 2,
})
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
const containerRef = useTemplateRef<HTMLDivElement>('container')
const app = shallowRef<Application>()
const canvas = shallowRef<HTMLCanvasElement>()
function logicalRenderScale() {
return Math.max(0.5, Math.min(2, props.resolution))
}
function render() {
if (!app.value)
return
try {
app.value.render()
}
catch (error) {
console.error('[Tachie] Pixi render error.', error)
}
}
function resize() {
if (!app.value)
return
const resolution = logicalRenderScale()
app.value.renderer.resize(
Math.max(1, Math.floor(props.width * resolution)),
Math.max(1, Math.floor(props.height * resolution)),
)
// Model transforms use logical CSS pixels. Scaling the root stage maps those
// coordinates onto the higher-resolution backing canvas.
app.value.stage.scale.set(resolution)
render()
}
function initialize(parent: HTMLDivElement) {
componentState.value = 'loading'
const resolution = logicalRenderScale()
extensions.add(BatchRenderer)
const nextApp = new Application({
width: Math.max(1, Math.floor(props.width * resolution)),
height: Math.max(1, Math.floor(props.height * resolution)),
backgroundAlpha: 0,
preserveDrawingBuffer: true,
autoDensity: false,
resolution: 1,
})
nextApp.stage.scale.set(resolution)
const nextCanvas = nextApp.view
nextCanvas.style.width = '100%'
nextCanvas.style.height = '100%'
nextCanvas.style.objectFit = 'cover'
nextCanvas.style.display = 'block'
parent.appendChild(nextCanvas)
app.value = nextApp
canvas.value = nextCanvas
componentState.value = 'mounted'
render()
}
async function captureFrame() {
return new Promise<Blob | null>((resolve) => {
if (!canvas.value)
return resolve(null)
render()
canvas.value.toBlob(resolve, 'image/png')
})
}
watch([() => props.width, () => props.height, () => props.resolution], resize)
onMounted(() => {
if (containerRef.value)
initialize(containerRef.value)
})
onUnmounted(() => {
app.value?.destroy(true)
app.value = undefined
canvas.value = undefined
})
defineExpose({
canvasElement: () => canvas.value,
captureFrame,
render,
})
</script>
<template>
<div
ref="container"
:class="[
'h-full w-full',
]"
>
<slot v-if="app" :app="app" :render="render" />
</div>
</template>
@@ -0,0 +1,2 @@
export { default as TachieCanvas } from './canvas.vue'
export { default as TachieModel } from './model.vue'
@@ -0,0 +1,296 @@
<script setup lang="ts">
import type { Application } from '@pixi/app'
import type { Texture } from '@pixi/core'
import type { TachieEmotion } from '../../../constants/emotions'
import type { TachieLoadedAssets } from '../../../utils/tachieArchive'
import { errorMessageFrom } from '@moeru/std'
import { Texture as PixiTexture, Renderer } from '@pixi/core'
import { Sprite } from '@pixi/sprite'
import { useTheme } from '@proj-airi/ui'
import { formatHex } from 'culori'
import { storeToRefs } from 'pinia'
import { DropShadowFilter } from 'pixi-filters'
import { onMounted, onUnmounted, shallowRef, useTemplateRef, watch } from 'vue'
import { DEFAULT_TACHIE_EMOTION, isTachieEmotion } from '../../../constants/emotions'
import { useTachie } from '../../../stores/tachie'
import {
loadTachieZip,
MAX_TACHIE_ARCHIVE_BYTES,
} from '../../../utils/tachieArchive'
interface MountedTachie {
assets: TachieLoadedAssets
sprite: Sprite
textures: Map<TachieEmotion, Texture>
}
const props = withDefaults(defineProps<{
app: Application
modelSrc?: string
modelId?: string
width: number
height: number
shadowEnabled?: boolean
themeColorsHue?: number
themeColorsHueDynamic?: boolean
paused?: boolean
}>(), {
shadowEnabled: true,
themeColorsHue: 220.44,
themeColorsHueDynamic: false,
paused: false,
})
const emit = defineEmits<{
error: [value: unknown]
}>()
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
const shadowColorRef = useTemplateRef<HTMLDivElement>('shadowColor')
const currentModel = shallowRef<MountedTachie>()
const { isDark } = useTheme()
const { position, scale } = storeToRefs(useTachie())
const shadowFilter = new DropShadowFilter({
alpha: 0.2,
blur: 0,
distance: 20,
rotation: 45,
})
let loadSequence = 0
let loadAbortController: AbortController | undefined
let shadowAnimationFrame = 0
function render() {
try {
props.app.render()
}
catch (error) {
console.error('[Tachie] Pixi render error.', error)
emit('error', error)
}
}
function applyTransform(model = currentModel.value) {
if (!model)
return
const containScale = Math.min(
props.width / model.assets.width,
props.height / model.assets.height,
)
model.sprite.scale.set(containScale * scale.value)
model.sprite.position.set(
props.width / 2 + position.value.x,
props.height / 2 + position.value.y,
)
render()
}
function applyShadow(model = currentModel.value) {
if (!model)
return
if (!props.shadowEnabled) {
model.sprite.filters = []
render()
return
}
const colorElement = shadowColorRef.value
if (colorElement) {
const color = formatHex(getComputedStyle(colorElement).backgroundColor)
if (color)
shadowFilter.color = Number(color.replace('#', '0x'))
}
model.sprite.filters = [shadowFilter]
render()
}
function stopDynamicShadow() {
cancelAnimationFrame(shadowAnimationFrame)
shadowAnimationFrame = 0
}
function updateDynamicShadow() {
applyShadow()
if (props.themeColorsHueDynamic && props.shadowEnabled && !props.paused)
shadowAnimationFrame = requestAnimationFrame(updateDynamicShadow)
else
shadowAnimationFrame = 0
}
function syncDynamicShadow() {
stopDynamicShadow()
updateDynamicShadow()
}
function disposeMountedModel(model?: MountedTachie) {
if (!model)
return
props.app.stage.removeChild(model.sprite)
model.sprite.destroy()
for (const texture of model.textures.values())
texture.destroy(true)
model.assets.dispose()
}
async function fetchModelSource(source: string, signal: AbortSignal) {
const response = await fetch(source, { signal })
if (!response.ok)
throw new Error(`Failed to fetch Tachie archive (${response.status} ${response.statusText}).`)
const contentLength = Number(response.headers.get('content-length'))
if (Number.isFinite(contentLength) && contentLength > MAX_TACHIE_ARCHIVE_BYTES)
throw new Error('Tachie archive exceeds the 100 MiB compressed size limit.')
return response.blob()
}
function createMountedModel(assets: TachieLoadedAssets): MountedTachie {
if (!(props.app.renderer instanceof Renderer)) {
assets.dispose()
throw new Error('Tachie rendering requires a WebGL renderer.')
}
const textures = new Map<TachieEmotion, Texture>()
try {
for (const [emotion, image] of assets.images) {
const texture = PixiTexture.from(image.source)
// Force the upload now so later emotion changes do not pay a first-use
// texture upload cost or reveal a decode failure mid-conversation.
props.app.renderer.texture.bind(texture.baseTexture)
textures.set(emotion, texture)
}
const neutralTexture = textures.get(DEFAULT_TACHIE_EMOTION)
if (!neutralTexture)
throw new Error('Decoded Tachie model has no neutral texture.')
const sprite = new Sprite(neutralTexture)
sprite.anchor.set(0.5)
return { assets, sprite, textures }
}
catch (error) {
for (const texture of textures.values())
texture.destroy(true)
assets.dispose()
throw error
}
}
async function loadModel() {
const requestId = ++loadSequence
loadAbortController?.abort()
loadAbortController = undefined
if (!props.modelSrc) {
disposeMountedModel(currentModel.value)
currentModel.value = undefined
componentState.value = 'mounted'
render()
return
}
const controller = new AbortController()
loadAbortController = controller
componentState.value = 'loading'
try {
const archive = await fetchModelSource(props.modelSrc, controller.signal)
const assets = await loadTachieZip(archive)
if (requestId !== loadSequence || controller.signal.aborted) {
assets.dispose()
return
}
const nextModel = createMountedModel(assets)
nextModel.sprite.visible = false
props.app.stage.addChild(nextModel.sprite)
applyTransform(nextModel)
applyShadow(nextModel)
nextModel.sprite.visible = true
render()
const previousModel = currentModel.value
currentModel.value = nextModel
disposeMountedModel(previousModel)
componentState.value = 'mounted'
}
catch (error) {
if (controller.signal.aborted)
return
console.error('[Tachie] Failed to load model:', errorMessageFrom(error))
componentState.value = currentModel.value ? 'mounted' : 'pending'
emit('error', error)
}
finally {
if (loadAbortController === controller)
loadAbortController = undefined
}
}
/**
* Displays the requested AIRI emotion, falling back to the required neutral
* image when the archive does not provide that state.
*/
function setEmotion(emotion: string, _intensity = 1): TachieEmotion | undefined {
const model = currentModel.value
if (!model)
return undefined
const requested = isTachieEmotion(emotion) ? emotion : DEFAULT_TACHIE_EMOTION
const resolved = model.textures.has(requested) ? requested : DEFAULT_TACHIE_EMOTION
const texture = model.textures.get(resolved)
if (!texture)
return undefined
model.sprite.texture = texture
render()
return resolved
}
watch(() => props.modelSrc, () => void loadModel(), { immediate: true })
watch(
[() => props.width, () => props.height, position, scale],
() => applyTransform(),
{ deep: true },
)
watch(
[() => props.shadowEnabled, () => props.themeColorsHue, isDark],
() => applyShadow(),
)
watch(
[() => props.themeColorsHueDynamic, () => props.shadowEnabled, () => props.paused],
syncDynamicShadow,
)
onMounted(syncDynamicShadow)
onUnmounted(() => {
loadSequence++
loadAbortController?.abort()
stopDynamicShadow()
disposeMountedModel(currentModel.value)
currentModel.value = undefined
})
defineExpose({
setEmotion,
})
</script>
<template>
<div
ref="shadowColor"
:class="[
'hidden',
'bg-primary-400 dark:bg-primary-500',
]"
/>
</template>
@@ -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)
}
+6
View File
@@ -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'
@@ -0,0 +1 @@
export * from './tachie'
@@ -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,
}
})
@@ -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.')
})
})
@@ -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<typeof TACHIE_EMOTIONS[number], TachieDecodedImage>
/** 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<typeof TACHIE_EMOTIONS[number], TachieArchiveEntry>()
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<TachieDecodedImage>) {
for (const image of images)
image.dispose()
}
async function inspectTachieArchive(
input: Blob | ArrayBuffer,
options: TachieArchiveLoadOptions = {},
): Promise<TachieArchiveInspection> {
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<typeof TACHIE_EMOTIONS[number], TachieDecodedImage>()
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<TachieLoadedAssets> {
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<TachieValidationReport> {
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
}
@@ -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<string | undefined> {
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()
}
}
+36
View File
@@ -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"
]
}
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
})
+1
View File
@@ -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:",
@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { Live2DValidationReport } from '@proj-airi/stage-ui-live2d'
import type { TachieValidationReport } from '@proj-airi/stage-ui-tachie'
import type { DisplayModel } from '../../../../stores/display-models'
import { validateLive2DZip } from '@proj-airi/stage-ui-live2d'
import { TACHIE_ARCHIVE_SUFFIX, validateTachieZip } from '@proj-airi/stage-ui-tachie'
import { Button } from '@proj-airi/ui'
import { useFileDialog } from '@vueuse/core'
import { storeToRefs } from 'pinia'
@@ -12,6 +14,7 @@ import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Live2DReportModal from './Live2DReportModal.vue'
import TachieReportModal from './tachieReportModal.vue'
import { DisplayModelFormat, useDisplayModelsStore } from '../../../../stores/display-models'
@@ -43,6 +46,9 @@ const highlightDisplayModelCard = ref<string | undefined>(props.selectedModel?.i
const showReportModal = ref(false)
const pendingFile = ref<File | null>(null)
const validationReport = ref<Live2DValidationReport | null>(null)
const showTachieReportModal = ref(false)
const pendingTachieFile = ref<File | null>(null)
const tachieValidationReport = ref<TachieValidationReport | null>(null)
watch(() => props.selectedModel?.id, (modelId) => {
highlightDisplayModelCard.value = modelId
@@ -81,6 +87,36 @@ async function confirmImport() {
pendingFile.value = null
}
async function handleAddTachieModel(file: FileList | null) {
if (file === null || file.length === 0)
return
const picked = file[0]
if (!picked.name.toLowerCase().endsWith(TACHIE_ARCHIVE_SUFFIX))
return
const report = await validateTachieZip(picked)
pendingTachieFile.value = picked
tachieValidationReport.value = report
if (report.status === 'VALID') {
await confirmTachieImport()
return
}
showTachieReportModal.value = true
}
async function confirmTachieImport() {
if (!pendingTachieFile.value || tachieValidationReport.value?.status === 'INVALID')
return
const displayModel = await displayModelStore.addDisplayModel(DisplayModelFormat.TachieZip, pendingTachieFile.value)
highlightDisplayModelCard.value = displayModel.id
pendingTachieFile.value = null
tachieValidationReport.value = null
}
function handleFixError(error: string) {
void error
}
@@ -162,6 +198,7 @@ const mapFormatRenderer: Record<DisplayModelFormat, string> = {
[DisplayModelFormat.Live2dDirectory]: 'Live2D',
[DisplayModelFormat.VRM]: 'VRM',
[DisplayModelFormat.SpineZip]: 'Spine',
[DisplayModelFormat.TachieZip]: 'Tachie',
[DisplayModelFormat.PMXDirectory]: 'MMD',
[DisplayModelFormat.PMXZip]: 'MMD',
[DisplayModelFormat.PMD]: 'MMD',
@@ -170,11 +207,13 @@ const mapFormatRenderer: Record<DisplayModelFormat, string> = {
const live2dDialog = useFileDialog({ accept: '.zip', multiple: false, reset: true })
const vrmDialog = useFileDialog({ accept: '.vrm', multiple: false, reset: true })
const spineDialog = useFileDialog({ accept: '.zip', multiple: false, reset: true })
const tachieDialog = useFileDialog({ accept: TACHIE_ARCHIVE_SUFFIX, multiple: false, reset: true })
const mmdDialog = useFileDialog({ accept: '.zip,.pmx,.pmd', multiple: false, reset: true })
live2dDialog.onChange(handleAddLive2DModel)
vrmDialog.onChange(handleAddVRMModel)
spineDialog.onChange(handleAddSpineModel)
tachieDialog.onChange(handleAddTachieModel)
mmdDialog.onChange(handleAddMMDModel)
</script>
@@ -186,6 +225,11 @@ mmdDialog.onChange(handleAddMMDModel)
@confirm="confirmImport"
@fix-error="handleFixError"
/>
<TachieReportModal
v-model:open="showTachieReportModal"
:report="tachieValidationReport"
@confirm="confirmTachieImport"
/>
<div flex items-center>
<div w-full flex-1 text-xl>
@@ -257,6 +301,18 @@ mmdDialog.onChange(handleAddMMDModel)
>
MMD
</DropdownMenuItem>
<DropdownMenuItem
:class="[
'data-[disabled]:text-mauve8 relative flex cursor-pointer select-none items-center rounded-md px-3 py-2 leading-none outline-none data-[disabled]:pointer-events-none',
'text-base sm:text-sm',
'data-[highlighted]:bg-primary-300/20 dark:data-[highlighted]:bg-primary-100/20',
'data-[highlighted]:text-primary-400 dark:data-[highlighted]:text-primary-200',
]"
transition="colors duration-200 ease-in-out"
@click="tachieDialog.open()"
>
Tachie
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
@@ -0,0 +1,210 @@
<script setup lang="ts">
import type { TachieValidationReport } from '@proj-airi/stage-ui-tachie'
import { Button } from '@proj-airi/ui'
import { useMediaQuery, useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot } from 'vaul-vue'
import { onMounted } from 'vue'
defineProps<{
report: TachieValidationReport | null
}>()
const emit = defineEmits<{
close: []
confirm: []
}>()
const open = defineModel<boolean>('open', { default: false })
const isDesktop = useMediaQuery('(min-width: 768px)')
const screenSafeArea = useScreenSafeArea()
useResizeObserver(document.documentElement, () => screenSafeArea.update())
onMounted(() => screenSafeArea.update())
function close() {
emit('close')
open.value = false
}
function confirm() {
emit('confirm')
open.value = false
}
</script>
<template>
<DialogRoot v-if="isDesktop" :open="open" @update:open="value => open = value">
<DialogPortal>
<DialogOverlay
:class="[
'fixed inset-0 z-[9999]',
'bg-black/50 backdrop-blur-sm',
'data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn',
]"
/>
<DialogContent
:class="[
'fixed left-1/2 top-1/2 z-[9999]',
'max-h-full max-w-xl w-[92dvw] overflow-y-auto',
'rounded-2xl bg-white p-6 shadow-xl outline-none dark:bg-neutral-900',
'-translate-x-1/2 -translate-y-1/2',
'data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow',
]"
>
<div :class="['mb-4', 'flex items-center justify-between gap-2']">
<DialogTitle class="text-lg text-neutral-900 font-semibold dark:text-neutral-100">
Tachie Model Audit Report
</DialogTitle>
<Button size="sm" variant="secondary" @click="close">
Close
</Button>
</div>
<div v-if="report" :class="['flex flex-col gap-4']">
<div
:class="[
'flex items-center gap-3 rounded-lg p-4 font-bold',
report.status === 'VALID' ? 'bg-green-100/50 text-green-700 dark:bg-green-900/30 dark:text-green-400' : '',
report.status === 'WARNING' ? 'bg-yellow-100/50 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' : '',
report.status === 'INVALID' ? 'bg-red-100/50 text-red-700 dark:bg-red-900/30 dark:text-red-400' : '',
]"
>
<div v-if="report.status === 'VALID'" i-solar:check-circle-bold-duotone text-2xl />
<div v-else-if="report.status === 'WARNING'" i-solar:danger-bold-duotone text-2xl />
<div v-else i-solar:close-circle-bold-duotone text-2xl />
<div :class="['flex flex-col']">
<span>Status: {{ report.status }}</span>
<span class="text-xs opacity-80">{{ report.fileName }}</span>
</div>
</div>
<div :class="['grid grid-cols-2 gap-2', 'rounded bg-neutral-100/50 p-2 text-sm dark:bg-neutral-800/50']">
<div>
Emotions: <span class="font-mono">{{ report.detected.length }}</span>
</div>
<div>
Canvas:
<span class="font-mono">
{{ report.canvas ? `${report.canvas.width}×${report.canvas.height}` : 'Unavailable' }}
</span>
</div>
<div class="col-span-2">
Detected:
<span class="font-mono">{{ report.detected.map(entry => entry.emotion).join(', ') || 'None' }}</span>
</div>
</div>
<div v-if="report.errors.length > 0" :class="['flex flex-col gap-1']">
<div class="flex items-center gap-1 text-red-600 font-bold dark:text-red-400">
<div i-solar:bug-bold-duotone />
Critical Issues
</div>
<ul :class="['list-none p-0', 'flex flex-col gap-1']">
<li
v-for="error in report.errors"
:key="error"
class="rounded bg-red-50/50 p-2 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-300"
>
{{ error }}
</li>
</ul>
</div>
<div v-if="report.warnings.length > 0" :class="['flex flex-col gap-1']">
<div class="flex items-center gap-1 text-yellow-600 font-bold dark:text-yellow-400">
<div i-solar:danger-bold-duotone />
Warnings
</div>
<ul :class="['list-none p-0', 'flex flex-col gap-1']">
<li
v-for="warning in report.warnings"
:key="warning"
class="rounded bg-yellow-50/50 p-2 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300"
>
{{ warning }}
</li>
</ul>
</div>
<div v-if="report.ignoredEntries.length > 0" class="text-xs text-neutral-500 dark:text-neutral-400">
Ignored: {{ report.ignoredEntries.join(', ') }}
</div>
<div :class="['mt-2', 'flex justify-end gap-2']">
<Button variant="secondary" @click="close">
Cancel
</Button>
<Button v-if="report.status !== 'INVALID'" @click="confirm">
{{ report.status === 'WARNING' ? 'Import Anyway' : 'Confirm Import' }}
</Button>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
<DrawerRoot v-else :open="open" should-scale-background @update:open="value => open = value">
<DrawerPortal>
<DrawerOverlay class="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm" />
<DrawerContent
:class="[
'fixed bottom-0 left-0 right-0 z-[9999]',
'h-full max-h-[85%] flex flex-col',
'rounded-t-2xl bg-neutral-50 px-4 pt-4 outline-none dark:bg-neutral-900/95',
]"
:style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 24)}px` }"
>
<DrawerHandle />
<div :class="['mb-4', 'flex items-center justify-between gap-2']">
<div class="text-lg text-neutral-900 font-semibold dark:text-neutral-100">
Tachie Model Audit Report
</div>
<Button size="sm" variant="secondary" @click="close">
Close
</Button>
</div>
<div v-if="report" :class="['flex flex-1 flex-col gap-4 overflow-y-auto pb-4']">
<div
:class="[
'rounded-lg p-4 font-bold',
report.status === 'VALID' ? 'bg-green-100/50 text-green-700 dark:bg-green-900/30' : '',
report.status === 'WARNING' ? 'bg-yellow-100/50 text-yellow-700 dark:bg-yellow-900/30' : '',
report.status === 'INVALID' ? 'bg-red-100/50 text-red-700 dark:bg-red-900/30' : '',
]"
>
{{ report.status }}: {{ report.fileName }}
</div>
<ul v-if="report.errors.length > 0" :class="['list-none p-0', 'flex flex-col gap-1']">
<li
v-for="error in report.errors"
:key="error"
class="rounded bg-red-50/50 p-2 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-300"
>
{{ error }}
</li>
</ul>
<ul v-if="report.warnings.length > 0" :class="['list-none p-0', 'flex flex-col gap-1']">
<li
v-for="warning in report.warnings"
:key="warning"
class="rounded bg-yellow-50/50 p-2 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300"
>
{{ warning }}
</li>
</ul>
<div :class="['mt-auto', 'flex flex-col gap-2 pt-4']">
<Button v-if="report.status !== 'INVALID'" @click="confirm">
{{ report.status === 'WARNING' ? 'Import Anyway' : 'Confirm Import' }}
</Button>
<Button variant="secondary" @click="close">
Cancel
</Button>
</div>
</div>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</template>
@@ -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"
/>
@@ -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) {
<p>
{{ t('settings.model-select.panel-callout.model-type-example') }}
</p>
<p v-if="effectiveRenderer === 'tachie'">
{{ t('settings.tachie.archive-description') }}
</p>
</Callout>
<div :class="['flex flex-wrap items-center gap-2']">
<ModelSelectorDialog v-model:show="modelSelectorOpen" :selected-model="stageModelSelectedDisplayModel" @pick="handleModelPick">
@@ -128,6 +132,13 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="emit('extractColorsFromModel')"
/>
<Tachie
v-if="effectiveRenderer === 'tachie'"
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="emit('extractColorsFromModel')"
/>
<Godot
v-if="effectiveRenderer === 'godot'"
:runtime-snapshot="runtimeSnapshot"
@@ -4,6 +4,7 @@ import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { Live2DScene } 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, useModelStore } from '@proj-airi/stage-ui-three'
import { useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
@@ -19,6 +20,7 @@ const props = defineProps<{
live2dSceneClass?: string | string[]
vrmSceneClass?: string | string[]
spineSceneClass?: string | string[]
tachieSceneClass?: string | string[]
mmdSceneClass?: string | string[]
}>()
@@ -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<ModelSettingsRuntimeSnapshot>(() => {
})
}
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(() => ({
/>
</div>
</template>
<template v-if="stageModelRenderer === 'tachie'">
<div :class="tachieSceneClassList">
<TachieScene
ref="tachieSceneRef"
v-model:state="tachieComponentState"
:model-src="stageModelSelectedUrl"
:model-id="stageModelSelected"
:theme-colors-hue="themeColorsHue"
:theme-colors-hue-dynamic="themeColorsHueDynamic"
/>
</div>
</template>
</template>
@@ -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 {
@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { tachieControlConfig, useTachie } from '@proj-airi/stage-ui-tachie'
import { Button, FieldCheckbox, FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Section } from '../../../layouts'
import { ColorPalette } from '../../../widgets'
const props = withDefaults(defineProps<{
palette: string[]
allowExtractColors?: boolean
runtimeSnapshot: ModelSettingsRuntimeSnapshot
}>(), {
allowExtractColors: true,
})
const emit = defineEmits<{
extractColorsFromModel: []
}>()
const { t } = useI18n()
const tachie = useTachie()
const { position, scale, renderScale, shadowEnabled } = storeToRefs(tachie)
const canExtractColors = computed(() => props.runtimeSnapshot.canCapturePreview)
</script>
<template>
<Section
:title="t('settings.tachie.scale-and-position.title')"
icon="i-solar:scale-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="true"
>
<FieldRange
v-model="scale"
as="div"
:min="tachieControlConfig.scale.min"
:max="tachieControlConfig.scale.max"
:step="tachieControlConfig.scale.step"
:default-value="tachieControlConfig.scale.default"
:label="t('settings.tachie.scale-and-position.scale')"
/>
<FieldRange
v-model="position.x"
as="div"
:min="tachieControlConfig.x.min"
:max="tachieControlConfig.x.max"
:step="tachieControlConfig.x.step"
:default-value="tachieControlConfig.x.default"
:label="t('settings.tachie.scale-and-position.x')"
/>
<FieldRange
v-model="position.y"
as="div"
:min="tachieControlConfig.y.min"
:max="tachieControlConfig.y.max"
:step="tachieControlConfig.y.step"
:default-value="tachieControlConfig.y.default"
:label="t('settings.tachie.scale-and-position.y')"
/>
</Section>
<Section
v-if="allowExtractColors"
:title="t('settings.tachie.theme-color-from-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="false"
>
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" :disabled="!canExtractColors" @click="emit('extractColorsFromModel')">
{{ t('settings.tachie.theme-color-from-model.button-extract.title') }}
</Button>
</Section>
<Section
:title="t('settings.tachie.rendering.title')"
icon="i-solar:settings-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="false"
>
<FieldRange
v-model="renderScale"
as="div"
:min="tachieControlConfig.renderScale.min"
:max="tachieControlConfig.renderScale.max"
:step="tachieControlConfig.renderScale.step"
:default-value="tachieControlConfig.renderScale.default"
:label="t('settings.tachie.rendering.render-scale')"
/>
<FieldCheckbox
v-model="shadowEnabled"
:label="t('settings.tachie.rendering.shadow')"
placement="right"
/>
</Section>
</template>
@@ -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<InstanceType<typeof ThreeScene>>()
const live2dSceneRef = ref<InstanceType<typeof Live2DScene>>()
const spineSceneRef = ref<InstanceType<typeof SpineScene>>()
const tachieSceneRef = ref<InstanceType<typeof TachieScene>>()
const mmdSceneRef = ref<InstanceType<typeof MMDScene>>()
const settingsStore = useSettings()
@@ -208,6 +210,9 @@ const emotionsQueue = createQueue<EmotionPayload>({
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"
/>
<TachieScene
v-if="stageModelRenderer === 'tachie' && showStage"
ref="tachieSceneRef"
v-model:state="componentState"
min-w="50% <lg:full" min-h="100 sm:100"
h-full w-full flex-1
:model-src="stageModelSelectedUrl"
:model-id="stageModelSelected"
:paused="paused"
:theme-colors-hue="themeColorsHue"
:theme-colors-hue-dynamic="themeColorsHueDynamic"
@error="console.error"
/>
<MMDScene
v-if="stageModelRenderer === 'mmd' && showStage"
ref="mmdSceneRef"
@@ -5,3 +5,4 @@ export {
Live2DModel,
Live2DScene as SceneLive2D,
} from '@proj-airi/stage-ui-live2d/components/scenes'
export { TachieScene as SceneTachie } from '@proj-airi/stage-ui-tachie/components/scenes'
@@ -90,6 +90,45 @@ describe('airi card package import/export', () => {
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<typeof useDisplayModelsStore>, id = 'unused') {
@@ -18,6 +18,7 @@ const MANIFEST_PATH = 'manifest.json'
const MODEL_EXT: Partial<Record<DisplayModelFormat, string>> = {
[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(),
}),
})),
@@ -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<string | undefined>
let generateVrmPreview: (file: File) => Promise<string | undefined>
let generateSpinePreview: (file: File) => Promise<string | undefined>
let generateTachiePreview: (file: File) => Promise<string | undefined>
let generateMMDPreview: (file: File) => Promise<string | undefined>
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,
@@ -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()
@@ -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')
})
})
@@ -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<StageModelRenderer, 'godot'>
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:
+52
View File
@@ -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':