dragonflight/services/worker/src/ffmpeg/executor.js

238 lines
7 KiB
JavaScript
Raw Normal View History

import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
export const runFFmpeg = async (args, options = {}) => {
const { stdio = 'pipe', ...otherOptions } = options;
try {
const { stdout, stderr } = await execFileAsync('ffmpeg', args, {
stdio,
...otherOptions,
});
return { stdout, stderr };
} catch (error) {
throw new Error(`FFmpeg error: ${error.message}\nStderr: ${error.stderr}`);
}
};
const runFFprobe = async (args) => {
try {
const { stdout } = await execFileAsync('ffprobe', args);
return { stdout };
} catch (error) {
throw new Error(`FFprobe error: ${error.message}\nStderr: ${error.stderr}`);
}
};
export const getMediaDuration = async (inputPath) => {
const args = [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
inputPath,
];
const { stdout } = await runFFprobe(args);
return parseFloat(stdout.trim());
};
/**
* Return structured media metadata for an input file.
* Result shape:
* { fps, codec, resolution, durationMs, fileSizeBytes }
* Any field may be null if ffprobe cannot determine it.
*/
export const getMediaInfo = async (inputPath) => {
const args = [
'-v', 'quiet',
'-print_format', 'json',
'-show_streams',
'-show_format',
inputPath,
];
const { stdout } = await runFFprobe(args);
const info = JSON.parse(stdout);
const videoStream = (info.streams || []).find(s => s.codec_type === 'video');
const fmt = info.format || {};
let fps = null;
if (videoStream?.r_frame_rate) {
const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
if (den > 0) fps = Math.round((num / den) * 1000) / 1000;
}
const audioStreams = (info.streams || []).filter(s => s.codec_type === 'audio');
const hasAudio = audioStreams.length > 0;
const audioMetadata = audioStreams.map(s => {
const bitDepth = s.bits_per_raw_sample
? parseInt(s.bits_per_raw_sample, 10)
: s.bit_depth
? parseInt(s.bit_depth, 10)
: null;
return {
index: s.index ?? 0,
codec: s.codec_name || null,
channels: s.channels ? parseInt(s.channels, 10) : null,
channel_layout: s.channel_layout || null,
sample_rate: s.sample_rate ? parseInt(s.sample_rate, 10) : null,
bit_depth: bitDepth,
bit_rate: s.bit_rate ? parseInt(s.bit_rate, 10) : null,
language: s.tags?.language || null,
title: s.tags?.title || null,
disposition: s.disposition || {},
};
});
return {
fps,
codec: videoStream?.codec_name || null,
resolution: videoStream ? `${videoStream.width}x${videoStream.height}` : null,
durationMs: fmt.duration ? Math.round(parseFloat(fmt.duration) * 1000) : null,
fileSizeBytes: fmt.size ? parseInt(fmt.size, 10) : null,
hasAudio,
audioMetadata: audioMetadata.length > 0 ? audioMetadata : null,
};
};
export const extractFrameAtTime = async (inputPath, outputPath, timeCode) => {
const args = [
'-ss', timeCode,
'-i', inputPath,
'-vframes', '1',
'-q:v', '2',
'-y',
outputPath,
];
await runFFmpeg(args);
};
const NVENC_CODECS = new Set(['h264_nvenc', 'hevc_nvenc']);
const VAAPI_CODECS = new Set(['h264_vaapi', 'hevc_vaapi']);
const HW_CODECS = new Set([...NVENC_CODECS, ...VAAPI_CODECS]);
export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
const {
videoCodec = 'libx264',
videoPreset = 'fast',
videoBitrate = '750k', // average/target for VBR
videoMinRate = null, // VBR minimum e.g. '500k'
videoMaxRate = null, // VBR maximum e.g. '1000k'
videoBufSize = null, // VBR buffer e.g. '2000k' (2× maxrate recommended)
rateControl = null, // 'cbr' | 'vbr' | 'cqp' — optional override for HW codecs
audioCodec = 'aac',
audioBitrate = '128k',
hasAudio = true,
} = options;
// libx264 / yuv420p require even dimensions.
const vf = "scale='trunc(iw/2)*2:trunc(ih/2)*2',format=yuv420p";
const args = [
'-analyzeduration', '100M',
'-probesize', '100M',
'-i', inputPath,
'-vf', vf,
'-c:v', videoCodec,
'-preset', videoPreset,
'-b:v', videoBitrate,
];
// VBR min/max/bufsize for libx264 ABR mode.
// When minrate+maxrate are set, libx264 operates in ABR with hard limits
// rather than strict CBR — quality varies per-scene within the envelope.
if (videoMinRate) args.push('-minrate', videoMinRate);
if (videoMaxRate) args.push('-maxrate', videoMaxRate);
if (videoBufSize) args.push('-bufsize', videoBufSize);
// NVENC/VAAPI hardware rate control flags
if (rateControl) {
if (NVENC_CODECS.has(videoCodec)) {
args.push('-rc', rateControl);
} else if (VAAPI_CODECS.has(videoCodec)) {
args.push('-rc_mode', rateControl.toUpperCase());
}
}
if (hasAudio) {
args.push('-c:a', audioCodec, '-b:a', audioBitrate);
} else {
args.push('-an');
}
args.push('-movflags', '+faststart', '-y', outputPath);
await runFFmpeg(args);
};
export const isHwCodec = (codec) => HW_CODECS.has(codec);
// Single-frame poster — used as a fallback "proxy" for still-image assets
// so the library can show them without a transcoded video.
export const transcodeImage = async (inputPath, outputPath) => {
await runFFmpeg([
'-i', inputPath,
'-vf', "scale='min(1920,iw)':-2",
'-q:v', '3',
'-y', outputPath,
]);
};
export const trimSegment = async (inputPath, outputPath, inPoint, outPoint) => {
const info = await getMediaInfo(inputPath);
const fps = info.fps || 30;
const inSeconds = inPoint / fps;
const frameCount = outPoint - inPoint;
const args = [
'-ss', inSeconds.toString(),
'-i', inputPath,
'-frames:v', frameCount.toString(),
'-start_number', '0',
'-y',
outputPath,
];
await runFFmpeg(args);
};
fix(player): stitch S3 ranges around RustFS empty-body bug (#143) RustFS returns empty bodies for ranged GETs whose start offset is past ~5.9 MB on single-file proxy MP4s. HEAD reports correct size, full GET (`bytes=0-`) works, but `bytes=8179166-` comes back 206 + correct Content-Range header with zero bytes. Confirmed via direct S3 probe against broadcastmgmt.cloud/dragonmam (see scratch tests). Workaround in mam-api `GET /api/v1/assets/:id/video` until the proxy worker emits HLS (planned v1.2.1): - HEAD the object first to learn total size (also gives ETag / Last-Modified for conditional requests). - No-Range / unparseable-Range / pre-EOF requests \u2192 plain pipe. - Parsed `bytes=N-M` requests below RUSTFS_RANGE_SAFE_START (default 5_500_000) \u2192 direct ranged GET, RustFS handles fine. - Anything reaching into the broken zone \u2192 stream from offset 0, drop bytes below start, stop at end. Memory stays flat; extra bandwidth = (end+1 - requested-size) per seek. - Genuinely out-of-range \u2192 416 with Cache-Control: no-store so the browser doesn't poison its cache. Also stashes (not yet wired up) the HLS pieces we'll need for the follow-up: `segmentToHls` ffmpeg helper + `uploadDirectoryToS3` worker s3 helper. Harmless additions; not referenced by any code path yet. Confirmed against the affected asset (a72aaa03-...): bytes=0-100k + 50% +100k native pass-through; 70% +100k and near-EOF previously hung the browser, now stream correctly via the stitched path. Refs #143.
2026-05-26 22:38:42 -04:00
// Segment an existing MP4/MOV into HLS (fMP4) — init.mp4 + segment_*.m4s +
// playlist.m3u8. Keeps the original codec (no re-encode) so this is cheap to
// run after the proxy transcode. fMP4 segments stay <5 MB at our proxy
// bitrate, which sidesteps RustFS's broken byte-range path on large objects.
export const segmentToHls = async (inputPath, outputDir, options = {}) => {
const {
segmentDurationSec = 4,
playlistName = 'playlist.m3u8',
initName = 'init.mp4',
segmentPattern = 'segment_%05d.m4s',
} = options;
const args = [
'-i', inputPath,
'-c', 'copy',
'-f', 'hls',
'-hls_time', String(segmentDurationSec),
'-hls_playlist_type', 'vod',
'-hls_segment_type', 'fmp4',
'-hls_flags', 'independent_segments',
'-hls_fmp4_init_filename', initName,
'-hls_segment_filename', `${outputDir}/${segmentPattern}`,
'-y',
`${outputDir}/${playlistName}`,
];
await runFFmpeg(args);
};
export const concatSegments = async (segmentListFile, outputPath) => {
const args = [
'-f', 'concat',
'-safe', '0',
'-i', segmentListFile,
'-c', 'copy',
'-y',
outputPath,
];
await runFFmpeg(args);
};