// HTML のリールを MP4(H.264 + AAC)に書き出す。
// 使い方: node export_mp4.mjs nack_intro_25s.html [fps=60] → out/nack_intro_25s.mp4(1920×1080・60fps の原本)
// node export_mp4.mjs nack_intro_25s.html --line → out/nack_intro_25s_line.mp4(LINE で動画として送る用)
// --line: 1280×720・30fps(30fps で1コマずつ撮る。2コマ平均は速い動きが二重に写るので使わない)、H.264 Main@3.1・上限 3.5Mbps、AAC 128k/44.1kHz、mp42。
// 1コマ目(プレビュー画像になる)だけ 0.1 秒時点の絵にして、真っ黒なプレビューを避ける。
// 絵は window.__seek(t) で1コマずつ決定的に描いて撮り、音は window.__audioWav() で同じ時刻表からオフライン合成する。
import path from 'node:path';
import fs from 'node:fs';
import { spawn } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
// playwright は akari_line_poc に入っているもの(1.61.1)を使う
const { chromium } = await import(pathToFileURL(path.join(here, '../akari_line_poc/node_modules/playwright/index.mjs')).href);
const [, , htmlArg, ...opts] = process.argv;
if (!htmlArg) { console.error('usage: node export_mp4.mjs [fps] [--line]'); process.exit(1); }
const line = opts.includes('--line');
const fps = line ? 30 : Number(opts.find(a => /^\d+$/.test(a)) || 60), TAIL = 1.5; // 最後の画を 1.5 秒のばし、ベルの余韻を切らない
const html = path.resolve(here, htmlArg), base = path.basename(html, '.html') + (line ? '_line' : '');
const outDir = path.join(here, 'out');
fs.mkdirSync(outDir, { recursive: true });
const wav = path.join(outDir, `${base}.wav`), mp4 = path.join(outDir, `${base}.mp4`);
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: 1 });
page.on('pageerror', e => console.error('pageerror:', e.message));
await page.goto(pathToFileURL(html).href + '?t=0');
await page.evaluate(() => window.__ready);
const dur = await page.evaluate(() => DUR);
fs.writeFileSync(wav, Buffer.from(await page.evaluate(sec => window.__audioWav(sec), dur + TAIL), 'base64'));
const venc = line
? ['-vf', 'scale=1280:720:flags=lanczos', '-c:v', 'libx264', '-profile:v', 'main', '-level:v', '3.1',
'-preset', 'slow', '-crf', '20', '-maxrate', '3500k', '-bufsize', '7000k', '-pix_fmt', 'yuv420p']
: ['-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-profile:v', 'high'];
const aenc = line ? ['-c:a', 'aac', '-b:a', '128k', '-ar', '44100', '-ac', '2'] : ['-c:a', 'aac', '-b:a', '192k'];
const ff = spawn('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error',
'-f', 'image2pipe', '-framerate', String(fps), '-i', '-', '-i', wav, ...venc,
'-af', `afade=t=out:st=${(dur + TAIL - 0.4).toFixed(2)}:d=0.4`, ...aenc,
'-movflags', '+faststart', ...(line ? ['-brand', 'mp42'] : []), '-shortest', mp4], { stdio: ['pipe', 'inherit', 'inherit'] });
const write = buf => new Promise(r => (ff.stdin.write(buf) ? r() : ff.stdin.once('drain', r)));
const total = Math.round(dur * fps);
let last;
for (let i = 0; i <= total; i++) {
await page.evaluate(t => window.__seek(t), line && i === 0 ? 0.1 : i / fps); // --line: 1コマ目はプレビュー用の絵(0.1秒時点の朱の点と輪)
last = await page.screenshot({ type: 'png' });
await write(last);
if (i % (fps * 5) === 0) console.log(`${base}: ${(i / fps).toFixed(0)}/${dur}s`);
}
for (let i = 0; i < Math.round(TAIL * fps); i++) await write(last);
ff.stdin.end();
await new Promise((res, rej) => ff.on('close', c => (c === 0 ? res() : rej(new Error(`ffmpeg exit ${c}`)))));
await browser.close();
console.log(`done: ${mp4}`);