// services/ffmpeg.service.js // // Fast container remux for video assets whose original format loads slowly // in the browser. Not a transcode — the video/audio streams are copied // byte-for-byte (`-c copy`), only the container is swapped: // // .mov — often exported without "fast start" (common for OBS/QuickTime // screen recordings), which puts the moov atom — the index the // browser needs before it can render anything — at the END of the // file. Playback can't begin until that's reached. // .mkv — same class of problem with its Cues index, plus native browser // support for Matroska demuxing/seeking is inconsistent to begin // with. // // Remuxing into a faststart .mp4 (moov moved to the front) makes both play // exactly like this app's already-fast .mp4 uploads. .mp4/.mp3 are untouched // — they don't have this problem. // // Reads directly from a presigned S3 URL — ffmpeg's own HTTP client handles // that (same as ffprobe.service.js's probeUrl()), this process never buffers // the original file. The mp4 muxer needs a seekable *output* to rewrite the // moov atom after the fact, so the result is written to a local temp file — // see services/assetTranscode.service.js for streaming that back to storage // without buffering it into memory either. const ffmpeg = require("fluent-ffmpeg"); const ffprobeStatic = require("ffprobe-static"); const os = require("os"); const path = require("path"); const fs = require("fs"); const crypto = require("crypto"); try { const { execSync } = require("child_process"); execSync("which ffprobe", { stdio: "ignore" }); } catch { ffmpeg.setFfprobePath(ffprobeStatic.path); } const REMUXABLE_EXTENSIONS = new Set(["mov", "mkv"]); function needsRemux(extension = "") { return REMUXABLE_EXTENSIONS.has((extension || "").toLowerCase()); } function tempOutputPath() { return path.join(os.tmpdir(), `remux_${Date.now()}_${crypto.randomBytes(4).toString("hex")}.mp4`); } // input: presigned GET URL for the original .mov/.mkv object // output: local filesystem path to the remuxed .mp4 (caller owns cleanup) // // -map 0:v:0 -map 0:a:0? — take the first video stream and, if present, the // first audio stream only. Drops subtitle/data streams some mkv/mov files // carry, which the mp4 muxer either can't hold or chokes on. // -max_muxing_queue_size — defensive bump; large copy-remuxes of files with // bursty interleaving can otherwise hit "Too many packets buffered for // output stream" and abort. function remuxToFaststartMp4(inputUrl, { timeoutMs = 30 * 60 * 1000 } = {}) { const outputPath = tempOutputPath(); return new Promise((resolve, reject) => { let settled = false; const command = ffmpeg(inputUrl) .outputOptions([ "-map 0:v:0", "-map 0:a:0?", "-c:v copy", "-c:a copy", "-movflags +faststart", "-max_muxing_queue_size 9999", ]) .format("mp4"); const timer = setTimeout(() => { if (settled) return; settled = true; command.kill("SIGKILL"); fs.promises.unlink(outputPath).catch(() => {}); reject(new Error(`Remux timed out after ${timeoutMs}ms`)); }, timeoutMs); command .on("error", (err) => { if (settled) return; settled = true; clearTimeout(timer); fs.promises.unlink(outputPath).catch(() => {}); reject(err); }) .on("end", () => { if (settled) return; settled = true; clearTimeout(timer); resolve(outputPath); }) .save(outputPath); }); } // Grabs a single frame from a video as a JPEG thumbnail — used when a video // asset lands with no client-provided thumbnail (see finalizeAssetFromStorage // in assets.controller.js). Unlike remuxToFaststartMp4 above, this is cheap // enough (one frame, not a full re-encode/copy) to run inline during the // upload-finalize request rather than as a background job. // // input: presigned GET URL for the video, + its duration (seconds, from // ffprobe) to pick a safe seek point. // output: local filesystem path to the extracted .jpg (caller owns cleanup). function extractFrameThumbnail(inputUrl, duration, { timeoutMs = 30 * 1000 } = {}) { // Seek 1s in, or the midpoint for clips shorter than ~2s — avoids grabbing // frame 0, which is often black/blank on screen recordings and slates. const atSeconds = !duration || duration <= 0 ? 0 : Math.min(1, duration / 2); const outputPath = path.join(os.tmpdir(), `thumb_${Date.now()}_${crypto.randomBytes(4).toString("hex")}.jpg`); return new Promise((resolve, reject) => { let settled = false; const command = ffmpeg(inputUrl) .seekInput(atSeconds) // input-side seek — fast, keyframe-based .outputOptions(["-frames:v 1", "-q:v 2"]); const timer = setTimeout(() => { if (settled) return; settled = true; command.kill("SIGKILL"); fs.promises.unlink(outputPath).catch(() => {}); reject(new Error(`Thumbnail extraction timed out after ${timeoutMs}ms`)); }, timeoutMs); command .on("error", (err) => { if (settled) return; settled = true; clearTimeout(timer); fs.promises.unlink(outputPath).catch(() => {}); reject(err); }) .on("end", () => { if (settled) return; settled = true; clearTimeout(timer); resolve(outputPath); }) .save(outputPath); }); } module.exports = { needsRemux, remuxToFaststartMp4, extractFrameThumbnail };