import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { build } from "esbuild"; const appRoot = process.cwd(); const sourceEntry = path.join(appRoot, "src", "components", "avatar", "live2dModel.ts"); const tempDir = path.join(appRoot, "node_modules", ".tmp", "live2d-assets"); const bundledModule = path.join(tempDir, "live2dModel.bundle.mjs"); const publicRoot = path.join(appRoot, "public", "live2d", "personas"); function round(value) { return Math.round(value * 1000) / 1000; } function json(value) { return `${JSON.stringify(value, null, 2)}\n`; } function expressionToExp3(motion) { return { Type: "Live2D Expression", Version: 3, FadeInTime: round(motion.fadeInMs / 1000), FadeOutTime: round(motion.fadeOutMs / 1000), Parameters: motion.parameters.map((parameter) => ({ Id: parameter.id, Value: round(parameter.value), Blend: parameter.blend, })), Vignette: { Label: motion.label, Group: motion.group, Duration: round(motion.durationMs / 1000), }, }; } function modelToModel3(model) { const code = model.personaCode.toLowerCase(); return { Version: 3, Name: model.displayName, Vignette: { Schema: model.schemaVersion, Renderer: model.renderer, ModelId: model.modelId, PersonaCode: model.personaCode, DefaultExpression: model.defaultExpression, Art: model.art, Parameters: model.parameters, GeneratedBy: "apps/web/scripts/generate-live2d-assets.mjs", }, FileReferences: { Expressions: model.expressions.map((motion) => ({ Name: motion.name, File: motion.file, })), }, Groups: [ { Target: "Parameter", Name: "EyeBlink", Ids: ["ParamEyeLOpen", "ParamEyeROpen"], }, { Target: "Parameter", Name: "LipSync", Ids: ["ParamMouthOpenY"], }, ], HitAreas: model.hitAreas.map((area) => ({ Id: area.id, Name: area.name, })), Model: { Type: "VignetteSvgParameterRig", File: `/live2d/personas/${code}/${code}.model3.json`, }, }; } await mkdir(tempDir, { recursive: true }); await build({ entryPoints: [sourceEntry], outfile: bundledModule, bundle: true, platform: "node", format: "esm", logLevel: "silent", }); const moduleUrl = `${pathToFileURL(bundledModule).href}?t=${Date.now()}`; const { PERSONA_LIVE2D_MODELS } = await import(moduleUrl); await rm(publicRoot, { recursive: true, force: true }); const index = { schemaVersion: "vignette.live2d.assets.v1", generatedAt: new Date(0).toISOString(), personas: [], }; for (const model of Object.values(PERSONA_LIVE2D_MODELS)) { const code = model.personaCode.toLowerCase(); const modelDir = path.join(publicRoot, code); const expressionsDir = path.join(modelDir, "expressions"); await mkdir(expressionsDir, { recursive: true }); await writeFile(path.join(modelDir, `${code}.model3.json`), json(modelToModel3(model)), "utf8"); await writeFile( path.join(modelDir, "manifest.json"), json({ schemaVersion: "vignette.live2d.persona.v1", modelId: model.modelId, personaCode: model.personaCode, defaultExpression: model.defaultExpression, model3: `${code}.model3.json`, expressionCount: model.expressions.length, expressions: model.expressions.map((motion) => ({ name: motion.name, label: motion.label, group: motion.group, file: motion.file, })), }), "utf8", ); for (const motion of model.expressions) { await writeFile( path.join(modelDir, motion.file), json(expressionToExp3(motion)), "utf8", ); } index.personas.push({ code: model.personaCode, modelId: model.modelId, model3: `/live2d/personas/${code}/${code}.model3.json`, expressionCount: model.expressions.length, }); } await writeFile(path.join(publicRoot, "index.json"), json(index), "utf8"); console.log(`Generated ${index.personas.length} persona Live2D asset sets in ${publicRoot}`);