#!/usr/bin/env node
/*
 * CUT render worker: loads editor.html?render=1 in headless Chrome, steps the
 * preview engine frame by frame, pipes JPEG frames into ffmpeg, and muxes with
 * an OfflineAudioContext mix rendered by the same page. What you preview is
 * what you get.
 *
 * usage: node render_worker.js <jobfile.json>
 */
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const puppeteer = require('puppeteer-core');

const { findChrome } = require('./chrome_find.js');

const CHROME = findChrome();

/* h264_nvenc needs an NVIDIA gpu — fall back to libx264 everywhere else */
function videoCodec() {
  try {
    const r = spawnSync('ffmpeg', ['-hide_banner', '-encoders'], { encoding: 'utf8', timeout: 15000 });
    if ((r.stdout || '').includes('h264_nvenc')) {
      // probe at a REAL size: 64x64 is below NVENC's minimum on some
      // drivers, so the old probe reported "unusable" on cards that work
      // fine and quietly fell back to x264 for every render.
      const t = spawnSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-f', 'lavfi', '-i',
        'color=black:s=640x360:d=0.2', '-pix_fmt', 'yuv420p',
        '-c:v', 'h264_nvenc', '-f', 'null', '-'], { timeout: 20000 });
      if (t.status === 0) return ['-c:v', 'h264_nvenc', '-preset', 'p5', '-rc', 'vbr', '-cq', '19'];
    }
  } catch (e) { /* fall through */ }
  return ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18'];
}
const VCODEC = videoCodec();

/* Canvas captures (MediaRecorder webm AND toDataURL jpegs) come out FULL-range
   bt601. Passing that straight to h264_nvenc bakes a pc/bt470bg mp4 that nearly
   every player decodes as limited-range bt709 → crushed blacks, shifted hues,
   "render darker than preview". Convert the pixels + tag the container so any
   player shows exactly what the canvas showed. */
const CSPACE_VF = 'scale=in_range=pc:in_color_matrix=bt601:out_range=tv:out_color_matrix=bt709,format=yuv420p';
const CSPACE_TAGS = ['-colorspace', 'bt709', '-color_primaries', 'bt709',
                     '-color_trc', 'bt709', '-color_range', 'tv'];

