feat(stage-ui-tachie): add tachie support (#2115)
This commit is contained in:
@@ -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: >-
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
+210
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user