Files
starr-philproperties/services/ffmpeg.service.js
T
2026-08-01 17:44:25 +08:00

103 lines
3.6 KiB
JavaScript

// 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);
});
}
module.exports = { needsRemux, remuxToFaststartMp4 };