feat(stage-ui): improve Live2D import diagnostics (#2406)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
-200
@@ -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>
|
||||
-197
@@ -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>
|
||||
+2
-7
@@ -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"
|
||||
|
||||
+338
@@ -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>
|
||||
+192
@@ -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>
|
||||
+116
@@ -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>
|
||||
Reference in New Issue
Block a user