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