mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-14 01:55:58 -07:00
fix(media): tolerate slow MKV audio extraction (#195)
This commit is contained in:
@@ -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`.
|
||||||
@@ -171,7 +171,9 @@ Without FFmpeg, card creation still works but audio and image fields will be emp
|
|||||||
|
|
||||||
**Audio or screenshot generation hangs**
|
**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.
|
- Using a local copy of the video file.
|
||||||
- Reducing `ankiConnect.media.imageQuality` or switching from `avif` to `static` image type.
|
- Reducing `ankiConnect.media.imageQuality` or switching from `avif` to `static` image type.
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import * as os from 'node:os';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import test from 'node:test';
|
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(
|
async function withStubbedFfmpeg(
|
||||||
run: (generator: MediaGenerator, argsPath: string) => Promise<void>,
|
run: (generator: MediaGenerator, argsPath: string) => Promise<void>,
|
||||||
options: {
|
options: MediaGeneratorOptions = {},
|
||||||
logDebug?: (message: string) => void;
|
stubOptions: {
|
||||||
now?: () => number;
|
skipOutput?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-generator-test-'));
|
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');",
|
"fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, JSON.stringify(args), 'utf8');",
|
||||||
'const outputPath = args.at(-1);',
|
'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'),
|
].join('\n'),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
@@ -46,8 +53,14 @@ async function withStubbedFfmpeg(
|
|||||||
|
|
||||||
const originalPath = process.env.PATH;
|
const originalPath = process.env.PATH;
|
||||||
const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS;
|
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.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||||
process.env.SUBMINER_TEST_FFMPEG_ARGS = argsPath;
|
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);
|
const generator = new MediaGenerator(tempDir, options);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -60,6 +73,11 @@ async function withStubbedFfmpeg(
|
|||||||
} else {
|
} else {
|
||||||
process.env.SUBMINER_TEST_FFMPEG_ARGS = originalArgsPath;
|
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 });
|
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 () => {
|
test('generateAudio debug-logs cached input and completion timing', async () => {
|
||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
const times = [1000, 1052];
|
const times = [1000, 1052];
|
||||||
|
|||||||
+32
-8
@@ -26,9 +26,17 @@ import { normalizeMediaInput, type MediaInput } from './media-input';
|
|||||||
const log = createLogger('media');
|
const log = createLogger('media');
|
||||||
const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-23:TP=-2:LRA=11';
|
const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-23:TP=-2:LRA=11';
|
||||||
const AUDIO_AMPLIFICATION_LIMITER_FILTER = 'alimiter=limit=0.891251:level=false';
|
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';
|
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 {
|
function normalizeAnimatedImageFps(fps: number | undefined): number {
|
||||||
const fallbackFps = 10;
|
const fallbackFps = 10;
|
||||||
const safeFps = typeof fps === 'number' && Number.isFinite(fps) ? fps : fallbackFps;
|
const safeFps = typeof fps === 'number' && Number.isFinite(fps) ? fps : fallbackFps;
|
||||||
@@ -77,6 +85,7 @@ export function buildAnimatedImageVideoFilter(options: {
|
|||||||
export interface MediaGeneratorOptions {
|
export interface MediaGeneratorOptions {
|
||||||
logDebug?: (message: string) => void;
|
logDebug?: (message: string) => void;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
|
execFile?: MediaGeneratorExecFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeDebugToken(value: string, fallback: string): string {
|
function sanitizeDebugToken(value: string, fallback: string): string {
|
||||||
@@ -274,6 +283,14 @@ export class MediaGenerator {
|
|||||||
const duration = endTime - start + safePadding;
|
const duration = endTime - start + safePadding;
|
||||||
const mediaInput = normalizeMediaInput(videoPath);
|
const mediaInput = normalizeMediaInput(videoPath);
|
||||||
const inputDescription = describeMediaInputForDebugLog(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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const outputPath = this.createTempOutputPath('audio', 'mp3');
|
const outputPath = this.createTempOutputPath('audio', 'mp3');
|
||||||
@@ -284,16 +301,14 @@ export class MediaGenerator {
|
|||||||
'-t',
|
'-t',
|
||||||
duration.toString(),
|
duration.toString(),
|
||||||
...mediaInput.inputArgs,
|
...mediaInput.inputArgs,
|
||||||
|
...(hasSelectedAudioStream && isLocalMatroskaMedia
|
||||||
|
? ['-probesize', '32768', '-analyzeduration', '0']
|
||||||
|
: []),
|
||||||
'-i',
|
'-i',
|
||||||
mediaInput.path,
|
mediaInput.path,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (
|
if (hasSelectedAudioStream) {
|
||||||
!mediaInput.singleResolvedStream &&
|
|
||||||
typeof audioStreamIndex === 'number' &&
|
|
||||||
Number.isInteger(audioStreamIndex) &&
|
|
||||||
audioStreamIndex >= 0
|
|
||||||
) {
|
|
||||||
args.push('-map', `0:${audioStreamIndex}`);
|
args.push('-map', `0:${audioStreamIndex}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +336,8 @@ export class MediaGenerator {
|
|||||||
this.logMediaDebug(
|
this.logMediaDebug(
|
||||||
`audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`,
|
`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) {
|
if (error) {
|
||||||
this.logMediaDebug(
|
this.logMediaDebug(
|
||||||
`audio failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
|
`audio failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
|
||||||
@@ -338,7 +354,15 @@ export class MediaGenerator {
|
|||||||
);
|
);
|
||||||
resolve(data);
|
resolve(data);
|
||||||
} catch (err) {
|
} 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user