feat(stage-ui): improve Live2D import diagnostics (#2406)

This commit is contained in:
Neko
2026-08-30 01:48:37 +08:00
committed by GitHub
parent da2ac8bbcc
commit 0977bcd741
10 changed files with 1439 additions and 526 deletions
@@ -129,6 +129,38 @@ model-select:
import: Import
remove: Remove
confirm: Confirm
live2d-report:
title: Before import
description: Review the model data and any issues found in the archive.
description-short: Review the model data and issues.
close: Close
model:
title: Model
files: '{count} files'
types:
cubism: 'Cubism {version} ({format})'
cubism-compatible: 'Cubism 3+ ({format})'
unknown: Unknown
resources:
title: Resources
motions: Motions
expressions: Expressions
parameters: Parameters
textures: Textures
referenced-and-found: '{referenced} referenced · {found} found'
referenced: '{count} referenced'
found: '{count} found'
issues:
title: Issues
errors: '{count} errors'
warnings: '{count} warnings'
none: No issues found.
how-to-fix: How to fix
resolve-before-import: Resolve the errors before import.
actions:
cancel: Cancel
confirm: Confirm Import
import-anyway: Import Anyway
live2d:
change-model:
from-file: Load from File
@@ -117,6 +117,38 @@ model-select:
import: 导入
remove: 移除
confirm: 确认
live2d-report:
title: 导入前检查
description: 检查模型数据以及压缩包中发现的问题。
description-short: 检查模型数据和问题。
close: 关闭
model:
title: 模型
files: '{count} 个文件'
types:
cubism: 'Cubism {version} ({format})'
cubism-compatible: 'Cubism 3+ ({format})'
unknown: 未知
resources:
title: 资源
motions: 动作
expressions: 表情
parameters: 参数
textures: 纹理
referenced-and-found: '引用 {referenced} 个 · 找到 {found} 个'
referenced: '引用 {count} 个'
found: '找到 {count} 个'
issues:
title: 问题
errors: '{count} 个错误'
warnings: '{count} 个警告'
none: 未发现问题。
how-to-fix: 如何解决
resolve-before-import: 请先解决错误再导入。
actions:
cancel: 取消
confirm: 确认导入
import-anyway: 仍然导入
live2d:
change-model:
from-file: 从文件加载
@@ -0,0 +1,175 @@
import JSZip from 'jszip'
import { describe, expect, it } from 'vitest'
import { validateLive2DZip } from './live2d-validator'
function createMoc(version = 5, size = 16): Uint8Array {
const moc = new Uint8Array(size)
moc.set([77, 79, 67, 51, version])
return moc
}
async function createLive2DFile(options: {
includeMoc?: boolean
includeUnreferencedResources?: boolean
includeBasenameCollision?: boolean
includeMacOSMetadata?: boolean
} = {}): Promise<File> {
const {
includeMoc = true,
includeUnreferencedResources = false,
includeBasenameCollision = false,
includeMacOSMetadata = false,
} = options
const zip = new JSZip()
const expressions = includeUnreferencedResources
? undefined
: [
{ Name: 'happy', File: 'expressions/happy.exp3.json' },
{ Name: 'sad', File: 'expressions/sad.exp3.json' },
]
const motions = includeUnreferencedResources
? undefined
: {
Idle: [
{ File: 'motions/idle.motion3.json' },
{ File: 'motions/wave.motion3.json' },
],
}
zip.file('model/avatar.model3.json', JSON.stringify({
Version: 3,
FileReferences: {
Moc: 'avatar.moc3',
Textures: ['textures/texture_00.png'],
DisplayInfo: 'avatar.cdi3.json',
Expressions: expressions,
Motions: motions,
},
}))
if (includeMoc)
zip.file('model/avatar.moc3', createMoc())
zip.file('model/textures/texture_00.png', new Uint8Array([1, 2, 3]))
zip.file('model/avatar.cdi3.json', JSON.stringify({
Version: 3,
Parameters: [
{ Id: 'ParamAngleX', Name: 'Angle X' },
{ Id: 'ParamAngleY', Name: 'Angle Y' },
{ Id: 'ParamMouthOpenY', Name: 'Mouth open' },
],
}))
zip.file('model/expressions/happy.exp3.json', JSON.stringify({
Type: 'Live2D Expression',
Parameters: [{ Id: 'ParamEyeLSmile', Value: 1, Blend: 'Add' }],
}))
zip.file('model/expressions/sad.exp3.json', JSON.stringify({
Type: 'Live2D Expression',
Parameters: [{ Id: 'ParamBrowLY', Value: -1, Blend: 'Add' }],
}))
zip.file('model/motions/idle.motion3.json', JSON.stringify({ Version: 3, Curves: [] }))
zip.file('model/motions/wave.motion3.json', JSON.stringify({ Version: 3, Curves: [] }))
if (includeBasenameCollision)
zip.file('model/alternate/texture_00.png', new Uint8Array([4, 5, 6]))
if (includeMacOSMetadata) {
zip.file('__MACOSX/model/._avatar.model3.json', new Uint8Array([0, 5, 22, 7]))
zip.file('__MACOSX/model/expressions/._happy.exp3.json', new Uint8Array([0, 5, 22, 7]))
}
const bytes = await zip.generateAsync({ type: 'arraybuffer' })
return new File([bytes], 'avatar.zip')
}
async function createLooseMocFile(): Promise<File> {
const zip = new JSZip()
zip.file('avatar.moc3', createMoc())
zip.file('texture.png', new Uint8Array([1, 2, 3]))
const bytes = await zip.generateAsync({ type: 'arraybuffer' })
return new File([bytes], 'avatar.zip')
}
describe('validateLive2DZip', () => {
it('reports the model type, parsed resources, parameters, and base model data', async () => {
const report = await validateLive2DZip(await createLive2DFile())
expect(report.status).toBe('VALID')
expect(report.model).toEqual({
type: 'model3',
entryPoint: 'model/avatar.model3.json',
archiveFileCount: 8,
moc: {
path: 'model/avatar.moc3',
version: 5,
size: 16,
},
})
expect(report.resources).toEqual({
textures: { discovered: 1, referenced: 1 },
motions: { discovered: 2, referenced: 2, parsed: 2 },
expressions: { discovered: 2, referenced: 2, parsed: 2 },
parameters: { parsed: 3, source: 'display-info' },
})
expect(report.issues).toEqual([])
})
it('reports a loose MOC file as a moc3 model', async () => {
const report = await validateLive2DZip(await createLooseMocFile())
expect(report.status).toBe('VALID')
expect(report.model.type).toBe('moc3')
expect(report.model.entryPoint).toBeNull()
expect(report.model.moc?.path).toBe('avatar.moc3')
})
it('reports unreferenced expressions and motions as import warnings', async () => {
const report = await validateLive2DZip(await createLive2DFile({
includeUnreferencedResources: true,
}))
expect(report.status).toBe('WARNING')
expect(report.issues).toEqual(expect.arrayContaining([
expect.objectContaining({
code: 'unreferenced-expressions',
severity: 'warning',
message: '2 expression files are not referenced by avatar.model3.json.',
}),
expect.objectContaining({
code: 'unreferenced-motions',
severity: 'warning',
message: '2 motion files are not referenced by avatar.model3.json.',
}),
]))
expect(report.issues.every(issue => issue.resolution.length > 0)).toBe(true)
})
it('derives INVALID from an error that blocks model loading', async () => {
const report = await validateLive2DZip(await createLive2DFile({ includeMoc: false }))
expect(report.status).toBe('INVALID')
expect(report.issues).toContainEqual(expect.objectContaining({
code: 'missing-reference',
severity: 'error',
message: 'The referenced MOC file "avatar.moc3" is missing.',
resolution: 'Add the file at "model/avatar.moc3", or update the MOC path in avatar.model3.json.',
}))
})
it('keeps same-name files in distinct paths and ignores macOS metadata', async () => {
const report = await validateLive2DZip(await createLive2DFile({
includeBasenameCollision: true,
includeMacOSMetadata: true,
}))
expect(report.status).toBe('VALID')
expect(report.model.archiveFileCount).toBe(9)
expect(report.resources.expressions.discovered).toBe(2)
expect(report.issues.map(issue => issue.code)).not.toContain('basename-collision')
expect(report.issues.some(issue => issue.message.includes('._'))).toBe(false)
})
})
@@ -2,156 +2,586 @@ import JSZip from 'jszip'
import { decodeZipFileName } from './decode-zip-filename'
/** Whether the inspected archive can be imported without review, with warnings, or not at all. */
export type Live2DValidationStatus = 'VALID' | 'WARNING' | 'INVALID'
/** Whether an issue blocks import or only needs review. */
export type Live2DValidationIssueSeverity = 'error' | 'warning'
/** The file type that AIRI uses as the model target. */
export type Live2DModelType = 'model3' | 'moc3' | 'unknown'
/** A stable identifier for a validation rule that produced an issue. */
export type Live2DValidationIssueCode
= | 'multiple-settings-files'
| 'missing-settings-file'
| 'invalid-settings-json'
| 'missing-moc-reference'
| 'invalid-moc-header'
| 'moc-too-large'
| 'moc-performance-risk'
| 'missing-reference'
| 'case-mismatch'
| 'invalid-resource-json'
| 'missing-display-info'
| 'invalid-display-info'
| 'unreferenced-expressions'
| 'unreferenced-motions'
/** A problem that AIRI found while it inspected a Live2D archive. */
export interface Live2DValidationIssue {
code: Live2DValidationIssueCode
severity: Live2DValidationIssueSeverity
message: string
resolution: string
}
/** Base model data that AIRI can read before it loads the Cubism runtime. */
export interface Live2DModelSummary {
type: Live2DModelType
entryPoint: string | null
archiveFileCount: number
moc: {
path: string
version: number
size: number
} | null
}
/** Counts the files found in the archive and referenced by the model settings. */
export interface Live2DResourceCount {
discovered: number
referenced: number
}
/** Adds the number of resource files that AIRI could parse. */
export interface Live2DParsedResourceCount extends Live2DResourceCount {
parsed: number
}
/** Resource counts that AIRI can collect without loading the model. */
export interface Live2DResourceSummary {
textures: Live2DResourceCount
motions: Live2DParsedResourceCount
expressions: Live2DParsedResourceCount
parameters: {
parsed: number
source: 'display-info' | 'unavailable'
}
}
/** The result of inspecting a Live2D ZIP before import. */
export interface Live2DValidationReport {
fileName: string
totalFiles: number
status: 'VALID' | 'WARNING' | 'INVALID'
entryPoint: string | null
structureType: 'Standard (model3.json)' | 'Heuristic (Loose Files)' | 'Unknown'
errors: string[]
warnings: string[]
checks: string[]
mocInfo?: {
header: string
ver: number
size: number
status: Live2DValidationStatus
model: Live2DModelSummary
resources: Live2DResourceSummary
issues: Live2DValidationIssue[]
}
interface ReferenceCheckOptions {
label: string
reference: string
expectedPath: string
settingsFileName: string
severity: Live2DValidationIssueSeverity
}
function isIgnoredArchivePath(filePath: string): boolean {
return filePath
.split('/')
.some(segment => segment === '__MACOSX' || segment.startsWith('._'))
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function basename(filePath: string): string {
return filePath.split(/[\\/]/).pop() ?? filePath
}
/**
* Normalizes a reference relative to the model settings file.
*
* @example
* resolveArchivePath('model/avatar.model3.json', '../motions/idle.motion3.json')
* // => 'motions/idle.motion3.json'
*/
function resolveArchivePath(settingsPath: string, reference: string): string {
const baseSegments = settingsPath.split(/[\\/]/).slice(0, -1)
const referenceSegments = reference.split(/[\\/]/)
const resolved: string[] = []
for (const segment of [...baseSegments, ...referenceSegments]) {
if (segment === '' || segment === '.')
continue
if (segment === '..') {
resolved.pop()
continue
}
resolved.push(segment)
}
return resolved.join('/')
}
function readString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function readStringArray(value: unknown): string[] {
if (!Array.isArray(value))
return []
return value.filter((item): item is string => typeof item === 'string' && item.length > 0)
}
function readExpressionReferences(value: unknown): string[] {
if (!Array.isArray(value))
return []
const references: string[] = []
for (const item of value) {
if (typeof item === 'string') {
references.push(item)
continue
}
if (!isRecord(item))
continue
const file = readString(item.File)
if (file)
references.push(file)
}
return references
}
function readMotionReferences(value: unknown): string[] {
if (!isRecord(value))
return []
const references: string[] = []
for (const definitions of Object.values(value)) {
if (!Array.isArray(definitions))
continue
for (const definition of definitions) {
if (!isRecord(definition))
continue
const file = readString(definition.File)
if (file)
references.push(file)
}
}
return references
}
async function readJsonObject(zip: JSZip, filePath: string): Promise<Record<string, unknown>> {
const file = zip.file(filePath)
if (!file)
throw new Error(`Archive entry not found: ${filePath}`)
const value: unknown = JSON.parse(await file.async('text'))
if (!isRecord(value))
throw new TypeError(`Expected a JSON object in ${filePath}`)
return value
}
function addIssue(
report: Live2DValidationReport,
code: Live2DValidationIssueCode,
severity: Live2DValidationIssueSeverity,
message: string,
resolution: string,
): void {
report.issues.push({ code, severity, message, resolution })
}
function checkReference(
report: Live2DValidationReport,
archivePaths: string[],
options: ReferenceCheckOptions,
): boolean {
if (archivePaths.includes(options.expectedPath))
return true
const caseMatch = archivePaths.find(path => path.toLowerCase() === options.expectedPath.toLowerCase())
if (caseMatch) {
addIssue(
report,
'case-mismatch',
options.severity,
`The referenced ${options.label} file "${options.reference}" uses different letter casing.`,
`Use "${caseMatch}" in ${options.settingsFileName}. Archive paths are case-sensitive.`,
)
return false
}
addIssue(
report,
'missing-reference',
options.severity,
`The referenced ${options.label} file "${options.reference}" is missing.`,
`Add the file at "${options.expectedPath}", or update the ${options.label} path in ${options.settingsFileName}.`,
)
return false
}
async function countParsedJsonResources(
zip: JSZip,
filePaths: string[],
resourceName: 'expression' | 'motion',
report: Live2DValidationReport,
): Promise<number> {
let parsed = 0
for (const filePath of filePaths) {
try {
await readJsonObject(zip, filePath)
parsed += 1
}
catch {
addIssue(
report,
'invalid-resource-json',
'warning',
`The ${resourceName} file "${filePath}" is not valid JSON.`,
`Export the ${resourceName} again, or remove the invalid file from the archive.`,
)
}
}
return parsed
}
async function readParameterSummary(
zip: JSZip,
displayInfoPath: string | undefined,
report: Live2DValidationReport,
): Promise<Live2DResourceSummary['parameters']> {
if (!displayInfoPath)
return { parsed: 0, source: 'unavailable' }
try {
const displayInfo = await readJsonObject(zip, displayInfoPath)
const parameters = displayInfo.Parameters
return {
parsed: Array.isArray(parameters) ? parameters.length : 0,
source: 'display-info',
}
}
catch {
addIssue(
report,
'invalid-display-info',
'warning',
`The display information file "${displayInfoPath}" is not valid JSON.`,
'Export the display information file again, or remove its reference from the model settings.',
)
return { parsed: 0, source: 'unavailable' }
}
}
function updateStatus(report: Live2DValidationReport): void {
if (report.issues.some(issue => issue.severity === 'error')) {
report.status = 'INVALID'
return
}
if (report.issues.some(issue => issue.severity === 'warning')) {
report.status = 'WARNING'
return
}
report.status = 'VALID'
}
/**
* Inspects a Live2D ZIP before AIRI imports it.
*
* Errors identify missing core resources that block loading. Warnings identify optional
* resources that AIRI can skip while it loads the model.
*/
export async function validateLive2DZip(file: File | Blob): Promise<Live2DValidationReport> {
const zip = await JSZip.loadAsync(file, { decodeFileName: decodeZipFileName })
const allPaths = Object.keys(zip.files)
const zip = await JSZip.loadAsync(await file.arrayBuffer(), { decodeFileName: decodeZipFileName })
const archivePaths = Object.entries(zip.files)
.filter(([filePath, entry]) => !entry.dir && !isIgnoredArchivePath(filePath))
.map(([filePath]) => filePath)
let fileName = 'live2d-model.zip'
if ('name' in file && typeof file.name === 'string' && file.name.length > 0)
fileName = file.name
const report: Live2DValidationReport = {
fileName: (file as File).name || 'live2d-model.zip',
totalFiles: allPaths.length,
fileName,
status: 'VALID',
entryPoint: null,
structureType: 'Unknown',
errors: [],
warnings: [],
checks: [],
model: {
type: 'unknown',
entryPoint: null,
archiveFileCount: archivePaths.length,
moc: null,
},
resources: {
textures: { discovered: 0, referenced: 0 },
motions: { discovered: 0, referenced: 0, parsed: 0 },
expressions: { discovered: 0, referenced: 0, parsed: 0 },
parameters: { parsed: 0, source: 'unavailable' },
},
issues: [],
}
// 1. Entry Point Identification
const model3Files = allPaths.filter(p => p.endsWith('.model3.json'))
if (model3Files.length > 0) {
report.entryPoint = model3Files[0]
report.structureType = 'Standard (model3.json)'
report.checks.push(`Entry point identified: ${report.entryPoint}`)
const settingsPaths = archivePaths.filter(path => path.toLowerCase().endsWith('.model3.json'))
const mocPaths = archivePaths.filter(path => path.toLowerCase().endsWith('.moc3'))
const texturePaths = archivePaths.filter(path => path.toLowerCase().endsWith('.png'))
const expressionPaths = archivePaths.filter(path => path.toLowerCase().endsWith('.exp3.json'))
const motionPaths = archivePaths.filter(path => path.toLowerCase().endsWith('.motion3.json'))
const displayInfoPaths = archivePaths.filter(path => path.toLowerCase().endsWith('.cdi3.json'))
report.resources.textures.discovered = texturePaths.length
report.resources.expressions.discovered = expressionPaths.length
report.resources.motions.discovered = motionPaths.length
report.resources.expressions.parsed = await countParsedJsonResources(zip, expressionPaths, 'expression', report)
report.resources.motions.parsed = await countParsedJsonResources(zip, motionPaths, 'motion', report)
let settings: Record<string, unknown> | undefined
let references: Record<string, unknown> | undefined
let settingsFileName = 'model3.json'
if (settingsPaths.length > 0) {
const entryPoint = settingsPaths[0]
report.model.type = 'model3'
report.model.entryPoint = entryPoint
settingsFileName = basename(entryPoint)
if (settingsPaths.length > 1) {
addIssue(
report,
'multiple-settings-files',
'warning',
`The archive contains ${settingsPaths.length} model settings files. AIRI will use ${settingsFileName}.`,
'Keep one model3.json file in each archive, or import each model as a separate ZIP.',
)
}
try {
settings = await readJsonObject(zip, entryPoint)
if (isRecord(settings.FileReferences))
references = settings.FileReferences
}
catch {
addIssue(
report,
'invalid-settings-json',
'error',
`The model settings file "${entryPoint}" is not valid JSON.`,
'Export the model settings again, or repair the JSON before import.',
)
}
}
else if (mocPaths.length === 1) {
report.model.type = 'moc3'
}
else {
const mocFiles = allPaths.filter(p => p.endsWith('.moc3'))
if (mocFiles.length === 1) {
report.structureType = 'Heuristic (Loose Files)'
report.checks.push(`Heuristic match found: Unique MOC file ${mocFiles[0]}`)
}
else {
report.errors.push(`Invalid Structure: No .model3.json found and ${mocFiles.length} .moc3 files encountered.`)
}
addIssue(
report,
'missing-settings-file',
'error',
`The archive has no model3.json file and contains ${mocPaths.length} MOC files.`,
'Add one model3.json file, or keep exactly one MOC3 file and its textures in the archive.',
)
}
// 2. MOC Header & Size Audit
const mocPath = allPaths.find(p => p.endsWith('.moc3'))
if (mocPath) {
const buf = await zip.file(mocPath)!.async('uint8array')
const header = String.fromCharCode(...buf.slice(0, 4))
const ver = buf[4]
const sizeMb = buf.length / 1024 / 1024
let mocPath: string | undefined
if (report.model.entryPoint && references) {
const mocReference = readString(references.Moc)
if (mocReference) {
const expectedPath = resolveArchivePath(report.model.entryPoint, mocReference)
if (checkReference(report, archivePaths, {
label: 'MOC',
reference: mocReference,
expectedPath,
settingsFileName,
severity: 'error',
})) {
mocPath = expectedPath
}
}
else {
addIssue(
report,
'missing-moc-reference',
'error',
`${settingsFileName} does not define a MOC file.`,
`Add FileReferences.Moc to ${settingsFileName}.`,
)
}
}
else if (!report.model.entryPoint && mocPaths.length === 1) {
mocPath = mocPaths[0]
}
report.mocInfo = { header, ver, size: buf.length }
if (mocPath) {
const moc = await zip.file(mocPath)!.async('uint8array')
const header = String.fromCharCode(...moc.slice(0, 4))
const version = moc[4] ?? 0
const sizeMb = moc.length / 1024 / 1024
report.model.moc = { path: mocPath, version, size: moc.length }
if (header !== 'MOC3') {
report.errors.push(`Invalid MOC Header: "${header}" (Expected MOC3)`)
addIssue(
report,
'invalid-moc-header',
'error',
`The MOC file "${mocPath}" does not have a valid MOC3 header.`,
'Export the MOC3 file again with the Live2D Cubism Editor.',
)
}
else {
report.checks.push(`MOC3 Header Valid (Sub-version: ${ver}, Size: ${sizeMb.toFixed(2)} MB)`)
}
if (sizeMb > 100) {
report.errors.push(`CRITICAL WEIGHT: MOC file is ${sizeMb.toFixed(2)} MB. This "Mega-Model" likely exceeds browser WASM memory limits.`)
addIssue(
report,
'moc-too-large',
'error',
`The MOC file is ${sizeMb.toFixed(2)} MB and exceeds the import limit.`,
'Reduce the model complexity or texture mesh density, then export the model again.',
)
}
else if (sizeMb > 30) {
report.warnings.push(`HEAVY RESOURCE: MOC file is ${sizeMb.toFixed(2)} MB. This may cause performance issues in web browsers.`)
addIssue(
report,
'moc-performance-risk',
'warning',
`The MOC file is ${sizeMb.toFixed(2)} MB and can reduce rendering performance.`,
'Reduce the model complexity or texture mesh density for better performance.',
)
}
}
// 3. Basename Collision Audit (AIRI ZipLoader weakness)
const basenames = new Map<string, string[]>()
allPaths.forEach((p) => {
if (p.endsWith('/'))
return // Skip directories
const base = p.split(/[\\/]/).pop()!
if (!basenames.has(base))
basenames.set(base, [])
basenames.get(base)!.push(p)
})
if (report.model.entryPoint && references) {
const entryPoint = report.model.entryPoint
const textureReferences = readStringArray(references.Textures)
const expressionReferences = readExpressionReferences(references.Expressions)
const motionReferences = readMotionReferences(references.Motions)
for (const [base, paths] of basenames.entries()) {
if (paths.length > 1) {
report.errors.push(`BASENAME COLLISION: Filename "${base}" exists in multiple locations: ${paths.join(', ')}. This causes data loss in AIRI's loader.`)
report.resources.textures.referenced = textureReferences.length
report.resources.expressions.referenced = expressionReferences.length
report.resources.motions.referenced = motionReferences.length
if (textureReferences.length === 0) {
addIssue(
report,
'missing-reference',
'error',
`${settingsFileName} does not define any textures.`,
`Add at least one texture path to FileReferences.Textures in ${settingsFileName}.`,
)
}
for (const reference of textureReferences) {
checkReference(report, archivePaths, {
label: 'texture',
reference,
expectedPath: resolveArchivePath(entryPoint, reference),
settingsFileName,
severity: 'error',
})
}
const optionalReferences: Array<{ label: string, reference: string }> = []
const physicsReference = readString(references.Physics)
const poseReference = readString(references.Pose)
if (physicsReference)
optionalReferences.push({ label: 'physics', reference: physicsReference })
if (poseReference)
optionalReferences.push({ label: 'pose', reference: poseReference })
for (const optionalReference of optionalReferences) {
checkReference(report, archivePaths, {
...optionalReference,
expectedPath: resolveArchivePath(entryPoint, optionalReference.reference),
settingsFileName,
severity: 'warning',
})
}
for (const reference of expressionReferences) {
checkReference(report, archivePaths, {
label: 'expression',
reference,
expectedPath: resolveArchivePath(entryPoint, reference),
settingsFileName,
severity: 'warning',
})
}
for (const reference of motionReferences) {
checkReference(report, archivePaths, {
label: 'motion',
reference,
expectedPath: resolveArchivePath(entryPoint, reference),
settingsFileName,
severity: 'warning',
})
}
const resolvedExpressionReferences = new Set(expressionReferences.map(reference => resolveArchivePath(entryPoint, reference)))
const resolvedMotionReferences = new Set(motionReferences.map(reference => resolveArchivePath(entryPoint, reference)))
const unreferencedExpressionCount = expressionPaths.filter(path => !resolvedExpressionReferences.has(path)).length
const unreferencedMotionCount = motionPaths.filter(path => !resolvedMotionReferences.has(path)).length
if (unreferencedExpressionCount > 0) {
const noun = unreferencedExpressionCount === 1 ? 'expression file is' : 'expression files are'
addIssue(
report,
'unreferenced-expressions',
'warning',
`${unreferencedExpressionCount} ${noun} not referenced by ${settingsFileName}.`,
`Add the files to FileReferences.Expressions in ${settingsFileName}, or remove the unused files.`,
)
}
if (unreferencedMotionCount > 0) {
const noun = unreferencedMotionCount === 1 ? 'motion file is' : 'motion files are'
addIssue(
report,
'unreferenced-motions',
'warning',
`${unreferencedMotionCount} ${noun} not referenced by ${settingsFileName}.`,
`Add the files to FileReferences.Motions in ${settingsFileName}, or remove the unused files.`,
)
}
const displayInfoReference = readString(references.DisplayInfo)
let displayInfoPath: string | undefined = displayInfoPaths[0]
if (displayInfoReference) {
const expectedPath = resolveArchivePath(entryPoint, displayInfoReference)
if (archivePaths.includes(expectedPath)) {
displayInfoPath = expectedPath
}
else {
addIssue(
report,
'missing-display-info',
'warning',
`The display information file "${displayInfoReference}" is missing.`,
`Add the file at "${expectedPath}", or remove DisplayInfo from ${settingsFileName}.`,
)
displayInfoPath = undefined
}
}
report.resources.parameters = await readParameterSummary(zip, displayInfoPath, report)
}
else {
report.resources.textures.referenced = texturePaths.length
report.resources.expressions.referenced = expressionPaths.length
report.resources.motions.referenced = motionPaths.length
report.resources.parameters = await readParameterSummary(zip, displayInfoPaths[0], report)
if (!report.model.entryPoint && mocPaths.length === 1 && texturePaths.length === 0) {
addIssue(
report,
'missing-reference',
'error',
'The loose model archive does not contain any textures.',
'Add the model textures to the archive, or export the model with a model3.json file.',
)
}
}
// 4. Detailed Reference Validation
if (report.entryPoint) {
try {
const content = await zip.file(report.entryPoint)!.async('text')
const json = JSON.parse(content)
const baseDir = report.entryPoint.split(/[\\/]/).slice(0, -1).join('/')
const resolve = (rel: string) => {
if (!rel)
return ''
const parts = baseDir ? [...baseDir.split('/'), ...rel.split(/[\\/]/)] : rel.split(/[\\/]/)
const stack: string[] = []
for (const p of parts) {
if (p === '.' || p === '')
continue
if (p === '..')
stack.pop()
else stack.push(p)
}
return stack.join('/')
}
const checkRef = (rel: string, type: string) => {
const full = resolve(rel)
if (!allPaths.includes(full)) {
// Check for case-insensitivity match to provide better error
const fuzzy = allPaths.find(p => p.toLowerCase() === full.toLowerCase())
if (fuzzy) {
report.errors.push(`CASE SENSITIVITY MISMATCH: "${rel}" expects "${full}" but ZIP contains "${fuzzy}". Browsers are case-sensitive.`)
}
else {
report.errors.push(`MISSING REFERENCE: ${type} "${rel}" (expected at "${full}") not found in ZIP.`)
}
}
}
const refs = json.FileReferences || {}
if (refs.Moc)
checkRef(refs.Moc, 'MOC')
if (Array.isArray(refs.Textures)) {
refs.Textures.forEach((t: string) => checkRef(t, 'Texture'))
}
if (refs.Physics)
checkRef(refs.Physics, 'Physics')
if (Array.isArray(refs.Expressions)) {
refs.Expressions.forEach((e: any) => checkRef(typeof e === 'string' ? e : e.File, 'Expression'))
}
}
catch (e: any) {
report.errors.push(`JSON PARSE ERROR: Failed to parse ${report.entryPoint}: ${e.message}`)
}
}
// 5. Final Status
if (report.errors.length > 0)
report.status = 'INVALID'
else if (report.warnings.length > 0)
report.status = 'WARNING'
else report.status = 'VALID'
updateStatus(report)
return report
}
@@ -1,200 +0,0 @@
<script setup lang="ts">
import type { Live2DValidationReport } from '@proj-airi/stage-ui-live2d'
import { Button, GhostButton } 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: Live2DValidationReport | null
}>()
const emits = defineEmits<{
(e: 'close'): void
(e: 'confirm'): void
(e: 'fixError', error: string): void
}>()
const showDialog = defineModel<boolean>('open', { default: false })
const isDesktop = useMediaQuery('(min-width: 768px)')
const screenSafeArea = useScreenSafeArea()
useResizeObserver(document.documentElement, () => screenSafeArea.update())
onMounted(() => screenSafeArea.update())
function handleConfirm() {
emits('confirm')
showDialog.value = false
}
function handleClose() {
emits('close')
showDialog.value = false
}
function canFixError(err: string) {
const e = err.toLowerCase()
return e.includes('preview') || e.includes('thumbnail') || e.includes('icon') || e.includes('expression')
}
function handleFix(err: string) {
emits('fixError', err)
}
</script>
<template>
<!-- Desktop Dialog -->
<DialogRoot v-if="isDesktop" :open="showDialog" @update:open="value => showDialog = 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] transform overflow-y-scroll rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
<div class="mb-4 flex items-center justify-between gap-2">
<DialogTitle class="text-lg text-neutral-900 font-semibold dark:text-neutral-100">
Live2D Model Audit Report
</DialogTitle>
<Button size="sm" @click="handleClose">
Close
</Button>
</div>
<div v-if="report" class="flex flex-col gap-4">
<!-- Report Header -->
<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 flex flex-col>
<span>Status: {{ report.status }}</span>
<span text-xs opacity-80>{{ report.fileName }}</span>
</div>
</div>
<!-- Body -->
<div class="max-h-96 overflow-y-auto pr-2 text-sm space-y-4">
<div class="grid grid-cols-2 gap-2 rounded bg-neutral-100/50 p-2 dark:bg-neutral-800/50">
<div>Structure: <span font-mono>{{ report.structureType }}</span></div>
<div>Files: <span font-mono>{{ report.totalFiles }}</span></div>
<div v-if="report.mocInfo" class="col-span-2 border-t border-neutral-200 pt-1 dark:border-neutral-700">
MOC3: <span font-mono>v{{ report.mocInfo.ver }}</span> ({{ (report.mocInfo.size / 1024 / 1024).toFixed(2) }} MB)
</div>
</div>
<div v-if="report.errors.length > 0" class="space-y-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 pl-0 space-y-1">
<li v-for="(err, i) in report.errors" :key="i" class="flex items-center justify-between gap-2 rounded bg-red-50/50 p-2 text-red-800 dark:bg-red-900/20 dark:text-red-300">
<span>{{ err }}</span>
<GhostButton v-if="canFixError(err)" size="sm" class="h-6 px-2 text-[10px] tracking-wider uppercase" @click="handleFix(err)">
Quick Fix
</GhostButton>
</li>
</ul>
</div>
<div v-if="report.warnings.length > 0" class="space-y-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 pl-0 space-y-1">
<li v-for="(w, i) in report.warnings" :key="i" class="rounded bg-yellow-50/50 p-2 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300">
{{ w }}
</li>
</ul>
</div>
</div>
<!-- Footer -->
<div class="mt-2 flex justify-end gap-2">
<Button @click="handleClose">
Cancel
</Button>
<Button v-if="report.status !== 'INVALID'" @click="handleConfirm">
{{ report.status === 'WARNING' ? 'Import Anyway' : 'Confirm Import' }}
</Button>
<div v-else class="flex items-center gap-1 text-xs text-red-500 italic">
<div i-solar:danger-triangle-bold /> Invalid models cannot be imported
</div>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
<!-- Mobile Drawer -->
<DrawerRoot v-else :open="showDialog" should-scale-background @update:open="value => showDialog = 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] mt-20 h-full max-h-[85%] flex flex-col rounded-t-2xl bg-neutral-50 px-4 pt-4 outline-none backdrop-blur-md 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">
Model Audit Report
</div>
<Button size="sm" @click="handleClose">
Close
</Button>
</div>
<div v-if="report" class="flex flex-1 flex-col gap-4 overflow-y-auto pb-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' : '',
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' : '',
]"
>
<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 />
<span>{{ report.status }}: {{ report.fileName.slice(0, 20) }}{{ report.fileName.length > 20 ? '...' : '' }}</span>
</div>
<div class="text-sm space-y-4">
<div v-if="report.errors.length > 0" class="space-y-1">
<ul class="list-none pl-0 space-y-1">
<li v-for="(err, i) in report.errors" :key="i" class="flex items-center justify-between gap-2 rounded bg-red-50/50 p-2 text-red-800 dark:bg-red-900/20 dark:text-red-300">
<span>{{ err }}</span>
<GhostButton v-if="canFixError(err)" size="sm" class="h-6 px-2 text-[10px] tracking-wider uppercase" @click="handleFix(err)">
Fix
</GhostButton>
</li>
</ul>
</div>
<div v-if="report.warnings.length > 0" class="space-y-1">
<ul class="list-none pl-0 space-y-1">
<li v-for="(w, i) in report.warnings" :key="i" class="rounded bg-yellow-50/50 p-2 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300">
{{ w }}
</li>
</ul>
</div>
</div>
<div class="mt-auto flex flex-col gap-2 pt-4">
<Button v-if="report.status !== 'INVALID'" @click="handleConfirm">
{{ report.status === 'WARNING' ? 'Import Anyway' : 'Confirm Import' }}
</Button>
<Button @click="handleClose">
Cancel
</Button>
</div>
</div>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</template>
@@ -1,197 +0,0 @@
<script setup lang="ts">
import type { Live2DValidationReport } from '@proj-airi/stage-ui-live2d'
import { Button } from '@proj-airi/ui'
import { reactive } from 'vue'
import Live2DReportModal from './Live2DReportModal.vue'
interface ReportVariantState {
open: boolean
events: string[]
}
const validReport: Live2DValidationReport = {
fileName: 'hiyori-production.model.zip',
totalFiles: 42,
status: 'VALID',
entryPoint: 'Hiyori/Hiyori.model3.json',
structureType: 'Standard (model3.json)',
errors: [],
warnings: [],
checks: [
'Entry point identified: Hiyori/Hiyori.model3.json',
'MOC3 Header Valid (Sub-version: 5, Size: 8.74 MB)',
],
mocInfo: {
header: 'MOC3',
ver: 5,
size: 9164554,
},
}
const warningReport: Live2DValidationReport = {
fileName: 'elena-concert-heavy.zip',
totalFiles: 186,
status: 'WARNING',
entryPoint: 'model/elena.model3.json',
structureType: 'Standard (model3.json)',
errors: [],
warnings: [
'HEAVY RESOURCE: MOC file is 45.28 MB. This may cause performance issues in web browsers.',
'Missing preview image. AIRI can still import this model, but the selector card will use a fallback preview.',
],
checks: [
'Entry point identified: model/elena.model3.json',
'MOC3 Header Valid (Sub-version: 5, Size: 45.28 MB)',
],
mocInfo: {
header: 'MOC3',
ver: 5,
size: 47479521,
},
}
const invalidReport: Live2DValidationReport = {
fileName: 'broken-archive.zip',
totalFiles: 17,
status: 'INVALID',
entryPoint: null,
structureType: 'Unknown',
errors: [
'Invalid Structure: No .model3.json found and 0 .moc3 files encountered.',
'Missing thumbnail referenced by model settings.',
'BASENAME COLLISION: Filename "texture_00.png" exists in multiple locations: model/textures/texture_00.png, model/expressions/texture_00.png. This causes data loss in AIRI\'s loader.',
],
warnings: [
'Archive contains loose files at the root. Put the model files in one folder before zipping.',
],
checks: [],
}
const validState = reactive<ReportVariantState>({ open: true, events: [] })
const warningState = reactive<ReportVariantState>({ open: true, events: [] })
const invalidState = reactive<ReportVariantState>({ open: true, events: [] })
function recordEvent(state: ReportVariantState, event: string) {
state.events = [event, ...state.events].slice(0, 4)
}
</script>
<template>
<Story
title="Dialogs / Live2D Report Modal"
group="dialogs"
>
<Variant
id="valid"
title="Valid Report"
>
<div
:class="[
'mx-auto max-w-xl p-4',
'flex flex-col gap-3',
]"
>
<Button @click="validState.open = true">
Open Valid Report
</Button>
<div
v-if="validState.events.length > 0"
:class="[
'rounded-lg bg-neutral-100/70 p-3 text-xs text-neutral-600',
'dark:bg-neutral-900/70 dark:text-neutral-300',
]"
>
<div
v-for="event in validState.events"
:key="event"
>
{{ event }}
</div>
</div>
<Live2DReportModal
v-model:open="validState.open"
:report="validReport"
@close="recordEvent(validState, 'close')"
@confirm="recordEvent(validState, 'confirm')"
@fix-error="error => recordEvent(validState, `fixError: ${error}`)"
/>
</div>
</Variant>
<Variant
id="warning"
title="Warning Report"
>
<div
:class="[
'mx-auto max-w-xl p-4',
'flex flex-col gap-3',
]"
>
<Button @click="warningState.open = true">
Open Warning Report
</Button>
<div
v-if="warningState.events.length > 0"
:class="[
'rounded-lg bg-neutral-100/70 p-3 text-xs text-neutral-600',
'dark:bg-neutral-900/70 dark:text-neutral-300',
]"
>
<div
v-for="event in warningState.events"
:key="event"
>
{{ event }}
</div>
</div>
<Live2DReportModal
v-model:open="warningState.open"
:report="warningReport"
@close="recordEvent(warningState, 'close')"
@confirm="recordEvent(warningState, 'confirm')"
@fix-error="error => recordEvent(warningState, `fixError: ${error}`)"
/>
</div>
</Variant>
<Variant
id="invalid"
title="Invalid Report"
>
<div
:class="[
'mx-auto max-w-xl p-4',
'flex flex-col gap-3',
]"
>
<Button @click="invalidState.open = true">
Open Invalid Report
</Button>
<div
v-if="invalidState.events.length > 0"
:class="[
'rounded-lg bg-neutral-100/70 p-3 text-xs text-neutral-600',
'dark:bg-neutral-900/70 dark:text-neutral-300',
]"
>
<div
v-for="event in invalidState.events"
:key="event"
>
{{ event }}
</div>
</div>
<Live2DReportModal
v-model:open="invalidState.open"
:report="invalidReport"
@close="recordEvent(invalidState, 'close')"
@confirm="recordEvent(invalidState, 'confirm')"
@fix-error="error => recordEvent(invalidState, `fixError: ${error}`)"
/>
</div>
</Variant>
</Story>
</template>
@@ -13,7 +13,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenu
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Live2DReportModal from './Live2DReportModal.vue'
import Live2DReportModal from './reports/live2d/modal.vue'
import TachieReportModal from './tachieReportModal.vue'
import { DisplayModelFormat, useDisplayModelsStore } from '../../../../stores/display-models'
@@ -64,7 +64,7 @@ async function handleAddLive2DModel(file: FileList | null) {
validationReport.value = report
pendingFile.value = file[0]
if (report.status === 'VALID' && report.errors.length === 0) {
if (report.status === 'VALID') {
await confirmImport()
return
}
@@ -117,10 +117,6 @@ async function confirmTachieImport() {
tachieValidationReport.value = null
}
function handleFixError(error: string) {
void error
}
function handlePick(m: DisplayModel) {
highlightDisplayModelCard.value = m.id
emits('pick', m)
@@ -223,7 +219,6 @@ mmdDialog.onChange(handleAddMMDModel)
v-model:open="showReportModal"
:report="validationReport"
@confirm="confirmImport"
@fix-error="handleFixError"
/>
<TachieReportModal
v-model:open="showTachieReportModal"
@@ -0,0 +1,338 @@
<script setup lang="ts">
import type { Live2DValidationIssue, Live2DValidationReport } from '@proj-airi/stage-ui-live2d'
import { Button } from '@proj-airi/ui'
import { TooltipArrow, TooltipContent, TooltipPortal, TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
import { DrawerContent, DrawerDescription, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRootNested, DrawerTitle } from 'vaul-vue'
import { computed, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useBreakpoints } from '../../../../../../composables/use-breakpoints'
const props = defineProps<{
report: Live2DValidationReport
}>()
const emits = defineEmits<{
(event: 'close'): void
(event: 'confirm'): void
}>()
/** The MOC3 header stores a format enum, not the Cubism version number itself. */
const cubismVersionByMocVersion: Readonly<Record<number, string>> = {
1: '3',
2: '3.3',
3: '4',
4: '4.2',
5: '5',
}
const { t } = useI18n()
const { isDesktop } = useBreakpoints()
const errorCount = computed(() => props.report.issues.filter(issue => issue.severity === 'error').length)
const warningCount = computed(() => props.report.issues.filter(issue => issue.severity === 'warning').length)
const modelTarget = computed(() => props.report.model.entryPoint ?? props.report.model.moc?.path ?? null)
const activeIssue = shallowRef<Live2DValidationIssue | null>(null)
const showIssueDrawer = shallowRef(false)
const modelTypeLabel = computed(() => {
if (props.report.model.type === 'unknown')
return t('settings.model-select.live2d-report.model.types.unknown')
const format = props.report.model.type === 'model3' ? '.model3.json' : '.moc3'
const mocVersion = props.report.model.moc?.version
const cubismVersion = mocVersion === undefined ? undefined : cubismVersionByMocVersion[mocVersion]
if (cubismVersion !== undefined)
return t('settings.model-select.live2d-report.model.types.cubism', { version: cubismVersion, format })
return t('settings.model-select.live2d-report.model.types.cubism-compatible', { format })
})
function formatMocSize(size: number): string {
const sizeMb = size / 1024 / 1024
if (sizeMb >= 0.01)
return `${sizeMb.toFixed(2)} MB`
return `${size} B`
}
function handleIssueClick(issue: Live2DValidationIssue) {
if (isDesktop.value)
return
activeIssue.value = issue
showIssueDrawer.value = true
}
watch(isDesktop, (desktop) => {
if (!desktop)
return
showIssueDrawer.value = false
activeIssue.value = null
})
</script>
<template>
<div class="min-h-0 flex flex-1 flex-col">
<div class="flex flex-col gap-5 overflow-y-auto pr-1 scrollbar-none">
<div class="flex flex-col gap-3">
<dl
:class="[
'rounded-xl p-3 text-sm',
'bg-neutral-100/70 dark:bg-neutral-800/60',
]"
>
<div>
<div class="flex items-center justify-between gap-3">
<dt class="text-xs text-neutral-500 dark:text-neutral-400">
{{ t('settings.model-select.live2d-report.model.title') }}
</dt>
<div class="text-xs text-neutral-500 font-medium dark:text-neutral-400">
{{ modelTypeLabel }}
</div>
</div>
<dd class="mt-2 min-w-0 break-all text-neutral-800 dark:text-neutral-100" :title="modelTarget ?? undefined">
{{ modelTarget ?? '—' }}
<div class="mt-1.5 flex items-center gap-2 text-xs text-neutral-500 tabular-nums dark:text-neutral-400">
<span>{{ t('settings.model-select.live2d-report.model.files', { count: report.model.archiveFileCount }) }}</span>
<span v-if="report.model.moc">{{ formatMocSize(report.model.moc.size) }}</span>
</div>
</dd>
</div>
</dl>
<h3 class="text-sm text-neutral-900 font-semibold dark:text-neutral-100">
{{ t('settings.model-select.live2d-report.resources.title') }}
</h3>
<dl class="grid grid-cols-4 gap-1.5 sm:gap-2">
<div :class="['rounded-lg p-2 sm:rounded-xl sm:p-3', 'bg-neutral-100/70 dark:bg-neutral-800/60']">
<dt class="text-[10px] text-neutral-500 sm:text-xs dark:text-neutral-400">
{{ t('settings.model-select.live2d-report.resources.motions') }}
</dt>
<dd class="mt-0.5 text-lg text-neutral-900 font-semibold tabular-nums sm:mt-1 sm:text-xl dark:text-neutral-100">
{{ report.resources.motions.parsed }}
</dd>
<div class="mt-0.5 text-[11px] text-neutral-500 hidden sm:block dark:text-neutral-400">
{{ t('settings.model-select.live2d-report.resources.referenced-and-found', { referenced: report.resources.motions.referenced, found: report.resources.motions.discovered }) }}
</div>
</div>
<div :class="['rounded-lg p-2 sm:rounded-xl sm:p-3', 'bg-neutral-100/70 dark:bg-neutral-800/60']">
<dt class="text-[10px] text-neutral-500 sm:text-xs dark:text-neutral-400">
{{ t('settings.model-select.live2d-report.resources.expressions') }}
</dt>
<dd class="mt-0.5 text-lg text-neutral-900 font-semibold tabular-nums sm:mt-1 sm:text-xl dark:text-neutral-100">
{{ report.resources.expressions.parsed }}
</dd>
<div class="mt-0.5 text-[11px] text-neutral-500 hidden sm:block dark:text-neutral-400">
{{ t('settings.model-select.live2d-report.resources.referenced-and-found', { referenced: report.resources.expressions.referenced, found: report.resources.expressions.discovered }) }}
</div>
</div>
<div :class="['rounded-lg p-2 sm:rounded-xl sm:p-3', 'bg-neutral-100/70 dark:bg-neutral-800/60']">
<dt class="text-[10px] text-neutral-500 sm:text-xs dark:text-neutral-400">
{{ t('settings.model-select.live2d-report.resources.parameters') }}
</dt>
<dd class="mt-0.5 text-lg text-neutral-900 font-semibold tabular-nums sm:mt-1 sm:text-xl dark:text-neutral-100">
{{ report.resources.parameters.parsed }}
</dd>
<div class="mt-0.5 text-[11px] text-neutral-500 hidden sm:block dark:text-neutral-400">
{{ t('settings.model-select.live2d-report.resources.found', { count: report.resources.parameters.parsed }) }}
</div>
</div>
<div :class="['rounded-lg p-2 sm:rounded-xl sm:p-3', 'bg-neutral-100/70 dark:bg-neutral-800/60']">
<dt class="text-[10px] text-neutral-500 sm:text-xs dark:text-neutral-400">
{{ t('settings.model-select.live2d-report.resources.textures') }}
</dt>
<dd class="mt-0.5 text-lg text-neutral-900 font-semibold tabular-nums sm:mt-1 sm:text-xl dark:text-neutral-100">
{{ report.resources.textures.discovered }}
</dd>
<div class="mt-0.5 text-[11px] text-neutral-500 hidden sm:block dark:text-neutral-400">
{{ t('settings.model-select.live2d-report.resources.referenced', { count: report.resources.textures.referenced }) }}
</div>
</div>
</dl>
</div>
<section class="flex flex-col gap-3" aria-labelledby="live2d-report-issues-title">
<div class="flex items-center justify-between gap-3">
<h3 id="live2d-report-issues-title" class="text-sm text-neutral-900 font-semibold dark:text-neutral-100">
{{ t('settings.model-select.live2d-report.issues.title') }}
</h3>
<div
:class="[
'rounded-full px-2.5 py-1 text-[11px] text-neutral-600 tabular-nums dark:text-neutral-300',
'flex items-center gap-2',
'bg-neutral-100/80 dark:bg-neutral-800/80',
]"
>
<span class="flex items-center gap-1">
<span class="i-mingcute:close-circle-fill text-red-500 dark:text-red-400" aria-hidden="true" />
{{ t('settings.model-select.live2d-report.issues.errors', { count: errorCount }) }}
</span>
<span class="h-3 w-px bg-neutral-300 dark:bg-neutral-600" aria-hidden="true" />
<span class="flex items-center gap-1">
<span class="i-mingcute:warning-fill text-amber-500 dark:text-amber-400" aria-hidden="true" />
{{ t('settings.model-select.live2d-report.issues.warnings', { count: warningCount }) }}
</span>
</div>
</div>
<div
v-if="report.issues.length === 0"
:class="[
'rounded-lg px-3 py-2.5 text-sm text-neutral-700 dark:text-neutral-200',
'flex items-center gap-2',
'bg-neutral-100/70 dark:bg-neutral-800/60',
]"
>
<span class="i-mingcute:check-circle-fill text-base text-emerald-500 dark:text-emerald-400" aria-hidden="true" />
<span>{{ t('settings.model-select.live2d-report.issues.none') }}</span>
</div>
<TooltipProvider v-else :delay-duration="250">
<div class="flex flex-col gap-1.5">
<TooltipRoot
v-for="issue in report.issues"
:key="`${issue.code}-${issue.message}`"
:disabled="!isDesktop"
>
<TooltipTrigger as-child>
<button
type="button"
:class="[
'w-full rounded-lg px-3 py-2.5 text-left text-sm text-neutral-700 outline-none dark:text-neutral-200',
'flex items-center gap-2.5',
'bg-neutral-100/70 transition-colors dark:bg-neutral-800/60',
'hover:bg-neutral-200/70 dark:hover:bg-neutral-700/60',
'focus-visible:ring-2 focus-visible:ring-neutral-400',
isDesktop ? 'cursor-help' : 'active:bg-neutral-200/70 dark:active:bg-neutral-700/60',
]"
@click="handleIssueClick(issue)"
>
<span
:class="[
'text-base',
issue.severity === 'error'
? 'i-mingcute:close-circle-fill text-red-500 dark:text-red-400'
: 'i-mingcute:warning-fill text-amber-500 dark:text-amber-400',
]"
aria-hidden="true"
/>
<span class="min-w-0 flex-1">
{{ issue.message }}
</span>
</button>
</TooltipTrigger>
<TooltipPortal v-if="isDesktop">
<TooltipContent
side="right"
:side-offset="8"
:class="[
'live2d-report-tooltip z-[10001] max-w-64 rounded-lg px-3 py-2.5 text-sm shadow-lg outline-none',
'bg-neutral-900 text-neutral-50',
]"
>
<div>
<div class="mb-1 text-xs font-semibold tracking-wide opacity-60">
{{ t('settings.model-select.live2d-report.issues.how-to-fix') }}
</div>
<div>
{{ issue.resolution }}
</div>
</div>
<TooltipArrow class="fill-neutral-900" />
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</div>
</TooltipProvider>
<DrawerRootNested
v-if="!isDesktop"
v-model:open="showIssueDrawer"
>
<DrawerPortal>
<DrawerOverlay class="fixed inset-0 z-[10000] bg-black/40" />
<DrawerContent
:class="[
'fixed bottom-0 left-0 right-0 z-[10001] max-h-[70dvh]',
'overflow-y-auto rounded-t-2xl bg-neutral-50 px-4 pt-3 shadow-xl outline-none dark:bg-neutral-900',
]"
:style="{ paddingBottom: 'max(1.5rem, env(safe-area-inset-bottom, 0px))' }"
>
<DrawerHandle />
<div v-if="activeIssue" class="mt-3">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-xs text-neutral-500 font-semibold dark:text-neutral-400">
<span
:class="[
'text-base',
activeIssue.severity === 'error'
? 'i-mingcute:close-circle-fill text-red-500 dark:text-red-400'
: 'i-mingcute:warning-fill text-amber-500 dark:text-amber-400',
]"
aria-hidden="true"
/>
{{ t('settings.model-select.live2d-report.issues.how-to-fix') }}
</div>
<Button size="sm" @click="showIssueDrawer = false">
{{ t('settings.model-select.live2d-report.close') }}
</Button>
</div>
<DrawerTitle class="mt-4 text-base text-neutral-900 font-semibold dark:text-neutral-100">
{{ activeIssue.message }}
</DrawerTitle>
<DrawerDescription class="mt-2 text-sm text-neutral-600 dark:text-neutral-300">
{{ activeIssue.resolution }}
</DrawerDescription>
</div>
</DrawerContent>
</DrawerPortal>
</DrawerRootNested>
</section>
</div>
<div
:class="[
'mt-5 border-t border-neutral-200 pt-4 dark:border-neutral-800',
'flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end',
]"
>
<div
v-if="report.status === 'INVALID'"
class="mr-auto flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400"
>
<span class="i-mingcute:warning-fill text-sm" aria-hidden="true" />
{{ t('settings.model-select.live2d-report.issues.resolve-before-import') }}
</div>
<Button @click="emits('close')">
{{ t('settings.model-select.live2d-report.actions.cancel') }}
</Button>
<Button v-if="report.status !== 'INVALID'" @click="emits('confirm')">
{{ report.status === 'WARNING' ? t('settings.model-select.live2d-report.actions.import-anyway') : t('settings.model-select.live2d-report.actions.confirm') }}
</Button>
</div>
</div>
</template>
<style scoped>
:global(.live2d-report-tooltip[data-state='delayed-open']),
:global(.live2d-report-tooltip[data-state='instant-open']) {
animation: live2d-report-tooltip-fade-in 200ms ease-out;
}
@keyframes live2d-report-tooltip-fade-in {
from {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
:global(.live2d-report-tooltip[data-state]) {
animation: none;
}
}
</style>
@@ -0,0 +1,192 @@
<script setup lang="ts">
import type { Live2DValidationReport } from '@proj-airi/stage-ui-live2d'
import { Button } from '@proj-airi/ui'
import { reactive } from 'vue'
import ReportModal from './modal.vue'
interface ReportVariantState {
open: boolean
events: string[]
}
const validReport: Live2DValidationReport = {
fileName: 'hiyori-production.model.zip',
status: 'VALID',
model: {
type: 'model3',
entryPoint: 'Hiyori/Hiyori.model3.json',
archiveFileCount: 42,
moc: { path: 'Hiyori/Hiyori.moc3', version: 5, size: 9164554 },
},
resources: {
textures: { discovered: 4, referenced: 4 },
motions: { discovered: 12, referenced: 12, parsed: 12 },
expressions: { discovered: 8, referenced: 8, parsed: 8 },
parameters: { parsed: 67, source: 'display-info' },
},
issues: [],
}
const warningReport: Live2DValidationReport = {
fileName: 'elena-concert-heavy.zip',
status: 'WARNING',
model: {
type: 'model3',
entryPoint: 'model/elena.model3.json',
archiveFileCount: 186,
moc: { path: 'model/elena.moc3', version: 5, size: 47479521 },
},
resources: {
textures: { discovered: 8, referenced: 8 },
motions: { discovered: 20, referenced: 16, parsed: 20 },
expressions: { discovered: 14, referenced: 12, parsed: 14 },
parameters: { parsed: 93, source: 'display-info' },
},
issues: [
{
code: 'moc-performance-risk',
severity: 'warning',
message: 'The MOC file is 45.28 MB and can reduce rendering performance.',
resolution: 'Reduce the model complexity or texture mesh density for better performance.',
},
{
code: 'unreferenced-expressions',
severity: 'warning',
message: '2 expression files are not referenced by elena.model3.json.',
resolution: 'Add the files to FileReferences.Expressions in elena.model3.json, or remove the unused files.',
},
{
code: 'unreferenced-motions',
severity: 'warning',
message: '4 motion files are not referenced by elena.model3.json.',
resolution: 'Add the files to FileReferences.Motions in elena.model3.json, or remove the unused files.',
},
],
}
const invalidReport: Live2DValidationReport = {
fileName: 'broken-archive.zip',
status: 'INVALID',
model: {
type: 'model3',
entryPoint: 'model/broken.model3.json',
archiveFileCount: 17,
moc: null,
},
resources: {
textures: { discovered: 1, referenced: 2 },
motions: { discovered: 3, referenced: 3, parsed: 3 },
expressions: { discovered: 6, referenced: 6, parsed: 6 },
parameters: { parsed: 0, source: 'unavailable' },
},
issues: [
{
code: 'missing-reference',
severity: 'error',
message: 'The referenced MOC file "broken.moc3" is missing.',
resolution: 'Add the file at "model/broken.moc3", or update the MOC path in broken.model3.json.',
},
{
code: 'missing-reference',
severity: 'error',
message: 'The referenced texture file "textures/texture_01.png" is missing.',
resolution: 'Add the file at "model/textures/texture_01.png", or update the texture path in broken.model3.json.',
},
{
code: 'missing-display-info',
severity: 'warning',
message: 'The display information file "broken.cdi3.json" is missing.',
resolution: 'Add the file at "model/broken.cdi3.json", or remove DisplayInfo from broken.model3.json.',
},
],
}
const validState = reactive<ReportVariantState>({ open: true, events: [] })
const warningState = reactive<ReportVariantState>({ open: true, events: [] })
const invalidState = reactive<ReportVariantState>({ open: true, events: [] })
function recordEvent(state: ReportVariantState, event: string) {
state.events = [event, ...state.events].slice(0, 4)
}
</script>
<template>
<Story title="Dialogs / Live2D Report Modal" group="dialogs">
<Variant id="valid" title="Valid Report">
<div :class="['mx-auto max-w-xl p-4', 'flex flex-col gap-3']">
<Button @click="validState.open = true">
Open Valid Report
</Button>
<div
v-if="validState.events.length > 0"
:class="[
'rounded-lg bg-neutral-100/70 p-3 text-xs text-neutral-600',
'dark:bg-neutral-900/70 dark:text-neutral-300',
]"
>
<div v-for="event in validState.events" :key="event">
{{ event }}
</div>
</div>
<ReportModal
v-model:open="validState.open"
:report="validReport"
@close="recordEvent(validState, 'close')"
@confirm="recordEvent(validState, 'confirm')"
/>
</div>
</Variant>
<Variant id="warning" title="Warning Report">
<div :class="['mx-auto max-w-xl p-4', 'flex flex-col gap-3']">
<Button @click="warningState.open = true">
Open Warning Report
</Button>
<div
v-if="warningState.events.length > 0"
:class="[
'rounded-lg bg-neutral-100/70 p-3 text-xs text-neutral-600',
'dark:bg-neutral-900/70 dark:text-neutral-300',
]"
>
<div v-for="event in warningState.events" :key="event">
{{ event }}
</div>
</div>
<ReportModal
v-model:open="warningState.open"
:report="warningReport"
@close="recordEvent(warningState, 'close')"
@confirm="recordEvent(warningState, 'confirm')"
/>
</div>
</Variant>
<Variant id="invalid" title="Invalid Report">
<div :class="['mx-auto max-w-xl p-4', 'flex flex-col gap-3']">
<Button @click="invalidState.open = true">
Open Invalid Report
</Button>
<div
v-if="invalidState.events.length > 0"
:class="[
'rounded-lg bg-neutral-100/70 p-3 text-xs text-neutral-600',
'dark:bg-neutral-900/70 dark:text-neutral-300',
]"
>
<div v-for="event in invalidState.events" :key="event">
{{ event }}
</div>
</div>
<ReportModal
v-model:open="invalidState.open"
:report="invalidReport"
@close="recordEvent(invalidState, 'close')"
@confirm="recordEvent(invalidState, 'confirm')"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { Live2DValidationReport } from '@proj-airi/stage-ui-live2d'
import { Button } from '@proj-airi/ui'
import { useMediaQuery, useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot } from 'vaul-vue'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import ReportContent from './content.vue'
defineProps<{
report: Live2DValidationReport | null
}>()
const emits = defineEmits<{
(event: 'close'): void
(event: 'confirm'): void
}>()
const showDialog = defineModel<boolean>('open', { default: false })
const { t } = useI18n()
const isDesktop = useMediaQuery('(min-width: 768px)')
const screenSafeArea = useScreenSafeArea()
useResizeObserver(document.documentElement, () => screenSafeArea.update())
onMounted(() => screenSafeArea.update())
function handleConfirm() {
emits('confirm')
showDialog.value = false
}
function handleClose() {
emits('close')
showDialog.value = false
}
</script>
<template>
<DialogRoot v-if="isDesktop" :open="showDialog" @update:open="value => showDialog = 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-[88dvh] max-w-2xl w-[92dvw] -translate-x-1/2 -translate-y-1/2',
'flex flex-col overflow-hidden rounded-2xl bg-white p-6 shadow-xl outline-none dark:bg-neutral-900',
'data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow',
]"
>
<div class="mb-4 flex items-start justify-between gap-3">
<div>
<DialogTitle class="text-lg text-neutral-900 font-semibold dark:text-neutral-100">
{{ t('settings.model-select.live2d-report.title') }}
</DialogTitle>
<DialogDescription class="mt-0.5 text-sm text-neutral-500 dark:text-neutral-400">
{{ t('settings.model-select.live2d-report.description') }}
</DialogDescription>
</div>
<Button size="sm" @click="handleClose">
{{ t('settings.model-select.live2d-report.close') }}
</Button>
</div>
<ReportContent
v-if="report"
:report="report"
@close="handleClose"
@confirm="handleConfirm"
/>
</DialogContent>
</DialogPortal>
</DialogRoot>
<DrawerRoot v-else :open="showDialog" should-scale-background @update:open="value => showDialog = 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] mt-20 max-h-[90%]',
'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-start justify-between gap-3">
<div>
<div class="text-lg text-neutral-900 font-semibold dark:text-neutral-100">
{{ t('settings.model-select.live2d-report.title') }}
</div>
<div class="mt-0.5 text-sm text-neutral-500 dark:text-neutral-400">
{{ t('settings.model-select.live2d-report.description-short') }}
</div>
</div>
<Button size="sm" @click="handleClose">
{{ t('settings.model-select.live2d-report.close') }}
</Button>
</div>
<ReportContent
v-if="report"
:report="report"
@close="handleClose"
@confirm="handleConfirm"
/>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</template>