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