Fix muxing

This commit is contained in:
2026-05-15 04:10:06 +02:00
parent 05faf1ce3b
commit 6deb883472
26 changed files with 662 additions and 169 deletions

View File

@@ -5,6 +5,7 @@ export interface OutputOptions {
audio: boolean;
subtitles: boolean;
muxed: boolean;
muxMode: 'separate' | 'mixed';
}
export interface Job {

View File

@@ -379,6 +379,7 @@ el('download-url').addEventListener('click', () => {
audio: fd.get('output-audio') === 'on',
subtitles: fd.get('output-subtitles') === 'on',
muxed: fd.get('output-muxed') === 'on',
muxMode: (fd.get('mux-mode') as string) === 'mixed' ? 'mixed' : 'separate',
};
if (config.visionProvider) {
@@ -404,6 +405,7 @@ el('download-url').addEventListener('click', () => {
delete config['output-audio'];
delete config['output-subtitles'];
delete config['output-muxed'];
delete config['mux-mode'];
try {
const data = await apiJson<{ job: Job }>('POST', '/api/jobs', {

View File

@@ -99,7 +99,11 @@
<legend>Output options</legend>
<label><input type="checkbox" name="output-audio" checked> Audio description track</label>
<label><input type="checkbox" name="output-subtitles" checked> Subtitles (SRT + VTT)</label>
<label><input type="checkbox" name="output-muxed"> Muxed video (MKV with 2nd audio track)</label>
<label><input type="checkbox" name="output-muxed"> Muxed video output (MKV)</label>
<div class="mux-mode-group" style="margin-left: 1.5em;">
<label><input type="radio" name="mux-mode" value="separate" checked> Separate description track (player must support track switching)</label>
<label><input type="radio" name="mux-mode" value="mixed"> Mixed into main audio with ducking (works in any player)</label>
</div>
</fieldset>
<details>

View File

@@ -2,7 +2,7 @@ import { Router, Request, Response } from 'express';
import path from 'path';
import fs from 'fs';
import { JobManager } from '../services/jobManager';
import { getJob } from '../db/jobStore';
import { getJob, OutputOptions } from '../db/jobStore';
function getParam(req: Request, name: string): string {
const val = req.params[name];
@@ -153,10 +153,13 @@ export function createJobsRouter(jobManager: JobManager): Router {
filePath = format === 'vtt' ? job.output_subtitles_vtt : job.output_subtitles_srt;
filename = `${path.basename(job.video_filename, path.extname(job.video_filename))}_description.${format}`;
break;
case 'muxed':
case 'muxed': {
const opts = JSON.parse(job.output_options || '{}') as Partial<OutputOptions>;
const suffix = opts.muxMode === 'mixed' ? '_described_mixed' : '_described';
filePath = job.output_muxed;
filename = `${path.basename(job.video_filename, path.extname(job.video_filename))}_described.mkv`;
filename = `${path.basename(job.video_filename, path.extname(job.video_filename))}${suffix}.mkv`;
break;
}
default:
res.status(400).json({ error: 'Invalid download type' });
return;

View File

@@ -6,7 +6,7 @@ import {
} from '../db/jobStore';
import { generateAudioDescriptionFromOptions } from '../../utils/processor';
import { generateSRT, generateVTT } from './subtitleGenerator';
import { muxAudioDescription } from './muxer';
import { muxAudioDescription, muxMixedAudioDescription } from './muxer';
import { getDefaultConfig, Config } from '../../config/config';
import { AudioSegment, BatchContext } from '../../interfaces';
import { getVideoDuration, cleanupTempFiles } from '../../utils/mediaUtils';
@@ -79,7 +79,8 @@ export class JobManager {
const opts: OutputOptions = {
audio: outputOptions.audio !== false,
subtitles: outputOptions.subtitles !== false,
muxed: outputOptions.muxed || false
muxed: outputOptions.muxed || false,
muxMode: outputOptions.muxMode === 'mixed' ? 'mixed' : 'separate'
};
return createJob(videoPath, filename, mergedConfig, opts);
@@ -307,8 +308,16 @@ export class JobManager {
}
if (outputOptions.muxed && fs.existsSync(outputAudio)) {
const muxedPath = path.join(outputDir, `${baseName}_described.mkv`);
muxAudioDescription(job.video_path, outputAudio, muxedPath);
const isMixed = outputOptions.muxMode === 'mixed';
const muxedPath = path.join(
outputDir,
`${baseName}${isMixed ? '_described_mixed' : '_described'}.mkv`
);
if (isMixed) {
muxMixedAudioDescription(job.video_path, outputAudio, muxedPath);
} else {
muxAudioDescription(job.video_path, outputAudio, muxedPath);
}
outputMuxed = muxedPath;
}

View File

@@ -44,3 +44,52 @@ export function muxAudioDescription(
throw new Error(`mux: ffmpeg exited ${result.status}: ${tail || '(no stderr)'}`);
}
}
export function muxMixedAudioDescription(
videoPath: string,
audioPath: string,
outputPath: string
): void {
if (!fs.existsSync(videoPath)) {
throw new Error(`mux: video not found: ${videoPath}`);
}
if (!fs.existsSync(audioPath)) {
throw new Error(`mux: audio not found: ${audioPath}`);
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
// Sidechain-ducked mix: original audio dips when the AD track is speaking,
// then both are summed into a single output audio stream. The AD track is
// already a full-length file that is silent between description segments
// (built by combineAudioSegments), so asplit gives us one copy to drive the
// sidechain detector and another to mix in on top.
const filterGraph =
'[1:a]asplit=2[ad_mix][ad_sc];' +
'[0:a][ad_sc]sidechaincompress=threshold=0.03:ratio=20:attack=5:release=300:level_sc=2[ducked];' +
'[ducked][ad_mix]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]';
const args = [
'-y',
'-v', 'error',
'-i', videoPath,
'-i', audioPath,
'-filter_complex', filterGraph,
'-map', '0:v',
'-map', '[aout]',
'-c:v', 'copy',
'-c:a', 'aac',
'-b:a', '192k',
outputPath,
];
const result = spawnSync('ffmpeg', args, { shell: false, encoding: 'utf-8' });
if (result.error) {
throw new Error(`mux: ffmpeg failed to start: ${result.error.message}`);
}
if (result.status !== 0) {
const tail = (result.stderr || '').trim().split('\n').slice(-5).join(' | ');
throw new Error(`mux: ffmpeg exited ${result.status}: ${tail || '(no stderr)'}`);
}
}