mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
151 lines
5.5 KiB
JavaScript
151 lines
5.5 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);
|
|
});
|
|
}
|
|
|
|
// 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 };
|