mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-30 17:40:53 -07:00
fix(subtitle-prefetch): reuse startup runs and prefetch overlap lines
- Warm combined text for overlapping subtitle cues. - Preserve valid in-flight tokenization across startup restarts while rejecting invalidated cache results.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: annotations
|
||||||
|
|
||||||
|
- Subtitle lines shown while two cues overlap (common in Jimaku and Jellyfin SRTs, and usually the long lines) are now prefetched, so their annotations appear immediately instead of after a delay.
|
||||||
|
- Subtitle prefetching no longer restarts each time the same subtitle file is re-selected during startup, and a line tokenized before a restart or seek is kept instead of discarded.
|
||||||
@@ -300,3 +300,65 @@ test('prefetch service deduplicates repeated cue text within a run', async () =>
|
|||||||
);
|
);
|
||||||
assert.ok(tokenizedTexts.includes('other'));
|
assert.ok(tokenizedTexts.includes('other'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('prefetch service keeps an in-flight result from a superseded run', async () => {
|
||||||
|
const cues = makeCues(5);
|
||||||
|
const cachedTexts: string[] = [];
|
||||||
|
let releaseFirst!: () => void;
|
||||||
|
const firstGate = new Promise<void>((resolve) => {
|
||||||
|
releaseFirst = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = createSubtitlePrefetchService({
|
||||||
|
cues,
|
||||||
|
tokenizeSubtitle: async (text) => {
|
||||||
|
if (text === 'line-0') await firstGate;
|
||||||
|
return { text, tokens: [] };
|
||||||
|
},
|
||||||
|
preCacheTokenization: (text) => {
|
||||||
|
cachedTexts.push(text);
|
||||||
|
},
|
||||||
|
getCacheGeneration: () => 0,
|
||||||
|
priorityWindowSize: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
service.start(0);
|
||||||
|
await flushMicrotasks();
|
||||||
|
service.stop();
|
||||||
|
releaseFirst();
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assert.deepEqual(cachedTexts, ['line-0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefetch service drops an in-flight result after cache invalidation', async () => {
|
||||||
|
const cues = makeCues(5);
|
||||||
|
const cachedTexts: string[] = [];
|
||||||
|
let generation = 0;
|
||||||
|
let releaseFirst!: () => void;
|
||||||
|
const firstGate = new Promise<void>((resolve) => {
|
||||||
|
releaseFirst = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = createSubtitlePrefetchService({
|
||||||
|
cues,
|
||||||
|
tokenizeSubtitle: async (text) => {
|
||||||
|
if (text === 'line-0') await firstGate;
|
||||||
|
return { text, tokens: [] };
|
||||||
|
},
|
||||||
|
preCacheTokenization: (text) => {
|
||||||
|
cachedTexts.push(text);
|
||||||
|
},
|
||||||
|
getCacheGeneration: () => generation,
|
||||||
|
priorityWindowSize: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
service.start(0);
|
||||||
|
await flushMicrotasks();
|
||||||
|
service.stop();
|
||||||
|
generation += 1;
|
||||||
|
releaseFirst();
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assert.deepEqual(cachedTexts, []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ export interface SubtitlePrefetchServiceDeps {
|
|||||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||||
hasCachedTokenization?: (text: string) => boolean;
|
hasCachedTokenization?: (text: string) => boolean;
|
||||||
|
/**
|
||||||
|
* Bumped whenever the tokenization cache is invalidated. A result is cached only if
|
||||||
|
* the generation is unchanged since its tokenization began; without this dep every
|
||||||
|
* result counts as fresh.
|
||||||
|
*/
|
||||||
|
getCacheGeneration?: () => number;
|
||||||
priorityWindowSize?: number;
|
priorityWindowSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +94,12 @@ export function createSubtitlePrefetchService(
|
|||||||
}
|
}
|
||||||
warmedKeys.add(cacheKey);
|
warmedKeys.add(cacheKey);
|
||||||
|
|
||||||
|
// Freshness follows the cache generation, not the run: a seek or restart that
|
||||||
|
// supersedes this run leaves the result valid, and the parser time is already spent.
|
||||||
|
const generation = deps.getCacheGeneration?.();
|
||||||
try {
|
try {
|
||||||
const result = await deps.tokenizeSubtitle(cue.text);
|
const result = await deps.tokenizeSubtitle(cue.text);
|
||||||
if (result && !stopped && runId === currentRunId) {
|
if (result && generation === deps.getCacheGeneration?.()) {
|
||||||
deps.preCacheTokenization(cue.text, result);
|
deps.preCacheTokenization(cue.text, result);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface SubtitleProcessingController {
|
|||||||
*/
|
*/
|
||||||
notePlainSubtitleEmitted: (text: string) => void;
|
notePlainSubtitleEmitted: (text: string) => void;
|
||||||
invalidateTokenizationCache: () => void;
|
invalidateTokenizationCache: () => void;
|
||||||
|
/** Incremented by invalidateTokenizationCache; lets async writers detect stale results. */
|
||||||
|
getCacheGeneration: () => number;
|
||||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||||
hasCachedSubtitle: (text: string) => boolean;
|
hasCachedSubtitle: (text: string) => boolean;
|
||||||
@@ -253,6 +255,7 @@ export function createSubtitleProcessingController(
|
|||||||
tokenizationCache.clear();
|
tokenizationCache.clear();
|
||||||
cacheGeneration += 1;
|
cacheGeneration += 1;
|
||||||
},
|
},
|
||||||
|
getCacheGeneration: () => cacheGeneration,
|
||||||
preCacheTokenization: (text: string, data: SubtitleData) => {
|
preCacheTokenization: (text: string, data: SubtitleData) => {
|
||||||
setCachedTokenization(text, data);
|
setCachedTokenization(text, data);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2152,6 +2152,7 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
|||||||
subtitleProcessingController.preCacheTokenization(text, data);
|
subtitleProcessingController.preCacheTokenization(text, data);
|
||||||
},
|
},
|
||||||
hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text),
|
hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text),
|
||||||
|
getCacheGeneration: () => subtitleProcessingController.getCacheGeneration(),
|
||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
logWarn: (message) => logger.warn(message),
|
logWarn: (message) => logger.warn(message),
|
||||||
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
||||||
|
|||||||
@@ -251,3 +251,99 @@ test('subtitle prefetch init logs a warning when the source parses to zero cues'
|
|||||||
assert.equal(warnings.length, 1);
|
assert.equal(warnings.length, 1);
|
||||||
assert.match(warnings[0]!, /\[subtitle-prefetch\].*0 cues.*\/tmp\/broken\.ass/);
|
assert.match(warnings[0]!, /\[subtitle-prefetch\].*0 cues.*\/tmp\/broken\.ass/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createTrackingController(loadContent: () => string) {
|
||||||
|
let currentService: SubtitlePrefetchService | null = null;
|
||||||
|
const events: string[] = [];
|
||||||
|
const cueUpdates: Array<string | null> = [];
|
||||||
|
let serviceCount = 0;
|
||||||
|
|
||||||
|
const controller = createSubtitlePrefetchInitController({
|
||||||
|
getCurrentService: () => currentService,
|
||||||
|
setCurrentService: (service) => {
|
||||||
|
currentService = service;
|
||||||
|
},
|
||||||
|
loadSubtitleSourceText: async () => loadContent(),
|
||||||
|
parseSubtitleCues: (content): SubtitleCue[] => [{ startTime: 0, endTime: 1, text: content }],
|
||||||
|
createSubtitlePrefetchService: () => {
|
||||||
|
const id = ++serviceCount;
|
||||||
|
return {
|
||||||
|
start: () => events.push(`start:${id}`),
|
||||||
|
stop: () => events.push(`stop:${id}`),
|
||||||
|
onSeek: () => {},
|
||||||
|
pause: () => {},
|
||||||
|
resume: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
tokenizeSubtitle: async () => null,
|
||||||
|
preCacheTokenization: () => {},
|
||||||
|
logInfo: () => {},
|
||||||
|
logWarn: () => {},
|
||||||
|
onParsedSubtitleCuesChanged: (cues, source) => {
|
||||||
|
cueUpdates.push(cues ? `${source}:${cues[0]!.text}` : null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { controller, events, cueUpdates };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('re-initializing an unchanged source keeps the running service and republishes cues', async () => {
|
||||||
|
const { controller, events, cueUpdates } = createTrackingController(() => 'content');
|
||||||
|
|
||||||
|
await controller.initSubtitlePrefetch('track-1.srt', 0);
|
||||||
|
await controller.initSubtitlePrefetch('track-1.srt', 3);
|
||||||
|
|
||||||
|
assert.deepEqual(events, ['start:1']);
|
||||||
|
assert.deepEqual(cueUpdates, ['track-1.srt:content', 'track-1.srt:content']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-initializing a source whose content changed restarts the service', async () => {
|
||||||
|
let content = 'first';
|
||||||
|
const { controller, events } = createTrackingController(() => content);
|
||||||
|
|
||||||
|
await controller.initSubtitlePrefetch('track-1.srt', 0);
|
||||||
|
content = 'second';
|
||||||
|
await controller.initSubtitlePrefetch('track-1.srt', 0);
|
||||||
|
|
||||||
|
assert.deepEqual(events, ['start:1', 'stop:1', 'start:2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle prefetch init warms overlap lines without publishing them as cues', async () => {
|
||||||
|
let prefetchedTexts: string[] = [];
|
||||||
|
let publishedTexts: string[] = [];
|
||||||
|
let currentService: SubtitlePrefetchService | null = null;
|
||||||
|
|
||||||
|
const controller = createSubtitlePrefetchInitController({
|
||||||
|
getCurrentService: () => currentService,
|
||||||
|
setCurrentService: (service) => {
|
||||||
|
currentService = service;
|
||||||
|
},
|
||||||
|
loadSubtitleSourceText: async () => 'content',
|
||||||
|
parseSubtitleCues: (): SubtitleCue[] => [
|
||||||
|
{ startTime: 0, endTime: 4, text: 'first' },
|
||||||
|
{ startTime: 2, endTime: 6, text: 'second' },
|
||||||
|
],
|
||||||
|
createSubtitlePrefetchService: ({ cues }) => {
|
||||||
|
prefetchedTexts = cues.map((cue) => cue.text);
|
||||||
|
return {
|
||||||
|
start: () => {},
|
||||||
|
stop: () => {},
|
||||||
|
onSeek: () => {},
|
||||||
|
pause: () => {},
|
||||||
|
resume: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
tokenizeSubtitle: async () => null,
|
||||||
|
preCacheTokenization: () => {},
|
||||||
|
logInfo: () => {},
|
||||||
|
logWarn: () => {},
|
||||||
|
onParsedSubtitleCuesChanged: (cues) => {
|
||||||
|
publishedTexts = cues?.map((cue) => cue.text) ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.initSubtitlePrefetch('track.srt', 0);
|
||||||
|
|
||||||
|
assert.deepEqual(prefetchedTexts, ['first', 'second', 'first\n\nsecond']);
|
||||||
|
assert.deepEqual(publishedTexts, ['first', 'second']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
} from '../../core/services/subtitle-prefetch';
|
} from '../../core/services/subtitle-prefetch';
|
||||||
import type { SubtitleData } from '../../types';
|
import type { SubtitleData } from '../../types';
|
||||||
import type { SubtitleCue } from '../../types';
|
import type { SubtitleCue } from '../../types';
|
||||||
|
import { buildOverlapPrefetchCues } from './subtitle-prefetch-overlaps';
|
||||||
|
|
||||||
export interface SubtitlePrefetchInitControllerDeps {
|
export interface SubtitlePrefetchInitControllerDeps {
|
||||||
getCurrentService: () => SubtitlePrefetchService | null;
|
getCurrentService: () => SubtitlePrefetchService | null;
|
||||||
@@ -14,6 +15,7 @@ export interface SubtitlePrefetchInitControllerDeps {
|
|||||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||||
hasCachedTokenization?: (text: string) => boolean;
|
hasCachedTokenization?: (text: string) => boolean;
|
||||||
|
getCacheGeneration?: () => number;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
logWarn: (message: string) => void;
|
logWarn: (message: string) => void;
|
||||||
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
|
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
|
||||||
@@ -32,11 +34,20 @@ export function createSubtitlePrefetchInitController(
|
|||||||
deps: SubtitlePrefetchInitControllerDeps,
|
deps: SubtitlePrefetchInitControllerDeps,
|
||||||
): SubtitlePrefetchInitController {
|
): SubtitlePrefetchInitController {
|
||||||
let initRevision = 0;
|
let initRevision = 0;
|
||||||
|
// What the running service was built from. Startup fires several inits for one source
|
||||||
|
// (Jellyfin preload seed, sid change, each track-list update); restarting on each would
|
||||||
|
// discard the in-flight tokenization and re-queue the priority window behind it.
|
||||||
|
let activeSource: { key: string; content: string; cues: SubtitleCue[] } | null = null;
|
||||||
|
|
||||||
|
const stopCurrentService = (): void => {
|
||||||
|
deps.getCurrentService()?.stop();
|
||||||
|
deps.setCurrentService(null);
|
||||||
|
activeSource = null;
|
||||||
|
};
|
||||||
|
|
||||||
const cancelPendingInit = (): void => {
|
const cancelPendingInit = (): void => {
|
||||||
initRevision += 1;
|
initRevision += 1;
|
||||||
deps.getCurrentService()?.stop();
|
stopCurrentService();
|
||||||
deps.setCurrentService(null);
|
|
||||||
deps.onParsedSubtitleCuesChanged?.(null, null);
|
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,14 +57,26 @@ export function createSubtitlePrefetchInitController(
|
|||||||
sourceKey = sourcePath,
|
sourceKey = sourcePath,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const revision = ++initRevision;
|
const revision = ++initRevision;
|
||||||
deps.getCurrentService()?.stop();
|
// The same source may still be current once loaded; keep its service running until then.
|
||||||
deps.setCurrentService(null);
|
if (activeSource?.key !== sourceKey) {
|
||||||
|
stopCurrentService();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await deps.loadSubtitleSourceText(sourcePath);
|
const content = await deps.loadSubtitleSourceText(sourcePath);
|
||||||
if (revision !== initRevision) {
|
if (revision !== initRevision) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
activeSource?.key === sourceKey &&
|
||||||
|
activeSource.content === content &&
|
||||||
|
deps.getCurrentService()
|
||||||
|
) {
|
||||||
|
// A track change clears the published cues synchronously, so republish them.
|
||||||
|
deps.onParsedSubtitleCuesChanged?.(activeSource.cues, sourceKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopCurrentService();
|
||||||
|
|
||||||
const cues = deps.parseSubtitleCues(content, sourcePath);
|
const cues = deps.parseSubtitleCues(content, sourcePath);
|
||||||
if (revision !== initRevision || cues.length === 0) {
|
if (revision !== initRevision || cues.length === 0) {
|
||||||
@@ -66,11 +89,16 @@ export function createSubtitlePrefetchInitController(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overlap cues only feed the cache; the published cue list stays as authored.
|
||||||
|
const prefetchCues = [...cues, ...buildOverlapPrefetchCues(cues)].sort(
|
||||||
|
(a, b) => a.startTime - b.startTime,
|
||||||
|
);
|
||||||
const nextService = deps.createSubtitlePrefetchService({
|
const nextService = deps.createSubtitlePrefetchService({
|
||||||
cues,
|
cues: prefetchCues,
|
||||||
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
|
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
|
||||||
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
|
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
|
||||||
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
|
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
|
||||||
|
getCacheGeneration: deps.getCacheGeneration,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (revision !== initRevision) {
|
if (revision !== initRevision) {
|
||||||
@@ -78,6 +106,7 @@ export function createSubtitlePrefetchInitController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
deps.setCurrentService(nextService);
|
deps.setCurrentService(nextService);
|
||||||
|
activeSource = { key: sourceKey, content, cues };
|
||||||
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
|
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
|
||||||
nextService.start(currentTimePos);
|
nextService.start(currentTimePos);
|
||||||
deps.logInfo(
|
deps.logInfo(
|
||||||
@@ -85,6 +114,7 @@ export function createSubtitlePrefetchInitController(
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (revision === initRevision) {
|
if (revision === initRevision) {
|
||||||
|
stopCurrentService();
|
||||||
deps.onParsedSubtitleCuesChanged?.(null, null);
|
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||||
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
|
deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { SubtitleCue } from '../../types';
|
||||||
|
import { buildOverlapPrefetchCues } from './subtitle-prefetch-overlaps';
|
||||||
|
|
||||||
|
test('overlapping cues yield the combined line the live path resolves', () => {
|
||||||
|
const cues: SubtitleCue[] = [
|
||||||
|
{ startTime: 0, endTime: 4, text: 'このまま頑張ったって―' },
|
||||||
|
{ startTime: 2, endTime: 6, text: 'ちょっとカズマ\n聞こえてんの?' },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(buildOverlapPrefetchCues(cues), [
|
||||||
|
{ startTime: 2, endTime: 4, text: 'このまま頑張ったって―\n\nちょっとカズマ\n聞こえてんの?' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('back-to-back cues yield no overlap lines', () => {
|
||||||
|
const cues: SubtitleCue[] = [
|
||||||
|
{ startTime: 0, endTime: 2, text: 'first' },
|
||||||
|
{ startTime: 2, endTime: 4, text: 'second' },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(buildOverlapPrefetchCues(cues), []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { SubtitleCue } from '../../types';
|
||||||
|
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While cues overlap, mpv publishes their texts joined as one `sub-text`, and the live
|
||||||
|
* path tokenizes and caches that combined line as its own entry. Prefetching single cues
|
||||||
|
* never warms it, so overlapping lines (usually the long ones) always miss the cache.
|
||||||
|
*
|
||||||
|
* Returns one synthetic cue per overlap span carrying the text the live path resolves
|
||||||
|
* for it, so the prefetcher can warm those lines in timeline order with the rest.
|
||||||
|
*/
|
||||||
|
export function buildOverlapPrefetchCues(cues: readonly SubtitleCue[]): SubtitleCue[] {
|
||||||
|
const boundaries = [...new Set(cues.flatMap((cue) => [cue.startTime, cue.endTime]))].sort(
|
||||||
|
(a, b) => a - b,
|
||||||
|
);
|
||||||
|
const singleTexts = new Set(cues.map((cue) => cue.text));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const overlapCues: SubtitleCue[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i + 1 < boundaries.length; i += 1) {
|
||||||
|
const startTime = boundaries[i]!;
|
||||||
|
const endTime = boundaries[i + 1]!;
|
||||||
|
const midpoint = (startTime + endTime) / 2;
|
||||||
|
const active = cues.filter((cue) => cue.startTime <= midpoint && cue.endTime > midpoint);
|
||||||
|
if (active.length < 2) continue;
|
||||||
|
|
||||||
|
const text = resolvePrimarySubtitleText({
|
||||||
|
liveText: active.map((cue) => cue.text).join('\n'),
|
||||||
|
currentTimeSec: midpoint,
|
||||||
|
cues,
|
||||||
|
});
|
||||||
|
if (!text.trim() || singleTexts.has(text) || seen.has(text)) continue;
|
||||||
|
seen.add(text);
|
||||||
|
overlapCues.push({ startTime, endTime, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
return overlapCues;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user