diff --git a/packages/stage-ui-live2d/src/index.ts b/packages/stage-ui-live2d/src/index.ts index 0dc3d6ffd..9adfd5cc8 100644 --- a/packages/stage-ui-live2d/src/index.ts +++ b/packages/stage-ui-live2d/src/index.ts @@ -7,5 +7,6 @@ export { randomSaccadeInterval } from './utils/eye-motions' export * from './utils/live2d-opfs-registration' export * from './utils/live2d-preview' export * from './utils/live2d-uri-encode-filenames' +export * from './utils/live2d-validator' export * from './utils/live2d-zip-loader' export * from './utils/opfs-loader' diff --git a/packages/stage-ui-live2d/src/utils/index.ts b/packages/stage-ui-live2d/src/utils/index.ts index dfb6a7b0b..9f738682a 100644 --- a/packages/stage-ui-live2d/src/utils/index.ts +++ b/packages/stage-ui-live2d/src/utils/index.ts @@ -3,5 +3,6 @@ export { randomSaccadeInterval } from './eye-motions' export * from './live2d-opfs-registration' export * from './live2d-preview' export * from './live2d-uri-encode-filenames' +export * from './live2d-validator' export * from './live2d-zip-loader' export * from './opfs-loader' diff --git a/packages/stage-ui-live2d/src/utils/live2d-structure-report.ts b/packages/stage-ui-live2d/src/utils/live2d-structure-report.ts new file mode 100644 index 000000000..d607fe1c8 --- /dev/null +++ b/packages/stage-ui-live2d/src/utils/live2d-structure-report.ts @@ -0,0 +1,192 @@ +import fs from 'node:fs' +import path from 'node:path' + +import JSZip from 'jszip' + +/** + * Enhanced reporting utility to analyze and validate Live2D ZIP structures. + */ + +async function generateReport(zipPath: string) { + console.log(`\n================================================================`) + console.log(`LIVE2D STRUCTURE REPORT: ${path.basename(zipPath)}`) + console.log(`================================================================\n`) + + if (!fs.existsSync(zipPath)) { + console.error(`Error: File not found at ${zipPath}`) + process.exit(1) + } + + const data = fs.readFileSync(zipPath) + const zip = await JSZip.loadAsync(data) + const allFiles = Object.keys(zip.files) + + const report = { + zipPath, + totalFiles: allFiles.length, + entryPoint: null as string | null, + structureType: 'Unknown', + issues: [] as string[], + checks: [] as string[], + metadata: { + moc: null as string | null, + textures: [] as string[], + physics: null as string | null, + pose: null as string | null, + cdi: null as string | null, + expressions: [] as string[], + motions: [] as string[], + }, + } + + // 1. Enumerate Files and Check Non-ASCII + console.log(`[1] Enumerating ${allFiles.length} files...`) + allFiles.forEach((f) => { + if (/[^\x00-\x7F]/.test(f)) { + report.issues.push(`Non-ASCII filename detected: "${f}" (Ensure middleware handles this)`) + } + }) + + // 2. Identify Entry Point + const settingsFiles = allFiles.filter(f => f.endsWith('.model3.json')) + if (settingsFiles.length > 0) { + report.entryPoint = settingsFiles[0] + report.structureType = 'Standard (model3.json)' + if (settingsFiles.length > 1) { + report.issues.push(`Multiple .model3.json files found. Using: ${settingsFiles[0]}`) + } + report.checks.push(`Entry point identified: ${report.entryPoint}`) + } + else { + report.structureType = 'Heuristic (Loose Files)' + const mocFiles = allFiles.filter(f => f.endsWith('.moc3')) + if (mocFiles.length === 1) { + report.checks.push(`Heuristic match found: Unique MOC file ${mocFiles[0]}`) + } + else { + report.issues.push(`Heuristic failure: Found ${mocFiles.length} .moc3 files (Exactly 1 required)`) + } + } + + // 3. Validation + if (report.entryPoint) { + try { + const content = await zip.file(report.entryPoint)!.async('text') + const json = JSON.parse(content) + report.checks.push(`Successfully parsed ${path.basename(report.entryPoint)}`) + + const baseDir = path.posix.dirname(report.entryPoint) + const refs = json.FileReferences || {} + + // MOC + if (refs.Moc) { + const mocPath = path.posix.join(baseDir, refs.Moc) + if (allFiles.includes(mocPath)) { + report.metadata.moc = mocPath + report.checks.push(`MOC file exists: ${mocPath}`) + } + else { + report.issues.push(`Missing MOC file: ${mocPath} (referenced in JSON)`) + } + } + + // Textures + if (Array.isArray(refs.Textures)) { + refs.Textures.forEach((tex: string, i: number) => { + const texPath = path.posix.join(baseDir, tex) + if (allFiles.includes(texPath)) { + report.metadata.textures.push(texPath) + } + else { + report.issues.push(`Missing Texture ${i}: ${texPath} (referenced in JSON)`) + } + }) + report.checks.push(`Verified ${report.metadata.textures.length}/${refs.Textures.length} textures`) + } + + // Physics + if (refs.Physics) { + const physPath = path.posix.join(baseDir, refs.Physics) + if (allFiles.includes(physPath)) { + report.metadata.physics = physPath + report.checks.push(`Physics file exists: ${physPath}`) + } + else { + report.issues.push(`Missing Physics file: ${physPath} (referenced in JSON)`) + } + } + + // DisplayInfo (CDI) + if (refs.DisplayInfo) { + const cdiPath = path.posix.join(baseDir, refs.DisplayInfo) + if (allFiles.includes(cdiPath)) { + report.metadata.cdi = cdiPath + report.checks.push(`CDI file exists: ${cdiPath}`) + } + else { + report.issues.push(`Missing CDI file: ${cdiPath} (referenced in JSON)`) + } + } + + // Expressions + if (Array.isArray(refs.Expressions)) { + refs.Expressions.forEach((exp: any) => { + const expFile = typeof exp === 'string' ? exp : exp.File + const expPath = path.posix.join(baseDir, expFile) + if (allFiles.includes(expPath)) { + report.metadata.expressions.push(expPath) + } + else { + report.issues.push(`Missing Expression: ${expPath} (referenced in JSON)`) + } + }) + } + } + catch (e: any) { + report.issues.push(`Failed to parse ${report.entryPoint}: ${e.message}`) + } + } + + // 4. Global Discovery (ZIP-wide scanning as per ZipLoader) + const cdiFiles = allFiles.filter(f => f.toLowerCase().endsWith('.cdi3.json')) + if (cdiFiles.length > 0 && !report.metadata.cdi) { + report.checks.push(`Auto-discovered CDI: ${cdiFiles[0]}`) + } + + const expFiles = allFiles.filter(f => f.toLowerCase().endsWith('.exp3.json')) + expFiles.forEach((f) => { + if (!report.metadata.expressions.includes(f)) { + report.metadata.expressions.push(f) + } + }) + report.checks.push(`Total Expressions found: ${report.metadata.expressions.length}`) + + const motionFiles = allFiles.filter(f => f.toLowerCase().endsWith('.motion3.json') || f.toLowerCase().endsWith('.mtn')) + report.metadata.motions = motionFiles + report.checks.push(`Total Motions found: ${report.metadata.motions.length}`) + + // Final Summary + console.log(`[2] SUMMARY`) + console.log(` Type: ${report.structureType}`) + console.log(` Status: ${report.issues.length === 0 ? 'VALID' : 'INVALID'}`) + + if (report.checks.length > 0) { + console.log(`\n[3] CHECKS PASSED:`) + report.checks.forEach(c => console.log(` [V] ${c}`)) + } + + if (report.issues.length > 0) { + console.log(`\n[4] ISSUES FOUND:`) + report.issues.forEach(i => console.log(` [X] ${i}`)) + } + + console.log(`\n================================================================\n`) +} + +const target = process.argv[2] +if (!target) { + console.log('Usage: node_modules/.bin/tsx packages/stage-ui-live2d/src/utils/live2d-structure-report.ts ') +} +else { + generateReport(target).catch(console.error) +} diff --git a/packages/stage-ui-live2d/src/utils/live2d-validator.ts b/packages/stage-ui-live2d/src/utils/live2d-validator.ts new file mode 100644 index 000000000..cc7519922 --- /dev/null +++ b/packages/stage-ui-live2d/src/utils/live2d-validator.ts @@ -0,0 +1,155 @@ +import JSZip from 'jszip' + +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 + } +} + +export async function validateLive2DZip(file: File | Blob): Promise { + const zip = await JSZip.loadAsync(file) + const allPaths = Object.keys(zip.files) + + const report: Live2DValidationReport = { + fileName: (file as File).name || 'live2d-model.zip', + totalFiles: allPaths.length, + status: 'VALID', + entryPoint: null, + structureType: 'Unknown', + errors: [], + warnings: [], + checks: [], + } + + // 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}`) + } + 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.`) + } + } + + // 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 + + report.mocInfo = { header, ver, size: buf.length } + + if (header !== 'MOC3') { + report.errors.push(`Invalid MOC Header: "${header}" (Expected MOC3)`) + } + 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.`) + } + else if (sizeMb > 30) { + report.warnings.push(`HEAVY RESOURCE: MOC file is ${sizeMb.toFixed(2)} MB. This may cause performance issues in web browsers.`) + } + } + + // 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) + }) + + 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.`) + } + } + + // 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' + + return report +} diff --git a/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts index c7a41bde1..101ca09e0 100644 --- a/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts +++ b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts @@ -9,12 +9,50 @@ ZipLoader.zipReader = (data: Blob, _url: string) => JSZip.loadAsync(data) const defaultCreateSettings = ZipLoader.createSettings ZipLoader.createSettings = async (reader: JSZip) => { const filePaths = Object.keys(reader.files) + const settings = await (async () => { + if (!filePaths.some(file => isSettingsFile(file))) { + return createFakeSettings(filePaths) + } + return defaultCreateSettings(reader) + })() - if (!filePaths.some(file => isSettingsFile(file))) { - return createFakeSettings(filePaths) + // Extract CDI data from the zip if available + try { + const metadataSettings = settings as ModelSettings & { + _cdiData?: unknown + _expFiles?: Array<{ name: string, fileName: string, data: unknown }> + } + + // Find and parse CDI file + const cdiPath = filePaths.find(f => f.toLowerCase().endsWith('.cdi3.json')) + if (cdiPath) { + const cdiText = await reader.file(cdiPath)!.async('text') + metadataSettings._cdiData = JSON.parse(cdiText) + console.info('[ZipLoader] Extracted CDI data from:', cdiPath) + } + + // Find and collect expression files + const expPaths = filePaths.filter(f => f.toLowerCase().endsWith('.exp3.json')) + if (expPaths.length > 0) { + const expFiles: Array<{ name: string, fileName: string, data: unknown }> = [] + for (const expPath of expPaths) { + const expText = await reader.file(expPath)!.async('text') + const baseName = expPath.split('/').pop()?.replace('.exp3.json', '') || expPath + expFiles.push({ + name: baseName, + fileName: expPath, + data: JSON.parse(expText), + }) + } + metadataSettings._expFiles = expFiles + console.info('[ZipLoader] Extracted', expFiles.length, 'expression files') + } + } + catch (e) { + console.warn('[ZipLoader] Failed to extract CDI/EXP metadata:', e) } - return defaultCreateSettings(reader) + return settings } export function isSettingsFile(file: string) { @@ -69,10 +107,10 @@ function createFakeSettings(files: string[]): ModelSettings { }, }) - settings.name = modelName; + settings.name = modelName // provide this property for FileLoader - (settings as any)._objectURL = `example://${settings.url}` + Object.assign(settings, { _objectURL: `example://${settings.url}` }) return settings } @@ -90,7 +128,11 @@ ZipLoader.readText = (jsZip: JSZip, path: string) => { ZipLoader.getFilePaths = (jsZip: JSZip) => { const paths: string[] = [] - jsZip.forEach(relativePath => paths.push(relativePath)) + jsZip.forEach((relativePath, file) => { + if (!file.dir) { + paths.push(relativePath) + } + }) return Promise.resolve(paths) } 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 new file mode 100644 index 000000000..b0fd03a31 --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/Live2DReportModal.vue @@ -0,0 +1,200 @@ + + + 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 962110d74..d8d8d9acb 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 @@ -1,11 +1,17 @@