From 0977bcd741d3a53655704fd1f26fb297be1dde83 Mon Sep 17 00:00:00 2001 From: Neko Date: Sun, 30 Aug 2026 01:48:37 +0800 Subject: [PATCH] feat(stage-ui): improve Live2D import diagnostics (#2406) --- packages/i18n/src/locales/en/settings.yaml | 32 + .../i18n/src/locales/zh-Hans/settings.yaml | 32 + .../src/utils/live2d-validator.test.ts | 175 +++++ .../src/utils/live2d-validator.ts | 674 ++++++++++++++---- .../model-selector/Live2DReportModal.vue | 200 ------ .../live2d-report-modal.story.vue | 197 ----- .../dialogs/model-selector/model-selector.vue | 9 +- .../model-selector/reports/live2d/content.vue | 338 +++++++++ .../reports/live2d/modal.story.vue | 192 +++++ .../model-selector/reports/live2d/modal.vue | 116 +++ 10 files changed, 1439 insertions(+), 526 deletions(-) create mode 100644 packages/stage-ui-live2d/src/utils/live2d-validator.test.ts delete mode 100644 packages/stage-ui/src/components/scenarios/dialogs/model-selector/Live2DReportModal.vue delete mode 100644 packages/stage-ui/src/components/scenarios/dialogs/model-selector/live2d-report-modal.story.vue create mode 100644 packages/stage-ui/src/components/scenarios/dialogs/model-selector/reports/live2d/content.vue create mode 100644 packages/stage-ui/src/components/scenarios/dialogs/model-selector/reports/live2d/modal.story.vue create mode 100644 packages/stage-ui/src/components/scenarios/dialogs/model-selector/reports/live2d/modal.vue diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index b7d32b697..16886baa6 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -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 diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 3d90b975a..82c7ea33b 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -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: 从文件加载 diff --git a/packages/stage-ui-live2d/src/utils/live2d-validator.test.ts b/packages/stage-ui-live2d/src/utils/live2d-validator.test.ts new file mode 100644 index 000000000..d72c24d8f --- /dev/null +++ b/packages/stage-ui-live2d/src/utils/live2d-validator.test.ts @@ -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 { + 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 { + 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) + }) +}) diff --git a/packages/stage-ui-live2d/src/utils/live2d-validator.ts b/packages/stage-ui-live2d/src/utils/live2d-validator.ts index 7d18897ab..c00dc17e1 100644 --- a/packages/stage-ui-live2d/src/utils/live2d-validator.ts +++ b/packages/stage-ui-live2d/src/utils/live2d-validator.ts @@ -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 { + 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> { + 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 { + 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 { + 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 { - 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 | undefined + let references: Record | 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() - 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 } diff --git a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/Live2DReportModal.vue b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/Live2DReportModal.vue deleted file mode 100644 index 0f6c9a9cd..000000000 --- a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/Live2DReportModal.vue +++ /dev/null @@ -1,200 +0,0 @@ - - - diff --git a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/live2d-report-modal.story.vue b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/live2d-report-modal.story.vue deleted file mode 100644 index e6aea65eb..000000000 --- a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/live2d-report-modal.story.vue +++ /dev/null @@ -1,197 +0,0 @@ - - - diff --git a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue index 4d399aa32..3c6664b5d 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue @@ -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" /> +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> = { + 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(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 +}) + + + + + diff --git a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/reports/live2d/modal.story.vue b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/reports/live2d/modal.story.vue new file mode 100644 index 000000000..ea79899b6 --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/reports/live2d/modal.story.vue @@ -0,0 +1,192 @@ + + + diff --git a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/reports/live2d/modal.vue b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/reports/live2d/modal.vue new file mode 100644 index 000000000..0da0e6cc2 --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/reports/live2d/modal.vue @@ -0,0 +1,116 @@ + + +