add buildPipelineCommand: single FFmpeg command for sub extraction, audio cleanup, transcode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 01:46:26 +01:00
parent ecb0732185
commit 97e60dbfc5

View File

@@ -361,6 +361,110 @@ export function buildExtractOnlyCommand(
return parts.join(' ');
}
/**
* Build a single FFmpeg command that:
* 1. Extracts subtitles to sidecar files
* 2. Remuxes with reordered/filtered audio
* 3. Transcodes incompatible audio codecs
*/
export function buildPipelineCommand(
item: MediaItem,
streams: MediaStream[],
decisions: (StreamDecision & { stream?: MediaStream })[]
): { command: string; extractedFiles: Array<{ path: string; language: string | null; codec: string | null; is_forced: number; is_hearing_impaired: number }> } {
const inputPath = item.file_path;
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
const basePath = inputPath.replace(/\.[^.]+$/, '');
// --- Subtitle extraction outputs ---
const extractionEntries = computeExtractionEntries(streams, basePath);
const subOutputArgs: string[] = [];
for (const e of extractionEntries) {
subOutputArgs.push(`-map 0:s:${e.typeIdx}`, `-c:s ${e.codecArg}`, shellQuote(e.outPath));
}
// --- Kept streams for remuxed output ---
// Enrich decisions with stream data
const enriched = decisions.map(d => {
const stream = d.stream ?? streams.find(s => s.id === d.stream_id);
return { ...d, stream: stream! };
}).filter(d => d.action === 'keep' && d.stream);
// Sort by type priority then target_index
const typeOrder: Record<string, number> = { Video: 0, Audio: 1, Data: 2, EmbeddedImage: 3 };
enriched.sort((a, b) => {
const ta = typeOrder[a.stream.type] ?? 9;
const tb = typeOrder[b.stream.type] ?? 9;
if (ta !== tb) return ta - tb;
return (a.target_index ?? 0) - (b.target_index ?? 0);
});
// Build -map flags
const maps = buildMaps(streams, enriched.map(d => ({ stream: d.stream, dec: d })));
// Build per-stream codec flags
const codecFlags: string[] = ['-c:v copy'];
let audioIdx = 0;
for (const d of enriched) {
if (d.stream.type === 'Audio') {
if (d.transcode_codec) {
codecFlags.push(`-c:a:${audioIdx} ${d.transcode_codec}`);
// For EAC3, set a reasonable bitrate based on channel count
if (d.transcode_codec === 'eac3') {
const bitrate = (d.stream.channels ?? 2) >= 6 ? '640k' : '256k';
codecFlags.push(`-b:a:${audioIdx} ${bitrate}`);
}
} else {
codecFlags.push(`-c:a:${audioIdx} copy`);
}
audioIdx++;
}
}
// If no audio transcoding, simplify to -c copy (covers video + audio)
const hasTranscode = enriched.some(d => d.transcode_codec);
const finalCodecFlags = hasTranscode ? codecFlags : ['-c copy'];
// Disposition + metadata flags for audio
const streamFlags = buildStreamFlags(enriched.map(d => ({ stream: d.stream, dec: d })));
// Assemble command
const parts: string[] = [
'ffmpeg', '-y',
'-i', shellQuote(inputPath),
];
// Subtitle extraction outputs first
parts.push(...subOutputArgs);
// Map flags for remuxed output
parts.push(...maps);
// Codec flags
parts.push(...finalCodecFlags);
// Stream flags (disposition, metadata)
parts.push(...streamFlags);
// Output file
parts.push(shellQuote(tmpPath));
const command = parts.join(' ')
+ ` && mv ${shellQuote(tmpPath)} ${shellQuote(inputPath)}`;
return {
command,
extractedFiles: extractionEntries.map(e => ({
path: e.outPath,
language: e.stream.language,
codec: e.stream.codec,
is_forced: e.stream.is_forced ? 1 : 0,
is_hearing_impaired: e.stream.is_hearing_impaired ? 1 : 0,
})),
};
}
/** Safely quote a path for shell usage. */
export function shellQuote(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`;