dragonflight/services/worker/src/ffmpeg/executor.js
Claude 6412b5c252 fix(worker): conform — preserve audio + map ProRes/DNxHR codecs
Three cooperating bugs left the rendered output silent and in the
wrong codec:

1. executor.js trimSegment used `-frames:v` with no audio mapping.
   ffmpeg dropped the audio track on each segment before they reached
   the concat step. Add `-c:a copy -shortest` so each segment carries
   its original audio.

2. conform.js audioFlag was `audio === 'include' ? aac : -an`. The
   panel's v2.2.1 defaults send `audio: 'broadcast'`, which didn't
   match 'include' → `-an` explicitly stripped audio at the encode
   step. Switch to the opposite default: only an explicit 'none' or
   'off' disables audio; everything else gets AAC 320k @ 48kHz.

3. conform.js video codec map only matched `codec === 'prores'`. The
   panel sends `'prores_hq'` (and the conform slide panel can send
   `'prores_4444'` / `'dnxhr_hq'`). All of those fell through to
   libx264 and silently rendered H.264 instead of the requested codec.
   Add a real codec map with the right prores_ks profiles (3=HQ,
   4=4444) and DNxHR. Skip -crf for ProRes since the profile encodes
   quality.

The asset-row metadata's `codec` column is normalised the same way so
the new asset record matches what was actually written.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:32:49 -04:00

243 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
// `-frames:v` bounds the video output but says nothing about audio. Without
// -c:a copy + -shortest the audio track is either dropped or runs past
// the trimmed video. We need audio in the final concat output, so passthrough
// the audio codec and stop on the shortest stream.
const args = [
'-ss', inSeconds.toString(),
'-i', inputPath,
'-frames:v', frameCount.toString(),
'-c:a', 'copy',
'-shortest',
'-start_number', '0',
'-y',
outputPath,
];
await runFFmpeg(args);
};
// 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);
};