mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-25 00:15:27 -07:00
fix(subtitles): suppress ASS texture layers and advance canonical lyrics
- Remove clipped and transparent ASS texture fragments - Show canonical lyrics when their animations begin
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps, including word gaps measured across wide glyphs that width normalization alone reads as ordinary letter advances. Progressive karaoke highlights, offset shadow copies, overlapping decorative glyphs, and font-based sign textures remain suppressed. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become concatenated primary or secondary lines. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display. A failed source refresh also clears ASS-only cleanup so fallback text from other formats stays intact.
|
||||
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps, including word gaps measured across wide glyphs that width normalization alone reads as ordinary letter advances. Progressive karaoke highlights, offset shadow copies, overlapping decorative glyphs, and sign textures remain suppressed, including clipped repeated-glyph mask strips without font overrides and texture payloads that switch actor or font and use nearly transparent random text. Canonical lyrics now advance when their generated entrance begins, so word-by-word opening effects appear as one sentence instead of stacked rows during the lead-in. Wrapped lyrics also remain intact when a timed token repeats at another horizontal position. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become concatenated primary or secondary lines. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display. A failed source refresh also clears ASS-only cleanup so fallback text from other formats stays intact.
|
||||
|
||||
@@ -87,7 +87,9 @@ interface SubtitleCue {
|
||||
|
||||
ASS scripts can also redraw one complete lyric for two or more long color/highlight phases. Those flush-timed phases collapse separately from short animation frames when they share text, style, actor, and layer and carry direct animation evidence, such as temporal tags or changing non-spatial overrides. Spatial command changes do not prove a phase, so separately positioned signs remain distinct.
|
||||
|
||||
**Canonical animation recovery.** Some ASS producers keep the readable lyric or sign as a timed `Comment:` and generate hundreds of `Dialogue:` frames containing repeated glyphs or changing clip regions. Others retain the complete line as brief `Dialogue:` events around the generated fragments. A complete event is promoted only when nearby dialogue from the same style and actor forms a proven animation cluster and reconstructs its entire text in source order. The generated frames are then replaced by one cue marked `source: 'canonical-ass'`. This source marker lets the live primary-subtitle path prefer the clean authored text and timing for display, sidebar history, immersion recording, and mining, while unmatched editor notes and alternative translations remain ignored.
|
||||
**Canonical animation recovery.** Some ASS producers keep the readable lyric or sign as a timed `Comment:` and generate hundreds of `Dialogue:` frames containing repeated glyphs or changing clip regions. Others retain the complete line as brief `Dialogue:` events around the generated fragments. A complete event is promoted only when nearby dialogue from the same style and actor forms a proven animation cluster and reconstructs its entire text in source order. The generated frames are then replaced by one cue marked `source: 'canonical-ass'`. This source marker lets the live primary-subtitle path prefer the clean authored text and timing for display, sidebar history, immersion recording, and mining, while unmatched editor notes and alternative translations remain ignored. Secondary selection advances to an entering canonical cue at its generated animation start when the preceding authored cue ends before the new authored span. Unrelated simultaneous cues that continue through the new span remain visible.
|
||||
|
||||
**Font texture cleanup.** A clipped repeated-glyph run or frequent changes to secondary alpha marks a texture seed. Clipped runs do not need a font override because some signs build their masks from ordinary `l` glyphs. The parser removes short clipped pieces that share a no-font seed's style and timing, or pieces that share a font seed's style, timing, and font even when the actor changes. It also removes positioned text layers with at least `E0` global alpha when they overlap a seed in the same style. Opaque authored sign text stays publishable when the texture switches fonts or actors around it.
|
||||
|
||||
#### Prefetch Service Lifecycle
|
||||
|
||||
|
||||
@@ -1265,6 +1265,21 @@ test('parseSubtitleCues drops clipped repeated-glyph texture text', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues drops clipped repeated-glyph texture text without a font override', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\an7\\pos(736.49,152.99)\\fscy150\\fs10\\bord3\\c&H657BC8&\\3c&H657BC8&\\blur3\\clip}lllllllllllll',
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\an7\\pos(769.9,106.18)\\fscy150\\fs12\\bord3\\c&H66729F&\\3c&H66729F&\\blur5\\clip}llll',
|
||||
'Dialogue: 5,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(893,311)}Read',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
|
||||
['Read'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues drops per-character alpha texture text', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
@@ -1285,6 +1300,53 @@ test('parseSubtitleCues drops per-character alpha texture text', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues drops transparent texture payloads across an animated sign', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
"Dialogue: 90,0:00:01.00,0:00:04.00,Alt,,0,0,0,,Even if you want to see her, she doesn't want to see you!",
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.08,FrogSigns,,0,0,0,,{\\pos(699,803)\\fnSerangkaian Pattern Regular\\clip(300,380,1130,1050)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a0}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\\\\\\\\\\\\\\\\\\\\\',
|
||||
'Dialogue: 3,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnGrain\\alpha&HE0&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
|
||||
'Dialogue: 5,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnRoboto Medium\\alpha&H00&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
|
||||
'Dialogue: 6,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnGrain\\alpha&HE0&}H1.4igcAhGYHVWD"kHcVlG2W9eKEWj"!X\\N\'uNVaEVpTXMd9rk7dnRX\'P!RhsS"Wn90k6',
|
||||
'Dialogue: 6,0:00:01.00,0:00:01.08,FrogSigns,18K,0,0,0,,{\\pos(284,821)\\fnGrain\\alpha&HE0&}ou:QepiiPqQ.4n.IYbFaGHtPzWyKI9CUSq:',
|
||||
'Dialogue: 1,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(581,921)\\fnSerangkaian Pattern Regular\\clip(195,495,986,1120)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{',
|
||||
'Dialogue: 3,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HE0&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
|
||||
'Dialogue: 5,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnRoboto Medium\\alpha&H00&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
|
||||
'Dialogue: 3,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HF0&}9LF\'GpPCTlOkLxBLV:QN,8R8NUVM"ha.s\\NNUUPNTBdJih4jUthK34i,yYe;9EBgLXbET',
|
||||
"Dialogue: 6,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(150,936)\\fnGrain\\alpha&HE0&}JS7vl:lD;'PzkCb!bGT;.7TbA.KCkEH0LOk",
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
|
||||
[
|
||||
'Street performance by Mortis from\nMujica - Acting prodigy in action!',
|
||||
"Even if you want to see her, she doesn't want to see you!",
|
||||
'Street performance by Mortis from\nMujica - Acting prodigy in action!',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues does not reconstruct short texture pieces under another actor', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,bubble,0,0,0,,{\\pos(245,-102)\\fnSerangkaian Pattern Regular\\clip(224,-1,831,106)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L',
|
||||
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(917,293)\\alpha&H20&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
|
||||
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(911,293)\\alpha&H58&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
|
||||
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(845,300)\\alpha&H00&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
|
||||
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(907,293)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,289,1010,338)}L{\\2a0}L{\\2a1}L{\\2a0}L',
|
||||
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(911,293)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,289,1010,338)}L{\\2a0}L{\\2a1}L{\\2a0}L',
|
||||
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(922,130)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,120,1010,173)}L{\\2a0}L{\\2a1}L{\\2a0}L',
|
||||
'Dialogue: 5,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(893,311)\\fnSFProDisplay-Regular-STR}Read 3',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
|
||||
['Read 3'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues separates overlapping positioned English lyric sequences', () => {
|
||||
const fragments = [
|
||||
['my', 642, '0:00:01.00', '0:00:04.05'],
|
||||
@@ -1382,25 +1444,25 @@ test('parseSubtitleCues marks a repeated-token sign wall as a fragment grid', ()
|
||||
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.assLayout?.kind, 'fragment-grid');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps a wrapped lyric with repeated syllables publishable', () => {
|
||||
test('parseSubtitleCues keeps a wrapped lyric with a staggered repeated token publishable', () => {
|
||||
const fragments = [
|
||||
['dreams', 300, 115],
|
||||
['ju', 250, 39],
|
||||
['n', 280, 39],
|
||||
['jo', 300, 39],
|
||||
['u', 330, 39],
|
||||
['to', 360, 39],
|
||||
['jo', 395, 39],
|
||||
['u', 425, 39],
|
||||
['ne', 455, 39],
|
||||
['tsu!', 485, 39],
|
||||
['dreams', 300, 115, '0:00:01.00'],
|
||||
['ju', 250, 39, '0:00:01.00'],
|
||||
['n', 280, 39, '0:00:01.00'],
|
||||
['jo', 300, 39, '0:00:01.00'],
|
||||
['u', 330, 39, '0:00:01.00'],
|
||||
['to', 360, 39, '0:00:01.00'],
|
||||
['jo', 395, 39, '0:00:01.02'],
|
||||
['u', 425, 39, '0:00:01.00'],
|
||||
['ne', 455, 39, '0:00:01.00'],
|
||||
['tsu!', 485, 39, '0:00:01.00'],
|
||||
] as const;
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
...[0, 1].flatMap((layer) =>
|
||||
fragments.map(
|
||||
([text, x, y], index) =>
|
||||
`Dialogue: ${layer},0:00:01.00,0:00:04.00,ED Romaji,,0,0,0,,{\\pos(${x},${y})\\t(${index * 2},${index * 2 + 100},\\fscx120)}${text}`,
|
||||
([text, x, y, start], index) =>
|
||||
`Dialogue: ${layer},${start},0:00:04.00,ED Romaji,,0,0,0,,{\\pos(${x},${y})\\t(${index * 2},${index * 2 + 100},\\fscx120)}${text}`,
|
||||
),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
@@ -679,8 +679,8 @@ function looksLikeFragmentGridParts(parts: readonly AssFragmentPart[]): boolean
|
||||
|
||||
const seenPlacements = new Map<string, number>();
|
||||
for (const part of positioned) {
|
||||
if (part.layout?.kind !== 'positioned') continue;
|
||||
const placement = `${part.text}@${Math.round(part.layout.y)}`;
|
||||
if (part.layout?.kind !== 'positioned' || !part.position) continue;
|
||||
const placement = `${part.text}@${Math.round(part.position.x)},${Math.round(part.position.y)}`;
|
||||
const earlierStart = seenPlacements.get(placement);
|
||||
if (earlierStart !== undefined && Math.abs(part.startTime - earlierStart) > 0.01) {
|
||||
return true;
|
||||
@@ -966,6 +966,8 @@ function staticFontOverride(cue: AnnotatedSubtitleCue): string | null {
|
||||
|
||||
const MIN_TEXTURE_GLYPH_RUN = 8;
|
||||
const MIN_TEXTURE_ALPHA_OVERRIDES = 6;
|
||||
const MIN_TEXTURE_LAYER_ALPHA = 0xe0;
|
||||
const ASS_ALPHA_VALUE_PATTERN = /^&?H([0-9a-f]{1,2})&?$/iu;
|
||||
|
||||
function hasStaticOverride(cue: AnnotatedSubtitleCue, expectedName: string): boolean {
|
||||
return cue.overrides.some(
|
||||
@@ -973,25 +975,36 @@ function hasStaticOverride(cue: AnnotatedSubtitleCue, expectedName: string): boo
|
||||
);
|
||||
}
|
||||
|
||||
function isClippedRepeatedGlyphFragment(cue: AnnotatedSubtitleCue): boolean {
|
||||
const glyphs = [...compactCueMatchText(cue)];
|
||||
return (
|
||||
glyphs.length > 0 &&
|
||||
glyphs.every((glyph) => glyph === glyphs[0]) &&
|
||||
(hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Some ASS signs feed placeholder glyphs through a texture font instead of drawing the
|
||||
* texture as a vector. A long clipped single-glyph run or frequent changing secondary
|
||||
* alpha tags identifies the effect without guessing from its visible text or font name.
|
||||
* Some ASS signs build image textures from clipped placeholder glyphs, optionally through
|
||||
* a texture font. A long clipped single-glyph run or frequent changing secondary alpha
|
||||
* tags identifies the effect without guessing from its visible text or font name.
|
||||
*/
|
||||
function isAssFontTextureSeed(cue: AnnotatedSubtitleCue): boolean {
|
||||
if (staticFontOverride(cue) === null || fragmentPosition(cue) === null) {
|
||||
function isAssTextureSeed(cue: AnnotatedSubtitleCue): boolean {
|
||||
if (fragmentPosition(cue) === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const glyphs = [...compactCueMatchText(cue)];
|
||||
const isClippedRepeatedGlyphRun =
|
||||
glyphs.length >= MIN_TEXTURE_GLYPH_RUN &&
|
||||
glyphs.every((glyph) => glyph === glyphs[0]) &&
|
||||
(hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'));
|
||||
glyphs.length >= MIN_TEXTURE_GLYPH_RUN && isClippedRepeatedGlyphFragment(cue);
|
||||
if (isClippedRepeatedGlyphRun) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (staticFontOverride(cue) === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const secondaryAlpha = cue.overrides.filter(
|
||||
(command) => !command.animated && command.name.toLowerCase() === '2a',
|
||||
);
|
||||
@@ -999,29 +1012,95 @@ function isAssFontTextureSeed(cue: AnnotatedSubtitleCue): boolean {
|
||||
return false;
|
||||
}
|
||||
const alphaValues = secondaryAlpha.map((command) => command.args.toLowerCase());
|
||||
return alphaValues.every((value, index) => index === 0 || value !== alphaValues[index - 1]);
|
||||
return new Set(alphaValues).size >= 2;
|
||||
}
|
||||
|
||||
function staticGlobalAlpha(cue: AnnotatedSubtitleCue): number | null {
|
||||
let alpha: number | null = null;
|
||||
for (const command of cue.overrides) {
|
||||
if (command.animated || command.name.toLowerCase() !== 'alpha') continue;
|
||||
const match = ASS_ALPHA_VALUE_PATTERN.exec(command.args.trim());
|
||||
const alphaValue = match?.[1];
|
||||
if (alphaValue !== undefined) {
|
||||
alpha = Number.parseInt(alphaValue, 16);
|
||||
}
|
||||
}
|
||||
return alpha;
|
||||
}
|
||||
|
||||
function isNearlyTransparentPositionedText(cue: AnnotatedSubtitleCue): boolean {
|
||||
const alpha = staticGlobalAlpha(cue);
|
||||
return (
|
||||
alpha !== null &&
|
||||
alpha >= MIN_TEXTURE_LAYER_ALPHA &&
|
||||
staticFontOverride(cue) !== null &&
|
||||
fragmentPosition(cue) !== null
|
||||
);
|
||||
}
|
||||
|
||||
function assFontTextureGroupKey(cue: AnnotatedSubtitleCue): string | null {
|
||||
const font = staticFontOverride(cue);
|
||||
return font === null
|
||||
? null
|
||||
: `${assEventGroupKey(cue)}\0${cue.startTime}\0${cue.endTime}\0${font}`;
|
||||
return font === null ? null : `${cue.style}\0${cue.startTime}\0${cue.endTime}\0${font}`;
|
||||
}
|
||||
|
||||
function assTextureTimingGroupKey(cue: AnnotatedSubtitleCue): string {
|
||||
return `${cue.style}\0${cue.startTime}\0${cue.endTime}`;
|
||||
}
|
||||
|
||||
function removeAssFontTextureEvents(events: ParsedAssEvents): ParsedAssEvents {
|
||||
// Short pieces can share the seeded font effect without carrying enough tags to identify
|
||||
// themselves. Limit propagation to the exact style, actor, time span, and font group.
|
||||
const seeds = events.dialogue.filter(isAssTextureSeed);
|
||||
const seedSet = new Set(seeds);
|
||||
// Short pieces can share the seeded font effect under another actor without carrying
|
||||
// enough tags to identify themselves. The exact style, time, and font group catches
|
||||
// those pieces without inspecting their content.
|
||||
const textureGroups = new Set(
|
||||
events.dialogue
|
||||
.filter(isAssFontTextureSeed)
|
||||
.map(assFontTextureGroupKey)
|
||||
.filter((key): key is string => key !== null),
|
||||
seeds.map(assFontTextureGroupKey).filter((key): key is string => key !== null),
|
||||
);
|
||||
const noFontTextureTimings = new Set(
|
||||
seeds
|
||||
.filter((seed) => staticFontOverride(seed) === null)
|
||||
.map((seed) => assTextureTimingGroupKey(seed)),
|
||||
);
|
||||
// Some signs switch actor and font between the texture mask and its payload. A nearly
|
||||
// transparent text event that overlaps a proven seed in the same style is another input
|
||||
// to that visual effect. Opaque authored text in the same sign remains publishable.
|
||||
const seedsByStyle = new Map<string, AnnotatedSubtitleCue[]>();
|
||||
for (const seed of seeds) {
|
||||
const styleSeeds = seedsByStyle.get(seed.style);
|
||||
if (styleSeeds) {
|
||||
styleSeeds.push(seed);
|
||||
} else {
|
||||
seedsByStyle.set(seed.style, [seed]);
|
||||
}
|
||||
}
|
||||
const seedIndexesByStyle = new Map(
|
||||
[...seedsByStyle].map(([style, styleSeeds]) => [style, buildAssEventGroupIndex(styleSeeds)]),
|
||||
);
|
||||
|
||||
return {
|
||||
dialogue: events.dialogue.filter((cue) => {
|
||||
if (seedSet.has(cue)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
staticFontOverride(cue) === null &&
|
||||
noFontTextureTimings.has(assTextureTimingGroupKey(cue)) &&
|
||||
isClippedRepeatedGlyphFragment(cue)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const key = assFontTextureGroupKey(cue);
|
||||
return key === null || !textureGroups.has(key);
|
||||
if (key !== null && textureGroups.has(key)) {
|
||||
return false;
|
||||
}
|
||||
if (!isNearlyTransparentPositionedText(cue)) {
|
||||
return true;
|
||||
}
|
||||
const styleSeedIndex = seedIndexesByStyle.get(cue.style);
|
||||
return (
|
||||
styleSeedIndex === undefined ||
|
||||
eventsOverlappingWindow(styleSeedIndex, cue.startTime, cue.endTime).length === 0
|
||||
);
|
||||
}),
|
||||
comments: events.comments,
|
||||
};
|
||||
|
||||
@@ -170,6 +170,30 @@ test('findActiveSubtitleText keeps a canonical ASS cue for its generated animati
|
||||
assert.equal(findActiveSubtitleText([poof], 1111.59), '');
|
||||
});
|
||||
|
||||
test('findActiveSubtitleText advances when the next canonical lyric animation starts', () => {
|
||||
const cues = [
|
||||
{
|
||||
startTime: 121.73,
|
||||
endTime: 124.1,
|
||||
text: 'Torn at the seams, a sound pours out',
|
||||
source: 'canonical-ass' as const,
|
||||
animationStartTime: 121.4,
|
||||
animationEndTime: 124.1,
|
||||
},
|
||||
{
|
||||
startTime: 124.13,
|
||||
endTime: 126.38,
|
||||
text: 'It’s silent, yet spreads all around',
|
||||
source: 'canonical-ass' as const,
|
||||
animationStartTime: 123.8,
|
||||
animationEndTime: 126.38,
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(findActiveSubtitleText(cues, 123.79), cues[0]!.text);
|
||||
assert.equal(findActiveSubtitleText(cues, 123.8), cues[1]!.text);
|
||||
});
|
||||
|
||||
test('ASS fragment karaoke stays separated by style with authored word spacing', () => {
|
||||
const lineEvents = (
|
||||
style: string,
|
||||
|
||||
@@ -91,7 +91,26 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds
|
||||
(cue) =>
|
||||
cue.source === 'canonical-ass' && cue.startTime <= timeSeconds && cue.endTime > timeSeconds,
|
||||
);
|
||||
const selectedCanonical = new Set<SubtitleCue>(authoredCanonical);
|
||||
const enteringCanonical = cues.filter(
|
||||
(cue) =>
|
||||
cue.source === 'canonical-ass' &&
|
||||
(cue.animationStartTime ?? cue.startTime) <= timeSeconds &&
|
||||
cue.startTime > timeSeconds &&
|
||||
(cue.animationEndTime ?? cue.endTime) > timeSeconds,
|
||||
);
|
||||
const nextAuthoredStart = enteringCanonical.reduce(
|
||||
(earliest, cue) => Math.min(earliest, cue.startTime),
|
||||
Infinity,
|
||||
);
|
||||
// Generated lyrics can begin drawing before their canonical Comment timing. Once that
|
||||
// entrance starts, replace a preceding lyric that ends before the new authored span;
|
||||
// genuinely concurrent subtitles that continue through the new span stay selected.
|
||||
const selectedCanonical = new Set<SubtitleCue>([
|
||||
...authoredCanonical.filter(
|
||||
(cue) => enteringCanonical.length === 0 || cue.endTime > nextAuthoredStart,
|
||||
),
|
||||
...enteringCanonical,
|
||||
]);
|
||||
if (selectedCanonical.size === 0) {
|
||||
const animatedCanonical = cues.filter(
|
||||
(cue) =>
|
||||
|
||||
Reference in New Issue
Block a user