feat(stage-ui): improve Live2D import diagnostics (#2406)
This commit is contained in:
@@ -129,6 +129,38 @@ model-select:
|
|||||||
import: Import
|
import: Import
|
||||||
remove: Remove
|
remove: Remove
|
||||||
confirm: Confirm
|
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:
|
live2d:
|
||||||
change-model:
|
change-model:
|
||||||
from-file: Load from File
|
from-file: Load from File
|
||||||
|
|||||||
@@ -117,6 +117,38 @@ model-select:
|
|||||||
import: 导入
|
import: 导入
|
||||||
remove: 移除
|
remove: 移除
|
||||||
confirm: 确认
|
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:
|
live2d:
|
||||||
change-model:
|
change-model:
|
||||||
from-file: 从文件加载
|
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'
|
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 {
|
export interface Live2DValidationReport {
|
||||||
fileName: string
|
fileName: string
|
||||||
totalFiles: number
|
status: Live2DValidationStatus
|
||||||
status: 'VALID' | 'WARNING' | 'INVALID'
|
model: Live2DModelSummary
|
||||||
entryPoint: string | null
|
resources: Live2DResourceSummary
|
||||||
structureType: 'Standard (model3.json)' | 'Heuristic (Loose Files)' | 'Unknown'
|
issues: Live2DValidationIssue[]
|
||||||
errors: string[]
|
}
|
||||||
warnings: string[]
|
|
||||||
checks: string[]
|
interface ReferenceCheckOptions {
|
||||||
mocInfo?: {
|
label: string
|
||||||
header: string
|
reference: string
|
||||||
ver: number
|
expectedPath: string
|
||||||
size: number
|
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> {
|
export async function validateLive2DZip(file: File | Blob): Promise<Live2DValidationReport> {
|
||||||
const zip = await JSZip.loadAsync(file, { decodeFileName: decodeZipFileName })
|
const zip = await JSZip.loadAsync(await file.arrayBuffer(), { decodeFileName: decodeZipFileName })
|
||||||
const allPaths = Object.keys(zip.files)
|
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 = {
|
const report: Live2DValidationReport = {
|
||||||
fileName: (file as File).name || 'live2d-model.zip',
|
fileName,
|
||||||
totalFiles: allPaths.length,
|
|
||||||
status: 'VALID',
|
status: 'VALID',
|
||||||
entryPoint: null,
|
model: {
|
||||||
structureType: 'Unknown',
|
type: 'unknown',
|
||||||
errors: [],
|
entryPoint: null,
|
||||||
warnings: [],
|
archiveFileCount: archivePaths.length,
|
||||||
checks: [],
|
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 settingsPaths = archivePaths.filter(path => path.toLowerCase().endsWith('.model3.json'))
|
||||||
const model3Files = allPaths.filter(p => p.endsWith('.model3.json'))
|
const mocPaths = archivePaths.filter(path => path.toLowerCase().endsWith('.moc3'))
|
||||||
if (model3Files.length > 0) {
|
const texturePaths = archivePaths.filter(path => path.toLowerCase().endsWith('.png'))
|
||||||
report.entryPoint = model3Files[0]
|
const expressionPaths = archivePaths.filter(path => path.toLowerCase().endsWith('.exp3.json'))
|
||||||
report.structureType = 'Standard (model3.json)'
|
const motionPaths = archivePaths.filter(path => path.toLowerCase().endsWith('.motion3.json'))
|
||||||
report.checks.push(`Entry point identified: ${report.entryPoint}`)
|
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 {
|
else {
|
||||||
const mocFiles = allPaths.filter(p => p.endsWith('.moc3'))
|
addIssue(
|
||||||
if (mocFiles.length === 1) {
|
report,
|
||||||
report.structureType = 'Heuristic (Loose Files)'
|
'missing-settings-file',
|
||||||
report.checks.push(`Heuristic match found: Unique MOC file ${mocFiles[0]}`)
|
'error',
|
||||||
}
|
`The archive has no model3.json file and contains ${mocPaths.length} MOC files.`,
|
||||||
else {
|
'Add one model3.json file, or keep exactly one MOC3 file and its textures in the archive.',
|
||||||
report.errors.push(`Invalid Structure: No .model3.json found and ${mocFiles.length} .moc3 files encountered.`)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. MOC Header & Size Audit
|
let mocPath: string | undefined
|
||||||
const mocPath = allPaths.find(p => p.endsWith('.moc3'))
|
if (report.model.entryPoint && references) {
|
||||||
if (mocPath) {
|
const mocReference = readString(references.Moc)
|
||||||
const buf = await zip.file(mocPath)!.async('uint8array')
|
if (mocReference) {
|
||||||
const header = String.fromCharCode(...buf.slice(0, 4))
|
const expectedPath = resolveArchivePath(report.model.entryPoint, mocReference)
|
||||||
const ver = buf[4]
|
if (checkReference(report, archivePaths, {
|
||||||
const sizeMb = buf.length / 1024 / 1024
|
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') {
|
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) {
|
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) {
|
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)
|
if (report.model.entryPoint && references) {
|
||||||
const basenames = new Map<string, string[]>()
|
const entryPoint = report.model.entryPoint
|
||||||
allPaths.forEach((p) => {
|
const textureReferences = readStringArray(references.Textures)
|
||||||
if (p.endsWith('/'))
|
const expressionReferences = readExpressionReferences(references.Expressions)
|
||||||
return // Skip directories
|
const motionReferences = readMotionReferences(references.Motions)
|
||||||
const base = p.split(/[\\/]/).pop()!
|
|
||||||
if (!basenames.has(base))
|
|
||||||
basenames.set(base, [])
|
|
||||||
basenames.get(base)!.push(p)
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const [base, paths] of basenames.entries()) {
|
report.resources.textures.referenced = textureReferences.length
|
||||||
if (paths.length > 1) {
|
report.resources.expressions.referenced = expressionReferences.length
|
||||||
report.errors.push(`BASENAME COLLISION: Filename "${base}" exists in multiple locations: ${paths.join(', ')}. This causes data loss in AIRI's loader.`)
|
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
|
updateStatus(report)
|
||||||
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'
|
|
||||||
|
|
||||||
return 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 { ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import Live2DReportModal from './Live2DReportModal.vue'
|
import Live2DReportModal from './reports/live2d/modal.vue'
|
||||||
import TachieReportModal from './tachieReportModal.vue'
|
import TachieReportModal from './tachieReportModal.vue'
|
||||||
|
|
||||||
import { DisplayModelFormat, useDisplayModelsStore } from '../../../../stores/display-models'
|
import { DisplayModelFormat, useDisplayModelsStore } from '../../../../stores/display-models'
|
||||||
@@ -64,7 +64,7 @@ async function handleAddLive2DModel(file: FileList | null) {
|
|||||||
validationReport.value = report
|
validationReport.value = report
|
||||||
pendingFile.value = file[0]
|
pendingFile.value = file[0]
|
||||||
|
|
||||||
if (report.status === 'VALID' && report.errors.length === 0) {
|
if (report.status === 'VALID') {
|
||||||
await confirmImport()
|
await confirmImport()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -117,10 +117,6 @@ async function confirmTachieImport() {
|
|||||||
tachieValidationReport.value = null
|
tachieValidationReport.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFixError(error: string) {
|
|
||||||
void error
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePick(m: DisplayModel) {
|
function handlePick(m: DisplayModel) {
|
||||||
highlightDisplayModelCard.value = m.id
|
highlightDisplayModelCard.value = m.id
|
||||||
emits('pick', m)
|
emits('pick', m)
|
||||||
@@ -223,7 +219,6 @@ mmdDialog.onChange(handleAddMMDModel)
|
|||||||
v-model:open="showReportModal"
|
v-model:open="showReportModal"
|
||||||
:report="validationReport"
|
:report="validationReport"
|
||||||
@confirm="confirmImport"
|
@confirm="confirmImport"
|
||||||
@fix-error="handleFixError"
|
|
||||||
/>
|
/>
|
||||||
<TachieReportModal
|
<TachieReportModal
|
||||||
v-model:open="showTachieReportModal"
|
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