Fix muxing
This commit is contained in:
@@ -5,6 +5,7 @@ export interface OutputOptions {
|
||||
audio: boolean;
|
||||
subtitles: boolean;
|
||||
muxed: boolean;
|
||||
muxMode: 'separate' | 'mixed';
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user