async function main() {
  const job = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
  const { project, out, fps, width, height, base } = job;
  const progress = p => {
    try {
      const j = JSON.parse(fs.readFileSync(job.progress, 'utf8'));
      j.progress = p;
      fs.writeFileSync(job.progress, JSON.stringify(j));
    } catch (e) { /* status file races are non-fatal */ }
  };

  const browser = await puppeteer.launch({
    executablePath: CHROME,
    headless: 'new',
    /* GPU decode (--use-angle=gl-egl --enable-gpu) is deliberately OFF.
       A consumer GPU's hardware decoder session limit gets exceeded by
       projects that reuse one source file many times (freeze-hold punch
       inserts, split+duplicated reaction clips) — seeks silently miss their
       4s __renderFrame deadline and bake in black frames, or the renderer
       crashes outright under enough concurrent <video> elements. Confirmed
       on a real production render: same project, same code, GPU on = black
       16-30s; GPU off = clean throughout. Costs ~2x capture speed (software
       rasterization) — worth it, a correct slow render beats a fast broken
       one. Revisit only with a video-element pool that caps concurrent
       decode sessions instead of one-per-clip. */
    args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required',
           '--disable-dev-shm-usage', `--window-size=${width},${height}`],
    protocolTimeout: 3600000, // hour-long reels: the render-driving evaluate lives as long as the capture
  });
  const page = await browser.newPage();
  await page.setViewport({ width, height });
  page.on('pageerror', e => console.error('[page]', e.message));

  await page.goto(`${base}/editor/editor.html?render=1`,
                  { waitUntil: 'domcontentloaded', timeout: 60000 });
  await page.waitForFunction('window.__renderReady === true', { timeout: 60000 });

  const meta = await page.evaluate(
    (proj, w, h) => window.__renderInit(proj, w, h), project, width, height);
  const dur = meta.dur;
  console.error(`render: ${dur.toFixed(2)}s @ ${fps}fps (${job.exact ? 'exact' : 'realtime'})`);

  // ---- audio: exact offline mix from the page (chunked workers: owner only) ----
  const wavPath = job.wavOut || path.join(os.tmpdir(), `cut-${job.id}.wav`);
  if (job.writeAudio !== false) {
    const wavB64 = await page.evaluate(() => window.__renderAudio());
    fs.writeFileSync(wavPath, Buffer.from(wavB64, 'base64'));
  }
  if (job.progOwner !== false) progress(0.08);

  const runFF = args => new Promise((res, rej) => {
    const ff = spawn('ffmpeg', args, { stdio: ['pipe', 'ignore', 'pipe'] });
    let err = '';
    ff.stderr.on('data', d => { err += d; if (err.length > 20000) err = err.slice(-10000); });
    ff.on('close', code => code === 0 ? res() : rej(new Error('ffmpeg: ' + err.slice(-1500))));
    ff._proc = ff;
    runFF.last = ff;
  });

  if (job.exact) {
    // ---- exact mode: frame-step the engine into ffmpeg (deterministic).
    // f0/f1 render a frame RANGE (chunked parallel workers, video-only
    // parts stitched by the orchestrator with -c copy) ----
    const total = Math.max(1, Math.ceil(dur * fps));
    const f0 = Number.isFinite(job.f0) ? job.f0 : 0;
    const f1 = Number.isFinite(job.f1) ? job.f1 : total;
    const ffArgs = job.vonly
      ? ['-y', '-f', 'image2pipe', '-framerate', String(fps), '-i', '-',
         '-vf', CSPACE_VF, ...CSPACE_TAGS, ...VCODEC,
         '-an', out]
      : ['-y', '-f', 'image2pipe', '-framerate', String(fps), '-i', '-', '-i', wavPath,
         '-vf', CSPACE_VF, ...CSPACE_TAGS, ...VCODEC,
         '-c:a', 'aac', '-b:a', '192k', '-t', dur.toFixed(3), out];
    const ff = spawn('ffmpeg', ffArgs, { stdio: ['pipe', 'ignore', 'pipe'] });
    let ffErr = '';
    ff.stderr.on('data', d => { ffErr += d; if (ffErr.length > 20000) ffErr = ffErr.slice(-10000); });
    const ffDone = new Promise((res, rej) =>
      ff.on('close', code => code === 0 ? res() : rej(new Error('ffmpeg: ' + ffErr.slice(-1500)))));
    const write = buf => new Promise(res => ff.stdin.write(buf, res) || ff.stdin.once('drain', res));
    for (let i = f0; i < f1; i++) {
      const b64 = await page.evaluate(tt => window.__renderFrame(tt), i / fps);
      await write(Buffer.from(b64, 'base64'));
      if (i % 30 === 0 && job.progOwner !== false) progress(0.08 + 0.9 * ((i - f0) / (f1 - f0)));
    }
    ff.stdin.end();
    await ffDone;
  } else {
    // ---- realtime mode: play once, record the canvas, retime + transcode ----
    // 0.35x capture gives ~3x headroom over what this box sustains even under
    // game-server load; probing with a second captureStream broke Chrome's capture.
    const speed = Number(job.speed) || 0.35;
    const webmPath = path.join(os.tmpdir(), `cut-${job.id}.webm`);
    const webm = fs.createWriteStream(webmPath);
    await page.exposeFunction('__chunk', b64 =>
      new Promise(res => webm.write(Buffer.from(b64, 'base64'), res)));
    await page.exposeFunction('__prog', p => progress(0.08 + 0.72 * p));
    await page.evaluate(s => window.__renderRealtime(s), speed);
    await new Promise(res => webm.end(res));
    progress(0.82);
    const vf = ['-vf', (speed !== 1 ? `setpts=PTS*${speed},` : '') + CSPACE_VF];
    await runFF([
      '-y', '-i', webmPath, '-i', wavPath,
      ...vf, ...CSPACE_TAGS, '-r', String(fps),
      '-map', '0:v:0', '-map', '1:a:0', ...VCODEC,
      '-c:a', 'aac', '-b:a', '192k', '-t', dur.toFixed(3), out,
    ]);
    fs.unlinkSync(webmPath);
  }

  if (!job.wavOut && job.writeAudio !== false) fs.unlinkSync(wavPath);
  await browser.close();
  console.error('render complete:', out);
}

main().catch(e => { console.error(e.stack || String(e)); process.exit(1); });
