包含三个子项目: - avatar-h5-renderer: Live2D Cubism 4 H5 渲染器 (Vite + TS) - avatar_flutter_app: Flutter 容器 App (打包 H5 进 WebView) - gif-export: puppeteer 导出 32 个动作的透明 GIF (供 ESP32 圆屏播放) 模型资源: Haru, Natori (含贴图、moc3、motions, expressions) 设计文档: AI驱动虚拟形象渲染方案_v5.1.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
7.5 KiB
JavaScript
189 lines
7.5 KiB
JavaScript
// Record Live2D motions from the running H5 renderer into PNG frames,
|
|
// then encode each motion to a GIF via ffmpeg.
|
|
//
|
|
// Usage:
|
|
// node record_motion.js
|
|
//
|
|
// Prereq: vite dev server running on http://localhost:5002
|
|
|
|
const puppeteer = require('puppeteer');
|
|
const { execFileSync } = require('node:child_process');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const URL = process.env.URL || 'http://localhost:5002/';
|
|
const SIZE = parseInt(process.env.SIZE || '360', 10); // square canvas, matches ESP32 round 360x360
|
|
const FPS = parseInt(process.env.FPS || '20', 10); // GIF playback fps
|
|
const OUT = path.join(__dirname, 'clips');
|
|
|
|
// Full motion catalog: [outputName, group, index, durationSec]
|
|
// Names derive from the underlying haru_g_*.motion3.json so it's easy to map back.
|
|
const CLIPS = [
|
|
['idle_0_haru_g_idle', 'Idle', 0, 10.00],
|
|
['idle_1_haru_g_m15', 'Idle', 1, 5.33],
|
|
['tapbody_0_haru_g_m26','TapBody', 0, 4.97],
|
|
['tapbody_1_haru_g_m06','TapBody', 1, 4.53],
|
|
['tapbody_2_haru_g_m20','TapBody', 2, 6.03],
|
|
['tapbody_3_haru_g_m09','TapBody', 3, 4.03],
|
|
['dance_00_haru_g_m01', 'Dance', 0, 2.90],
|
|
['dance_01_haru_g_m02', 'Dance', 1, 2.03],
|
|
['dance_02_haru_g_m03', 'Dance', 2, 4.63],
|
|
['dance_03_haru_g_m04', 'Dance', 3, 5.30],
|
|
['dance_04_haru_g_m05', 'Dance', 4, 2.03],
|
|
['dance_05_haru_g_m06', 'Dance', 5, 4.53],
|
|
['dance_06_haru_g_m07', 'Dance', 6, 3.93],
|
|
['dance_07_haru_g_m08', 'Dance', 7, 4.60],
|
|
['dance_08_haru_g_m09', 'Dance', 8, 4.03],
|
|
['dance_09_haru_g_m10', 'Dance', 9, 5.53],
|
|
['dance_10_haru_g_m11', 'Dance', 10, 3.43],
|
|
['dance_11_haru_g_m12', 'Dance', 11, 4.93],
|
|
['dance_12_haru_g_m13', 'Dance', 12, 2.53],
|
|
['dance_13_haru_g_m14', 'Dance', 13, 3.03],
|
|
['dance_14_haru_g_m15', 'Dance', 14, 5.33],
|
|
['dance_15_haru_g_m16', 'Dance', 15, 4.00],
|
|
['dance_16_haru_g_m17', 'Dance', 16, 4.50],
|
|
['dance_17_haru_g_m18', 'Dance', 17, 3.20],
|
|
['dance_18_haru_g_m19', 'Dance', 18, 8.00],
|
|
['dance_19_haru_g_m20', 'Dance', 19, 6.03],
|
|
['dance_20_haru_g_m21', 'Dance', 20, 5.00],
|
|
['dance_21_haru_g_m22', 'Dance', 21, 5.03],
|
|
['dance_22_haru_g_m23', 'Dance', 22, 4.00],
|
|
['dance_23_haru_g_m24', 'Dance', 23, 3.40],
|
|
['dance_24_haru_g_m25', 'Dance', 24, 4.03],
|
|
['dance_25_haru_g_m26', 'Dance', 25, 4.97]
|
|
];
|
|
|
|
(async () => {
|
|
fs.mkdirSync(OUT, { recursive: true });
|
|
|
|
const browser = await puppeteer.launch({
|
|
headless: 'new',
|
|
args: [
|
|
`--window-size=${SIZE},${SIZE}`,
|
|
'--hide-scrollbars',
|
|
'--disable-web-security'
|
|
],
|
|
defaultViewport: { width: SIZE, height: SIZE, deviceScaleFactor: 1 }
|
|
});
|
|
|
|
const page = await browser.newPage();
|
|
page.on('console', m => {
|
|
const t = m.text();
|
|
if (t.startsWith('[avatar]') || t.startsWith('[record]')) console.log(' page>', t);
|
|
});
|
|
|
|
await page.goto(URL, { waitUntil: 'networkidle2' });
|
|
|
|
// Hide debug panel + force a fully transparent page so only the live2d canvas remains.
|
|
// The WebGL context is cleared with (0,0,0,0), so the canvas itself is already transparent.
|
|
await page.addStyleTag({ content: `
|
|
#debug-panel { display: none !important; }
|
|
body > canvas { width: ${SIZE}px !important; height: ${SIZE}px !important; }
|
|
`});
|
|
// Inline styles beat stylesheets — guarantees the classroom background-image is gone.
|
|
await page.evaluate(() => {
|
|
document.documentElement.style.background = 'transparent';
|
|
document.documentElement.style.backgroundImage = 'none';
|
|
document.body.style.background = 'transparent';
|
|
document.body.style.backgroundImage = 'none';
|
|
});
|
|
|
|
// Wait for window.avatar to be ready
|
|
console.log('[record] waiting for avatar...');
|
|
await page.waitForFunction(() => {
|
|
return window.avatar && Object.keys(window.avatar.listMotions()).length > 0;
|
|
}, { timeout: 30000 });
|
|
console.log('[record] avatar ready');
|
|
|
|
// The classroom background and gear icon are NOT CSS — they are sprites drawn
|
|
// into the WebGL canvas by LAppView. Import the delegate via Vite's module
|
|
// server and null them out so the canvas keeps only the Live2D model.
|
|
const stripped = await page.evaluate(async () => {
|
|
const mod = await import('/src/lappdelegate.ts');
|
|
const sub = mod.LAppDelegate.getInstance().getSubdelegate(0);
|
|
if (!sub) return 'no subdelegate';
|
|
const view = sub._view;
|
|
if (!view) return 'no view';
|
|
let n = 0;
|
|
for (const k of ['_back', '_gear']) {
|
|
if (view[k]) { try { view[k].release(); } catch {} view[k] = null; n++; }
|
|
}
|
|
return `stripped ${n} sprite(s)`;
|
|
});
|
|
console.log('[record] stage:', stripped);
|
|
|
|
// Find the live2d canvas element
|
|
const canvasHandle = await page.evaluateHandle(() => document.querySelector('canvas'));
|
|
const canvasBox = await canvasHandle.asElement().boundingBox();
|
|
console.log('[record] canvas box:', canvasBox);
|
|
|
|
for (const [name, group, idx, dur] of CLIPS) {
|
|
const framesDir = path.join(OUT, `${name}_frames`);
|
|
fs.rmSync(framesDir, { recursive: true, force: true });
|
|
fs.mkdirSync(framesDir, { recursive: true });
|
|
|
|
console.log(`\n[record] === ${name} (${group}#${idx}, ${dur}s) ===`);
|
|
|
|
// Trigger the motion
|
|
await page.evaluate((g, i) => window.avatar.playMotion(g, i), group, idx);
|
|
|
|
// Give the engine 1 frame to actually swap to the new motion
|
|
await new Promise(r => setTimeout(r, 50));
|
|
|
|
const frameCount = Math.ceil(dur * FPS);
|
|
const periodMs = 1000 / FPS;
|
|
const t0 = Date.now();
|
|
|
|
for (let f = 0; f < frameCount; f++) {
|
|
const targetT = t0 + f * periodMs;
|
|
const wait = targetT - Date.now();
|
|
if (wait > 0) await new Promise(r => setTimeout(r, wait));
|
|
|
|
const buf = await page.screenshot({
|
|
clip: { x: canvasBox.x, y: canvasBox.y, width: canvasBox.width, height: canvasBox.height },
|
|
omitBackground: true, // keep alpha from the WebGL canvas
|
|
captureBeyondViewport: false,
|
|
type: 'png'
|
|
});
|
|
const fp = path.join(framesDir, `f_${String(f).padStart(4,'0')}.png`);
|
|
fs.writeFileSync(fp, buf);
|
|
}
|
|
console.log(`[record] captured ${frameCount} frames -> ${framesDir}`);
|
|
|
|
// Encode to GIF with 1-bit transparency preserved.
|
|
// - palettegen reserves one slot for transparent
|
|
// - paletteuse maps any pixel with alpha < threshold to that slot
|
|
// - dither is disabled to avoid speckled noise on the transparent area edges
|
|
const gifPath = path.join(OUT, `${name}.gif`);
|
|
const palette = path.join(framesDir, 'palette.png');
|
|
execFileSync('ffmpeg', [
|
|
'-y',
|
|
'-framerate', String(FPS),
|
|
'-i', path.join(framesDir, 'f_%04d.png'),
|
|
'-vf', `fps=${FPS},scale=${SIZE}:${SIZE}:flags=lanczos,palettegen=max_colors=192:reserve_transparent=1`,
|
|
palette
|
|
], { stdio: 'ignore' });
|
|
execFileSync('ffmpeg', [
|
|
'-y',
|
|
'-framerate', String(FPS),
|
|
'-i', path.join(framesDir, 'f_%04d.png'),
|
|
'-i', palette,
|
|
'-lavfi', `fps=${FPS},scale=${SIZE}:${SIZE}:flags=lanczos[v];[v][1:v]paletteuse=dither=none:alpha_threshold=128`,
|
|
'-loop', '0',
|
|
gifPath
|
|
], { stdio: 'ignore' });
|
|
|
|
const kb = (fs.statSync(gifPath).size / 1024).toFixed(1);
|
|
console.log(`[record] gif written: ${gifPath} (${kb} KB)`);
|
|
|
|
// Drop the per-clip frame folder — keeping 32 of them eats ~300MB and we
|
|
// already have the GIF. Comment this out if you want to debug a frame.
|
|
if (process.env.KEEP_FRAMES !== '1') {
|
|
fs.rmSync(framesDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
await browser.close();
|
|
console.log('\n[record] done. clips in:', OUT);
|
|
})().catch(e => { console.error(e); process.exit(1); });
|