Recording: 60fps capture, remux when no rescale, CRF 16 slow otherwise
captureStream at 60fps with an 80 Mbps cap. When the requested frame equals the source and the first pass is H.264, the MP4 is a pure remux (-c copy): the hardware realtime encode is the only compression in the final file. The re-encode path (vertical reels, VP9 fallback) moves to CRF 16 preset slow at constant 60fps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVMQ2JhsJ77pGKqcvvFHqc
This commit is contained in:
1 parent
d896a6f4b6
commit
51c6bca2c8
2 files changed
+28
-14
No files matched your search
@@ -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)));
|
||||
|
||||
+12
-7
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user