|
| 1 | +/** |
| 2 | + * FFmpeg post-render compression for video pipeline. |
| 3 | + * |
| 4 | + * Downloads the rendered video from Remotion Lambda's S3 output, |
| 5 | + * compresses it with FFmpeg, and returns the compressed buffer. |
| 6 | + * |
| 7 | + * Uses two-pass encoding for optimal quality/size ratio. |
| 8 | + */ |
| 9 | + |
| 10 | +import { execFileSync, execSync } from "child_process"; |
| 11 | +import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from "fs"; |
| 12 | +import { join } from "path"; |
| 13 | +import { tmpdir } from "os"; |
| 14 | + |
| 15 | +// --------------------------------------------------------------------------- |
| 16 | +// Types |
| 17 | +// --------------------------------------------------------------------------- |
| 18 | + |
| 19 | +export interface CompressOptions { |
| 20 | + /** Target bitrate for video (default: "2M" for 2 Mbps) */ |
| 21 | + videoBitrate?: string; |
| 22 | + /** Audio bitrate (default: "128k") */ |
| 23 | + audioBitrate?: string; |
| 24 | + /** CRF value for quality (default: 23, lower = better quality) */ |
| 25 | + crf?: number; |
| 26 | + /** Preset (default: "medium", options: ultrafast … veryslow) */ |
| 27 | + preset?: string; |
| 28 | + /** Max width — will scale down if larger, maintaining aspect ratio */ |
| 29 | + maxWidth?: number; |
| 30 | + /** Max height — will scale down if larger, maintaining aspect ratio */ |
| 31 | + maxHeight?: number; |
| 32 | +} |
| 33 | + |
| 34 | +export interface CompressResult { |
| 35 | + /** Compressed video as Buffer */ |
| 36 | + buffer: Buffer; |
| 37 | + /** Original size in bytes */ |
| 38 | + originalSize: number; |
| 39 | + /** Compressed size in bytes */ |
| 40 | + compressedSize: number; |
| 41 | + /** Compression ratio (e.g., 0.6 means 60% of original) */ |
| 42 | + ratio: number; |
| 43 | + /** Duration of compression in ms */ |
| 44 | + durationMs: number; |
| 45 | +} |
| 46 | + |
| 47 | +export interface VideoMetadata { |
| 48 | + /** Duration in seconds */ |
| 49 | + duration: number; |
| 50 | + /** Width in pixels */ |
| 51 | + width: number; |
| 52 | + /** Height in pixels */ |
| 53 | + height: number; |
| 54 | + /** Video codec (e.g. "h264") */ |
| 55 | + videoCodec: string; |
| 56 | + /** Audio codec (e.g. "aac") */ |
| 57 | + audioCodec: string; |
| 58 | + /** Overall bitrate in bits/s */ |
| 59 | + bitrate: number; |
| 60 | + /** File size in bytes */ |
| 61 | + fileSize: number; |
| 62 | +} |
| 63 | + |
| 64 | +// --------------------------------------------------------------------------- |
| 65 | +// Helpers |
| 66 | +// --------------------------------------------------------------------------- |
| 67 | + |
| 68 | +const LOG_PREFIX = "[FFMPEG]"; |
| 69 | + |
| 70 | +function log(...args: unknown[]) { |
| 71 | + console.log(LOG_PREFIX, ...args); |
| 72 | +} |
| 73 | + |
| 74 | +function warn(...args: unknown[]) { |
| 75 | + console.warn(LOG_PREFIX, ...args); |
| 76 | +} |
| 77 | + |
| 78 | +function isFfmpegAvailable(): boolean { |
| 79 | + try { |
| 80 | + execSync("ffmpeg -version", { stdio: "ignore" }); |
| 81 | + return true; |
| 82 | + } catch { |
| 83 | + return false; |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +function isFfprobeAvailable(): boolean { |
| 88 | + try { |
| 89 | + execSync("ffprobe -version", { stdio: "ignore" }); |
| 90 | + return true; |
| 91 | + } catch { |
| 92 | + return false; |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +/** |
| 97 | + * Download a URL to a Buffer. Works in Node 18+ (global fetch). |
| 98 | + */ |
| 99 | +async function downloadUrl(url: string): Promise<Buffer> { |
| 100 | + log("Downloading video from URL …"); |
| 101 | + const res = await fetch(url); |
| 102 | + if (!res.ok) { |
| 103 | + throw new Error(`${LOG_PREFIX} Failed to download video: ${res.status} ${res.statusText}`); |
| 104 | + } |
| 105 | + const arrayBuffer = await res.arrayBuffer(); |
| 106 | + return Buffer.from(arrayBuffer); |
| 107 | +} |
| 108 | + |
| 109 | +/** |
| 110 | + * Create a temp directory and return helpers for managing temp files. |
| 111 | + */ |
| 112 | +function makeTempDir() { |
| 113 | + const dir = mkdtempSync(join(tmpdir(), "ffmpeg-compress-")); |
| 114 | + const inputPath = join(dir, "input.mp4"); |
| 115 | + const outputPath = join(dir, "output.mp4"); |
| 116 | + const passLogPrefix = join(dir, "ffmpeg2pass"); |
| 117 | + |
| 118 | + function cleanup() { |
| 119 | + for (const f of [inputPath, outputPath]) { |
| 120 | + try { |
| 121 | + unlinkSync(f); |
| 122 | + } catch { |
| 123 | + /* ignore */ |
| 124 | + } |
| 125 | + } |
| 126 | + // Two-pass log files |
| 127 | + for (const suffix of ["", "-0.log", "-0.log.mbtree"]) { |
| 128 | + try { |
| 129 | + unlinkSync(passLogPrefix + suffix); |
| 130 | + } catch { |
| 131 | + /* ignore */ |
| 132 | + } |
| 133 | + } |
| 134 | + try { |
| 135 | + // Remove the temp directory itself |
| 136 | + execSync(`rm -rf "${dir}"`, { stdio: "ignore" }); |
| 137 | + } catch { |
| 138 | + /* ignore */ |
| 139 | + } |
| 140 | + } |
| 141 | + |
| 142 | + return { dir, inputPath, outputPath, passLogPrefix, cleanup }; |
| 143 | +} |
| 144 | + |
| 145 | +// --------------------------------------------------------------------------- |
| 146 | +// Build FFmpeg arguments |
| 147 | +// --------------------------------------------------------------------------- |
| 148 | + |
| 149 | +function buildScaleFilter(opts: CompressOptions): string | null { |
| 150 | + if (!opts.maxWidth && !opts.maxHeight) return null; |
| 151 | + |
| 152 | + const w = opts.maxWidth ? `'min(${opts.maxWidth},iw)'` : "-2"; |
| 153 | + const h = opts.maxHeight ? `'min(${opts.maxHeight},ih)'` : "-2"; |
| 154 | + |
| 155 | + // Ensure dimensions are divisible by 2 for H.264 |
| 156 | + return `scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2`; |
| 157 | +} |
| 158 | + |
| 159 | +function buildFfmpegArgs( |
| 160 | + inputPath: string, |
| 161 | + outputPath: string, |
| 162 | + opts: CompressOptions, |
| 163 | + pass: 1 | 2 | "crf", |
| 164 | + passLogPrefix?: string, |
| 165 | +): string[] { |
| 166 | + const args: string[] = ["-y", "-i", inputPath]; |
| 167 | + |
| 168 | + // Video codec |
| 169 | + args.push("-c:v", "libx264"); |
| 170 | + |
| 171 | + // Preset |
| 172 | + args.push("-preset", opts.preset || "medium"); |
| 173 | + |
| 174 | + // Scale filter |
| 175 | + const scaleFilter = buildScaleFilter(opts); |
| 176 | + if (scaleFilter) { |
| 177 | + args.push("-vf", scaleFilter); |
| 178 | + } |
| 179 | + |
| 180 | + if (pass === "crf") { |
| 181 | + // Single-pass CRF mode |
| 182 | + args.push("-crf", String(opts.crf ?? 23)); |
| 183 | + // Audio |
| 184 | + args.push("-c:a", "aac", "-b:a", opts.audioBitrate || "128k"); |
| 185 | + // Faststart for web playback |
| 186 | + args.push("-movflags", "+faststart"); |
| 187 | + args.push(outputPath); |
| 188 | + } else if (pass === 1) { |
| 189 | + // Two-pass: first pass |
| 190 | + args.push("-b:v", opts.videoBitrate || "2M"); |
| 191 | + args.push("-pass", "1"); |
| 192 | + args.push("-passlogfile", passLogPrefix!); |
| 193 | + args.push("-an"); // No audio in first pass |
| 194 | + args.push("-f", "null"); |
| 195 | + args.push(process.platform === "win32" ? "NUL" : "/dev/null"); |
| 196 | + } else { |
| 197 | + // Two-pass: second pass |
| 198 | + args.push("-b:v", opts.videoBitrate || "2M"); |
| 199 | + args.push("-pass", "2"); |
| 200 | + args.push("-passlogfile", passLogPrefix!); |
| 201 | + args.push("-c:a", "aac", "-b:a", opts.audioBitrate || "128k"); |
| 202 | + args.push("-movflags", "+faststart"); |
| 203 | + args.push(outputPath); |
| 204 | + } |
| 205 | + |
| 206 | + return args; |
| 207 | +} |
| 208 | + |
| 209 | +// --------------------------------------------------------------------------- |
| 210 | +// Main: compressVideo |
| 211 | +// --------------------------------------------------------------------------- |
| 212 | + |
| 213 | +/** |
| 214 | + * Compress a video using FFmpeg. |
| 215 | + * |
| 216 | + * @param input - A video URL (string) or a Buffer containing the video data. |
| 217 | + * @param options - Compression options. |
| 218 | + * @returns CompressResult with the compressed buffer and stats. |
| 219 | + */ |
| 220 | +export async function compressVideo( |
| 221 | + input: string | Buffer, |
| 222 | + options: CompressOptions = {}, |
| 223 | +): Promise<CompressResult> { |
| 224 | + const startTime = Date.now(); |
| 225 | + |
| 226 | + // 1. Resolve input to a Buffer |
| 227 | + let inputBuffer: Buffer; |
| 228 | + if (typeof input === "string") { |
| 229 | + inputBuffer = await downloadUrl(input); |
| 230 | + } else { |
| 231 | + inputBuffer = input; |
| 232 | + } |
| 233 | + |
| 234 | + const originalSize = inputBuffer.length; |
| 235 | + log(`Original video size: ${(originalSize / 1024 / 1024).toFixed(2)} MB`); |
| 236 | + |
| 237 | + // 2. Check FFmpeg availability |
| 238 | + if (!isFfmpegAvailable()) { |
| 239 | + warn("FFmpeg is not available — returning original video uncompressed."); |
| 240 | + return { |
| 241 | + buffer: inputBuffer, |
| 242 | + originalSize, |
| 243 | + compressedSize: originalSize, |
| 244 | + ratio: 1, |
| 245 | + durationMs: Date.now() - startTime, |
| 246 | + }; |
| 247 | + } |
| 248 | + |
| 249 | + // 3. Write input to temp file |
| 250 | + const { inputPath, outputPath, passLogPrefix, cleanup } = makeTempDir(); |
| 251 | + |
| 252 | + try { |
| 253 | + writeFileSync(inputPath, inputBuffer); |
| 254 | + |
| 255 | + // 4. Decide encoding strategy |
| 256 | + const useTwoPass = !!options.videoBitrate; |
| 257 | + |
| 258 | + if (useTwoPass) { |
| 259 | + log("Running two-pass encode …"); |
| 260 | + |
| 261 | + // Pass 1 |
| 262 | + const pass1Args = buildFfmpegArgs(inputPath, outputPath, options, 1, passLogPrefix); |
| 263 | + log("Pass 1:", "ffmpeg", pass1Args.join(" ")); |
| 264 | + execFileSync("ffmpeg", pass1Args, { |
| 265 | + stdio: ["ignore", "ignore", "pipe"], |
| 266 | + timeout: 600_000, // 10 min |
| 267 | + }); |
| 268 | + |
| 269 | + // Pass 2 |
| 270 | + const pass2Args = buildFfmpegArgs(inputPath, outputPath, options, 2, passLogPrefix); |
| 271 | + log("Pass 2:", "ffmpeg", pass2Args.join(" ")); |
| 272 | + execFileSync("ffmpeg", pass2Args, { |
| 273 | + stdio: ["ignore", "ignore", "pipe"], |
| 274 | + timeout: 600_000, |
| 275 | + }); |
| 276 | + } else { |
| 277 | + log("Running CRF encode …"); |
| 278 | + const crfArgs = buildFfmpegArgs(inputPath, outputPath, options, "crf"); |
| 279 | + log("ffmpeg", crfArgs.join(" ")); |
| 280 | + execFileSync("ffmpeg", crfArgs, { |
| 281 | + stdio: ["ignore", "ignore", "pipe"], |
| 282 | + timeout: 600_000, |
| 283 | + }); |
| 284 | + } |
| 285 | + |
| 286 | + // 5. Read compressed output |
| 287 | + const compressedBuffer = readFileSync(outputPath); |
| 288 | + const compressedSize = compressedBuffer.length; |
| 289 | + const ratio = compressedSize / originalSize; |
| 290 | + const durationMs = Date.now() - startTime; |
| 291 | + |
| 292 | + log( |
| 293 | + `Compression complete: ${(compressedSize / 1024 / 1024).toFixed(2)} MB ` + |
| 294 | + `(${(ratio * 100).toFixed(1)}% of original) in ${(durationMs / 1000).toFixed(1)}s`, |
| 295 | + ); |
| 296 | + |
| 297 | + return { |
| 298 | + buffer: compressedBuffer, |
| 299 | + originalSize, |
| 300 | + compressedSize, |
| 301 | + ratio, |
| 302 | + durationMs, |
| 303 | + }; |
| 304 | + } catch (error) { |
| 305 | + const errMsg = error instanceof Error ? error.message : String(error); |
| 306 | + warn(`Compression failed: ${errMsg} — returning original video.`); |
| 307 | + return { |
| 308 | + buffer: inputBuffer, |
| 309 | + originalSize, |
| 310 | + compressedSize: originalSize, |
| 311 | + ratio: 1, |
| 312 | + durationMs: Date.now() - startTime, |
| 313 | + }; |
| 314 | + } finally { |
| 315 | + // 6. Clean up temp files |
| 316 | + cleanup(); |
| 317 | + } |
| 318 | +} |
| 319 | + |
| 320 | +// --------------------------------------------------------------------------- |
| 321 | +// getVideoMetadata |
| 322 | +// --------------------------------------------------------------------------- |
| 323 | + |
| 324 | +/** |
| 325 | + * Get video metadata using ffprobe. |
| 326 | + * |
| 327 | + * @param input - A file path, URL, or Buffer. |
| 328 | + * @returns VideoMetadata with duration, resolution, codecs, etc. |
| 329 | + */ |
| 330 | +export async function getVideoMetadata(input: string | Buffer): Promise<VideoMetadata> { |
| 331 | + if (!isFfprobeAvailable()) { |
| 332 | + throw new Error(`${LOG_PREFIX} ffprobe is not available`); |
| 333 | + } |
| 334 | + |
| 335 | + let filePath: string; |
| 336 | + let cleanupFn: (() => void) | null = null; |
| 337 | + |
| 338 | + if (Buffer.isBuffer(input)) { |
| 339 | + const { inputPath, cleanup } = makeTempDir(); |
| 340 | + writeFileSync(inputPath, input); |
| 341 | + filePath = inputPath; |
| 342 | + cleanupFn = cleanup; |
| 343 | + } else if (input.startsWith("http://") || input.startsWith("https://")) { |
| 344 | + // ffprobe can read URLs directly, but downloading is more reliable |
| 345 | + const buf = await downloadUrl(input); |
| 346 | + const { inputPath, cleanup } = makeTempDir(); |
| 347 | + writeFileSync(inputPath, buf); |
| 348 | + filePath = inputPath; |
| 349 | + cleanupFn = cleanup; |
| 350 | + } else { |
| 351 | + filePath = input; |
| 352 | + } |
| 353 | + |
| 354 | + try { |
| 355 | + const probeArgs = [ |
| 356 | + "-v", |
| 357 | + "quiet", |
| 358 | + "-print_format", |
| 359 | + "json", |
| 360 | + "-show_format", |
| 361 | + "-show_streams", |
| 362 | + filePath, |
| 363 | + ]; |
| 364 | + |
| 365 | + const result = execFileSync("ffprobe", probeArgs, { |
| 366 | + encoding: "utf-8", |
| 367 | + timeout: 30_000, |
| 368 | + }); |
| 369 | + |
| 370 | + const data = JSON.parse(result); |
| 371 | + |
| 372 | + const videoStream = data.streams?.find( |
| 373 | + (s: { codec_type: string }) => s.codec_type === "video", |
| 374 | + ); |
| 375 | + const audioStream = data.streams?.find( |
| 376 | + (s: { codec_type: string }) => s.codec_type === "audio", |
| 377 | + ); |
| 378 | + const format = data.format || {}; |
| 379 | + |
| 380 | + return { |
| 381 | + duration: parseFloat(format.duration || "0"), |
| 382 | + width: videoStream?.width || 0, |
| 383 | + height: videoStream?.height || 0, |
| 384 | + videoCodec: videoStream?.codec_name || "unknown", |
| 385 | + audioCodec: audioStream?.codec_name || "unknown", |
| 386 | + bitrate: parseInt(format.bit_rate || "0", 10), |
| 387 | + fileSize: parseInt(format.size || "0", 10), |
| 388 | + }; |
| 389 | + } finally { |
| 390 | + if (cleanupFn) cleanupFn(); |
| 391 | + } |
| 392 | +} |
0 commit comments