From 8bf847503d3df0d4a37e025b793abdc0dd9b25c2 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 13 Aug 2026 23:19:22 -0700 Subject: [PATCH] fix(media): tolerate slow MKV audio extraction (#195) --- changes/audio-generation-network-mounts.md | 4 + docs-site/troubleshooting.md | 4 +- src/media-generator.test.ts | 87 ++++++++++++++++++++-- src/media-generator.ts | 40 ++++++++-- 4 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 changes/audio-generation-network-mounts.md diff --git a/changes/audio-generation-network-mounts.md b/changes/audio-generation-network-mounts.md new file mode 100644 index 00000000..30221c75 --- /dev/null +++ b/changes/audio-generation-network-mounts.md @@ -0,0 +1,4 @@ +type: fixed +area: Anki media + +- Fixed sentence-audio generation timing out on slow network-mounted MKV files with many subtitle and font-attachment streams. Selected audio tracks now use bounded FFmpeg probing and a two-minute extraction budget, and missing output reports a clear FFmpeg error instead of raw `ENOENT`. diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index f383474a..e88b010b 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -171,7 +171,9 @@ Without FFmpeg, card creation still works but audio and image fields will be emp **Audio or screenshot generation hangs** -Media generation has a 30-second timeout (60 seconds for animated AVIF). If your video file is on a slow network mount or the codec requires software decoding, generation may time out. Try: +Audio extraction has a 2-minute timeout. SubMiner also limits FFmpeg probing when mpv provides the selected audio stream, which avoids scanning unrelated subtitle and font-attachment streams in large MKV files. Screenshots retain a 30-second timeout, and animated AVIF uses 60 seconds. + +If your video file is on a slow or unresponsive network mount, generation may still time out. Try: - Using a local copy of the video file. - Reducing `ankiConnect.media.imageQuality` or switching from `avif` to `static` image type. diff --git a/src/media-generator.test.ts b/src/media-generator.test.ts index 752eb725..68a3fa30 100644 --- a/src/media-generator.test.ts +++ b/src/media-generator.test.ts @@ -4,13 +4,18 @@ import * as os from 'node:os'; import * as path from 'node:path'; import test from 'node:test'; -import { buildAnimatedImageVideoFilter, MediaGenerator } from './media-generator'; +import { + AUDIO_GENERATION_TIMEOUT_MS, + buildAnimatedImageVideoFilter, + MediaGenerator, + type MediaGeneratorOptions, +} from './media-generator'; async function withStubbedFfmpeg( run: (generator: MediaGenerator, argsPath: string) => Promise, - options: { - logDebug?: (message: string) => void; - now?: () => number; + options: MediaGeneratorOptions = {}, + stubOptions: { + skipOutput?: boolean; } = {}, ): Promise { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-generator-test-')); @@ -31,7 +36,9 @@ async function withStubbedFfmpeg( '}', "fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, JSON.stringify(args), 'utf8');", 'const outputPath = args.at(-1);', - "fs.writeFileSync(outputPath, 'avif', 'utf8');", + "if (process.env.SUBMINER_TEST_FFMPEG_SKIP_OUTPUT !== '1') {", + " fs.writeFileSync(outputPath, 'avif', 'utf8');", + '}', ].join('\n'), 'utf8', ); @@ -46,8 +53,14 @@ async function withStubbedFfmpeg( const originalPath = process.env.PATH; const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS; + const originalSkipOutput = process.env.SUBMINER_TEST_FFMPEG_SKIP_OUTPUT; process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`; process.env.SUBMINER_TEST_FFMPEG_ARGS = argsPath; + if (stubOptions.skipOutput) { + process.env.SUBMINER_TEST_FFMPEG_SKIP_OUTPUT = '1'; + } else { + delete process.env.SUBMINER_TEST_FFMPEG_SKIP_OUTPUT; + } const generator = new MediaGenerator(tempDir, options); try { @@ -60,6 +73,11 @@ async function withStubbedFfmpeg( } else { process.env.SUBMINER_TEST_FFMPEG_ARGS = originalArgsPath; } + if (originalSkipOutput === undefined) { + delete process.env.SUBMINER_TEST_FFMPEG_SKIP_OUTPUT; + } else { + process.env.SUBMINER_TEST_FFMPEG_SKIP_OUTPUT = originalSkipOutput; + } fs.rmSync(root, { recursive: true, force: true }); } } @@ -316,6 +334,65 @@ test('generateAudio keeps explicit audio stream maps for normal media paths', as }); }); +test('generateAudio bounds probing when the selected local audio stream is known', async () => { + await withStubbedFfmpeg(async (generator, argsPath) => { + await generator.generateAudio('/video.mkv', 10, 12, 0, 2); + + const args = readFfmpegArgs(argsPath); + const inputIndex = args.indexOf('-i'); + assert.ok(args.indexOf('-probesize') > -1); + assert.ok(args.indexOf('-probesize') < inputIndex); + assert.equal(args[args.indexOf('-probesize') + 1], '32768'); + assert.ok(args.indexOf('-analyzeduration') < inputIndex); + assert.equal(args[args.indexOf('-analyzeduration') + 1], '0'); + }); +}); + +test('generateAudio retains normal probing for non-Matroska local media', async () => { + await withStubbedFfmpeg(async (generator, argsPath) => { + await generator.generateAudio('/video.mp4', 10, 12, 0, 2); + + const args = readFfmpegArgs(argsPath); + assert.equal(args.includes('-probesize'), false); + assert.equal(args.includes('-analyzeduration'), false); + }); +}); + +test('generateAudio retains a two-minute extraction timeout', async () => { + let observedTimeout: number | undefined; + + await withStubbedFfmpeg( + async (generator) => { + await generator.generateAudio('/video.mp4', 10, 12); + }, + { + execFile: (_file, args, options, callback) => { + observedTimeout = options.timeout; + const outputPath = args.at(-1); + assert.ok(outputPath); + fs.writeFileSync(outputPath, 'mp3', 'utf8'); + queueMicrotask(() => callback(null)); + }, + }, + ); + + assert.equal(AUDIO_GENERATION_TIMEOUT_MS, 120_000); + assert.equal(observedTimeout, AUDIO_GENERATION_TIMEOUT_MS); +}); + +test('generateAudio reports when ffmpeg exits without creating output', async () => { + await withStubbedFfmpeg( + async (generator) => { + await assert.rejects( + generator.generateAudio('/video.mp4', 10, 12), + /FFmpeg audio generation failed: FFmpeg exited without creating an output file/, + ); + }, + {}, + { skipOutput: true }, + ); +}); + test('generateAudio debug-logs cached input and completion timing', async () => { const logs: string[] = []; const times = [1000, 1052]; diff --git a/src/media-generator.ts b/src/media-generator.ts index 79787b5b..037ae50f 100644 --- a/src/media-generator.ts +++ b/src/media-generator.ts @@ -26,9 +26,17 @@ import { normalizeMediaInput, type MediaInput } from './media-input'; const log = createLogger('media'); const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-23:TP=-2:LRA=11'; const AUDIO_AMPLIFICATION_LIMITER_FILTER = 'alimiter=limit=0.891251:level=false'; +export const AUDIO_GENERATION_TIMEOUT_MS = 120_000; export type { MediaInput, MediaInputOptions } from './media-input'; +type MediaGeneratorExecFile = ( + file: string, + args: readonly string[], + options: { timeout: number }, + callback: (error: ExecFileException | null) => void, +) => void; + function normalizeAnimatedImageFps(fps: number | undefined): number { const fallbackFps = 10; const safeFps = typeof fps === 'number' && Number.isFinite(fps) ? fps : fallbackFps; @@ -77,6 +85,7 @@ export function buildAnimatedImageVideoFilter(options: { export interface MediaGeneratorOptions { logDebug?: (message: string) => void; now?: () => number; + execFile?: MediaGeneratorExecFile; } function sanitizeDebugToken(value: string, fallback: string): string { @@ -274,6 +283,14 @@ export class MediaGenerator { const duration = endTime - start + safePadding; const mediaInput = normalizeMediaInput(videoPath); const inputDescription = describeMediaInputForDebugLog(videoPath); + const hasSelectedAudioStream = + !mediaInput.singleResolvedStream && + typeof audioStreamIndex === 'number' && + Number.isInteger(audioStreamIndex) && + audioStreamIndex >= 0; + const isLocalMatroskaMedia = + !/^[A-Za-z][A-Za-z\d+.-]*:\/\//.test(mediaInput.path) && + /\.(?:mkv|mka|mks|webm)$/i.test(mediaInput.path); return new Promise((resolve, reject) => { const outputPath = this.createTempOutputPath('audio', 'mp3'); @@ -284,16 +301,14 @@ export class MediaGenerator { '-t', duration.toString(), ...mediaInput.inputArgs, + ...(hasSelectedAudioStream && isLocalMatroskaMedia + ? ['-probesize', '32768', '-analyzeduration', '0'] + : []), '-i', mediaInput.path, ]; - if ( - !mediaInput.singleResolvedStream && - typeof audioStreamIndex === 'number' && - Number.isInteger(audioStreamIndex) && - audioStreamIndex >= 0 - ) { + if (hasSelectedAudioStream) { args.push('-map', `0:${audioStreamIndex}`); } @@ -321,7 +336,8 @@ export class MediaGenerator { this.logMediaDebug( `audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`, ); - execFile('ffmpeg', args, { timeout: 30000 }, (error) => { + const runExecFile: MediaGeneratorExecFile = this.options.execFile ?? execFile; + runExecFile('ffmpeg', args, { timeout: AUDIO_GENERATION_TIMEOUT_MS }, (error) => { if (error) { this.logMediaDebug( `audio failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`, @@ -338,7 +354,15 @@ export class MediaGenerator { ); resolve(data); } catch (err) { - reject(err); + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + reject( + new Error( + 'FFmpeg audio generation failed: FFmpeg exited without creating an output file.', + ), + ); + } else { + reject(err); + } } }); });