// 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); });