diff --git a/main.js b/main.js index 4c0bcbe..362240c 100644 --- a/main.js +++ b/main.js @@ -309,13 +309,22 @@ function transcodeToMp4(input, output, opts) { const h = (opts && opts.h) || 1080; // Cover the target frame then centre-crop, so the chosen aspect is filled // without distortion. - const vf = `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`; - // Post-processing, not realtime: spend encode time on quality. CRF 18 + - // medium is visually transparent for VJ motion; -r 30 turns the recorder's - // variable frame timing into constant 30fps (players and socials want CFR). - const args = ['-y', '-i', input, '-vf', vf, '-r', '30', - '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-pix_fmt', 'yuv420p', - '-c:a', 'aac', '-b:a', '256k', '-movflags', '+faststart', output]; + const args = ['-y', '-i', input]; + if (opts && opts.srcH264 && opts.srcW === w && opts.srcH === h) { + // Source already matches the requested frame and is H.264: REMUX without + // re-encoding — the hardware realtime pass stays the only encode in the + // final file (zero generational loss). + args.push('-c', 'copy'); + } else { + // Rescale/crop needed (e.g. vertical reels) or non-H264 fallback source. + // Post-processing, not realtime: spend encode time on quality. -r 60 + // turns the recorder's variable frame timing into constant fps. + const vf = `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`; + args.push('-vf', vf, '-r', '60', + '-c:v', 'libx264', '-preset', 'slow', '-crf', '16', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', '-b:a', '256k'); + } + args.push('-movflags', '+faststart', output); return new Promise((resolve, reject) => { execFile(ffmpegPath(), args, { maxBuffer: 1 << 24 }, (err, stdout, stderr) => { if (err) reject(new Error('ffmpeg: ' + String(stderr || err.message).slice(-500))); diff --git a/src/output.js b/src/output.js index c2b42a9..2d7a0b0 100644 --- a/src/output.js +++ b/src/output.js @@ -181,16 +181,15 @@ async function startRecording(opts) { recCanvas.height = canvas.height; recCtx = recCanvas.getContext('2d'); composeFrame(); - const vstream = recCanvas.captureStream(30); + const vstream = recCanvas.captureStream(60); const astream = audio.recordDest.stream; const stream = new MediaStream([...vstream.getVideoTracks(), ...astream.getAudioTracks()]); - // Generous bitrate: this WebM is only an intermediate (deleted after the - // MP4 mux) and the realtime VP9 encoder needs headroom on full-screen - // abstract motion — 12 Mbps left visible blocking, the ffmpeg pass can - // only preserve what survives this first encode. + // Generous bitrate: this file is only an intermediate (deleted after the + // MP4 mux) — and when no rescale is needed it becomes the FINAL stream + // via remux, so the realtime hardware pass is the only encode that counts. recorder = new MediaRecorder(stream, { mimeType: pickMime(), - videoBitsPerSecond: 50e6, // hardware H.264 barely notices; VP9 fallback undershoots anyway + videoBitsPerSecond: 80e6, // 1080p60 headroom; the VP9 fallback undershoots anyway audioBitsPerSecond: 192e3 }); // Serialise chunk delivery: ondataavailable is async, so without a chain the @@ -203,7 +202,13 @@ async function startRecording(opts) { recorder.onstop = async () => { stopCompose(); await chain; // ensure every chunk is flushed, in order, before muxing - const res = await djv.recStop(pendingRecOpts || {}); + // Tell main what the source actually is: same size + H.264 lets it + // remux instead of re-encoding (zero extra quality loss). + const res = await djv.recStop({ + ...(pendingRecOpts || {}), + srcW: recCanvas.width, srcH: recCanvas.height, + srcH264: /avc1|h264/i.test(recorder.mimeType || '') + }); recording = false; djv.report({ type: 'recState', recording: false }); if (res && res.ok) djv.report({ type: 'recSaved', path: res.path, name: res.name, url: res.url });