mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 05:16:22 -07:00
feat(subtitles): use loaded subtitles to guide generation timing (#249)
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { SubtitleGenerationProgress } from '../../shared/subtitle-generation';
|
||||
import { parseSrtCues } from './subtitle-cue-parser';
|
||||
import { runSubtitleGenerationProcess } from './subtitle-generation-process';
|
||||
|
||||
export type SubtitleGenerationReference = {
|
||||
label: string;
|
||||
delaySeconds: number;
|
||||
source: { kind: 'embedded'; streamIndex: number } | { kind: 'external'; path: string };
|
||||
};
|
||||
|
||||
// Check both titles and filenames: releases often tag only one of them.
|
||||
const EXCLUDED =
|
||||
/(?:^|[^\p{L}\p{N}])(?:signs?(?:songs?)?|songs?|lyrics?|karaoke|forced|s[\s&+_-]*s|op|ed|opening|ending|generated)(?=$|[^\p{L}\p{N}])|看板|歌詞/iu;
|
||||
const TEXT_CODECS = new Set(['ass', 'ssa', 'subrip', 'srt', 'webvtt', 'mov_text', 'text']);
|
||||
|
||||
/** Rank loaded dialogue tracks, preferring English, then explicitly full tracks. */
|
||||
export function subtitleGenerationReferences(
|
||||
value: unknown,
|
||||
workingDirectory?: string,
|
||||
delays: ReadonlyMap<number, number> = new Map(),
|
||||
): SubtitleGenerationReference[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const tracks: unknown[] = value;
|
||||
return tracks
|
||||
.flatMap((track) => {
|
||||
if (typeof track !== 'object' || track === null || !('type' in track) || track.type !== 'sub')
|
||||
return [];
|
||||
const title = 'title' in track && typeof track.title === 'string' ? track.title : '';
|
||||
const filename =
|
||||
'external-filename' in track && typeof track['external-filename'] === 'string'
|
||||
? track['external-filename']
|
||||
: '';
|
||||
const name = `${title} ${path.basename(filename)}`;
|
||||
if (('forced' in track && track.forced === true) || EXCLUDED.test(name)) return [];
|
||||
if ('codec' in track && typeof track.codec === 'string' && !TEXT_CODECS.has(track.codec))
|
||||
return [];
|
||||
let source: SubtitleGenerationReference['source'];
|
||||
if ('external' in track && track.external === true) {
|
||||
let local = filename;
|
||||
if (local.startsWith('file://')) {
|
||||
try {
|
||||
local = fileURLToPath(local);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
} else if (/^[a-z][a-z\d+.-]*:\/\//i.test(local)) return [];
|
||||
if (!local || (!path.isAbsolute(local) && !workingDirectory)) return [];
|
||||
if (!/\.(?:srt|ass|ssa|vtt)$/i.test(local)) return [];
|
||||
source = { kind: 'external', path: path.resolve(workingDirectory ?? '.', local) };
|
||||
} else {
|
||||
if (
|
||||
!('ff-index' in track) ||
|
||||
typeof track['ff-index'] !== 'number' ||
|
||||
!Number.isSafeInteger(track['ff-index']) ||
|
||||
track['ff-index'] < 0
|
||||
)
|
||||
return [];
|
||||
source = { kind: 'embedded', streamIndex: track['ff-index'] };
|
||||
}
|
||||
const language = 'lang' in track && typeof track.lang === 'string' ? track.lang : '';
|
||||
const english =
|
||||
/^(?:en|eng|english)(?:[-_]|$)/i.test(language) ||
|
||||
/(?:^|[\s.\[(_-])(?:en|eng|english)(?=$|[\s.\])_-])/i.test(name);
|
||||
const full = /\b(?:full|dialogue|dialog)\b/i.test(name);
|
||||
const selected = 'selected' in track && track.selected === true;
|
||||
const preferred = 'default' in track && track.default === true;
|
||||
return [
|
||||
{
|
||||
reference: {
|
||||
label:
|
||||
title ||
|
||||
path.basename(filename) ||
|
||||
`${language || 'Subtitle'} stream ${source.kind === 'embedded' ? source.streamIndex : ''}`,
|
||||
source,
|
||||
delaySeconds:
|
||||
'id' in track && typeof track.id === 'number' ? (delays.get(track.id) ?? 0) : 0,
|
||||
},
|
||||
score:
|
||||
Number(english) * 100 + Number(full) * 20 + Number(selected) * 2 + Number(preferred),
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ reference }) => reference);
|
||||
}
|
||||
|
||||
/** Read mpv's path base and active subtitle delays while capturing timing references. */
|
||||
export async function readSubtitleGenerationReferences(
|
||||
tracks: unknown,
|
||||
requestProperty: (name: string) => Promise<unknown>,
|
||||
): Promise<SubtitleGenerationReference[]> {
|
||||
const [directory, primary, secondary, primaryDelay, secondaryDelay] = await Promise.all(
|
||||
['working-directory', 'sid', 'secondary-sid', 'sub-delay', 'secondary-sub-delay'].map((name) =>
|
||||
requestProperty(name).catch(() => null),
|
||||
),
|
||||
);
|
||||
const delays = new Map<number, number>();
|
||||
for (const [id, delay] of [
|
||||
[primary, primaryDelay],
|
||||
[secondary, secondaryDelay],
|
||||
]) {
|
||||
if (typeof id === 'number' && typeof delay === 'number' && Number.isFinite(delay))
|
||||
delays.set(id, delay);
|
||||
}
|
||||
return subtitleGenerationReferences(
|
||||
tracks,
|
||||
typeof directory === 'string' ? directory : undefined,
|
||||
delays,
|
||||
);
|
||||
}
|
||||
|
||||
/** Reference timestamps are hints on the extracted audio timeline, never a coverage mask. */
|
||||
export async function loadSubtitleGenerationReference(input: {
|
||||
references: readonly SubtitleGenerationReference[];
|
||||
mediaPath: string;
|
||||
ffmpegPath: string;
|
||||
directory: string;
|
||||
audioOffset: number;
|
||||
onProgress?: (progress: SubtitleGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<number[]> {
|
||||
for (const [index, reference] of input.references.entries()) {
|
||||
input.signal?.throwIfAborted();
|
||||
try {
|
||||
const output = path.join(input.directory, `reference-${index}.srt`);
|
||||
const embedded = reference.source.kind === 'embedded';
|
||||
await runSubtitleGenerationProcess({
|
||||
command: input.ffmpegPath,
|
||||
args: [
|
||||
'-nostdin',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
...(embedded ? ['-copyts', '-start_at_zero'] : []),
|
||||
'-i',
|
||||
reference.source.kind === 'external' ? reference.source.path : input.mediaPath,
|
||||
'-map',
|
||||
reference.source.kind === 'embedded' ? `0:${reference.source.streamIndex}` : '0:s:0',
|
||||
'-c:s',
|
||||
'srt',
|
||||
output,
|
||||
],
|
||||
signal: input.signal,
|
||||
});
|
||||
const starts = parseSrtCues(await readFile(output, 'utf8'))
|
||||
.filter((cue) => cue.text.trim() && !/[♪♫]/u.test(cue.text) && cue.endTime > cue.startTime)
|
||||
.map((cue) => cue.startTime + reference.delaySeconds - input.audioOffset)
|
||||
.filter((time) => Number.isFinite(time) && time >= 0);
|
||||
if (starts.length === 0) continue;
|
||||
input.onProgress?.({
|
||||
stage: 'extract',
|
||||
message: `Using subtitle timing reference: ${reference.label}`,
|
||||
});
|
||||
return [...new Set(starts)].sort((a, b) => a - b);
|
||||
} catch {
|
||||
input.signal?.throwIfAborted();
|
||||
// An optional reference must not prevent transcription. Try the next loaded track.
|
||||
}
|
||||
}
|
||||
if (input.references.length)
|
||||
input.onProgress?.({
|
||||
stage: 'extract',
|
||||
message: 'No readable subtitle timing reference. Using audio timing.',
|
||||
});
|
||||
return [];
|
||||
}
|
||||
Reference in New Issue
Block a user