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:
@@ -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, "'\\''")}'`;
|
||||
|
||||
Reference in New Issue
Block a user