mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-25 12:15:26 -07:00
fix(subtitles): suppress texture payloads and recover static ASS lyrics
- Filter texture-font payloads while preserving phone translations - Prefer static canonical dialogue over animated glyph copies
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 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.
|
||||
- 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. Static canonical lyric lines also replace their animated glyph copies. Wrapped lyrics remain intact when a timed token repeats at another horizontal position. Tiny multiline alpha payloads from known texture-font families are suppressed, while phone translations styled with word-level secondary alpha remain publishable. 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.
|
||||
|
||||
@@ -556,6 +556,29 @@ test('parseSubtitleCues recovers a full Dialogue line surrounding generated frag
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues replaces animated glyph copies of a static canonical Dialogue line', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 1,0:00:01.00,0:00:04.00,OP - JP,,0,0,0,,重複字幕',
|
||||
'Dialogue: 2,0:00:01.00,0:00:04.00,OP - JP,,0,0,0,,{\\pos(400,50)\\t(0,100,\\fry0)}重',
|
||||
'Dialogue: 2,0:00:01.10,0:00:04.00,OP - JP,,0,0,0,,{\\pos(440,50)\\t(0,100,\\fry0)}複',
|
||||
'Dialogue: 2,0:00:01.20,0:00:04.00,OP - JP,,0,0,0,,{\\pos(480,50)\\t(0,100,\\fry0)}字',
|
||||
'Dialogue: 2,0:00:01.30,0:00:04.00,OP - JP,,0,0,0,,{\\pos(520,50)\\t(0,100,\\fry0)}幕',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 4,
|
||||
text: '重複字幕',
|
||||
source: 'canonical-ass',
|
||||
animationStartTime: 1,
|
||||
animationEndTime: 4,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues does not promote a short animated fragment as a complete line', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
@@ -1280,6 +1303,21 @@ test('parseSubtitleCues preserves opaque same-font text beside texture fragments
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues drops tiny alpha payloads from a proven texture font', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Comment: 2,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\fs100\\alpha&HFC&}texture seed',
|
||||
"Dialogue: 1,0:00:06.00,0:00:09.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain\\fs10\\alpha&H70&}q26D'vrA;\\NE? GS\\NESLhlawEv",
|
||||
'Dialogue: 3,0:00:06.00,0:00:09.00,FrogSigns,,0,0,0,,{\\pos(1040,620)\\fnSF Pro Display\\fs66}Waiting!',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
|
||||
['Waiting!'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues drops clipped repeated-glyph texture text without a font override', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
|
||||
@@ -966,8 +966,13 @@ function staticFontOverride(cue: AnnotatedSubtitleCue): string | null {
|
||||
|
||||
const MIN_TEXTURE_GLYPH_RUN = 8;
|
||||
const MIN_TEXTURE_ALPHA_OVERRIDES = 6;
|
||||
const MAX_TEXTURE_GLYPHS_PER_ALPHA_OVERRIDE = 2;
|
||||
const MIN_TEXTURE_LAYER_ALPHA = 0xe0;
|
||||
const MAX_TEXTURE_PAYLOAD_FONT_SIZE = 12;
|
||||
const MIN_TEXTURE_PAYLOAD_LINES = 3;
|
||||
const ASS_ALPHA_VALUE_PATTERN = /^&?H([0-9a-f]{1,2})&?$/iu;
|
||||
const ASS_FONT_WEIGHT_SUFFIX_PATTERN =
|
||||
/\s+(?:black|bold|heavy|light|medium|regular|semibold|thin)$/u;
|
||||
|
||||
function hasStaticOverride(cue: AnnotatedSubtitleCue, expectedName: string): boolean {
|
||||
return cue.overrides.some(
|
||||
@@ -1011,6 +1016,11 @@ function isAssTextureSeed(cue: AnnotatedSubtitleCue): boolean {
|
||||
if (secondaryAlpha.length < MIN_TEXTURE_ALPHA_OVERRIDES) {
|
||||
return false;
|
||||
}
|
||||
// Real signs can alternate secondary alpha between words. Texture payloads switch it
|
||||
// per glyph, so sparse word-level styling must not seed a texture-font group.
|
||||
if (glyphs.length > secondaryAlpha.length * MAX_TEXTURE_GLYPHS_PER_ALPHA_OVERRIDE) {
|
||||
return false;
|
||||
}
|
||||
const alphaValues = secondaryAlpha.map((command) => command.args.toLowerCase());
|
||||
return new Set(alphaValues).size >= 2;
|
||||
}
|
||||
@@ -1028,6 +1038,18 @@ function staticGlobalAlpha(cue: AnnotatedSubtitleCue): number | null {
|
||||
return alpha;
|
||||
}
|
||||
|
||||
function staticFontSize(cue: AnnotatedSubtitleCue): number | null {
|
||||
let fontSize: number | null = null;
|
||||
for (const command of cue.overrides) {
|
||||
if (command.animated || command.name.toLowerCase() !== 'fs') continue;
|
||||
const value = Number(command.args.trim());
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
fontSize = value;
|
||||
}
|
||||
}
|
||||
return fontSize;
|
||||
}
|
||||
|
||||
function isNearlyTransparentPositionedText(cue: AnnotatedSubtitleCue): boolean {
|
||||
const alpha = staticGlobalAlpha(cue);
|
||||
return (
|
||||
@@ -1046,6 +1068,28 @@ function hasAssTextureCandidateEvidence(cue: AnnotatedSubtitleCue): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function textureFontFamilyKey(font: string): string {
|
||||
return font.replace(ASS_FONT_WEIGHT_SUFFIX_PATTERN, '');
|
||||
}
|
||||
|
||||
function isTextureFontPayload(
|
||||
cue: AnnotatedSubtitleCue,
|
||||
textureFontFamilies: ReadonlySet<string>,
|
||||
): boolean {
|
||||
const font = staticFontOverride(cue);
|
||||
const fontSize = staticFontSize(cue);
|
||||
const visibleLines = cue.text.split('\n').filter((line) => line.trim().length > 0);
|
||||
return (
|
||||
font !== null &&
|
||||
textureFontFamilies.has(textureFontFamilyKey(font)) &&
|
||||
fontSize !== null &&
|
||||
fontSize <= MAX_TEXTURE_PAYLOAD_FONT_SIZE &&
|
||||
staticGlobalAlpha(cue) !== null &&
|
||||
fragmentPosition(cue) !== null &&
|
||||
visibleLines.length >= MIN_TEXTURE_PAYLOAD_LINES
|
||||
);
|
||||
}
|
||||
|
||||
function assFontTextureGroupKey(cue: AnnotatedSubtitleCue): string | null {
|
||||
const font = staticFontOverride(cue);
|
||||
return font === null ? null : `${cue.style}\0${cue.startTime}\0${cue.endTime}\0${font}`;
|
||||
@@ -1058,6 +1102,16 @@ function assTextureTimingGroupKey(cue: AnnotatedSubtitleCue): string {
|
||||
function removeAssFontTextureEvents(events: ParsedAssEvents): ParsedAssEvents {
|
||||
const seeds = events.dialogue.filter(isAssTextureSeed);
|
||||
const seedSet = new Set(seeds);
|
||||
const textureFontFamilies = new Set(
|
||||
[
|
||||
...seeds,
|
||||
...events.dialogue.filter(isNearlyTransparentPositionedText),
|
||||
...events.comments.filter(isNearlyTransparentPositionedText),
|
||||
]
|
||||
.map(staticFontOverride)
|
||||
.filter((font): font is string => font !== null)
|
||||
.map(textureFontFamilyKey),
|
||||
);
|
||||
// Short pieces can share the seeded font effect under another actor. Matching the
|
||||
// seed's style, timing, and font only narrows the candidates; each piece must still
|
||||
// carry structural texture evidence.
|
||||
@@ -1101,6 +1155,9 @@ function removeAssFontTextureEvents(events: ParsedAssEvents): ParsedAssEvents {
|
||||
if (key !== null && textureGroups.has(key) && hasAssTextureCandidateEvidence(cue)) {
|
||||
return false;
|
||||
}
|
||||
if (isTextureFontPayload(cue, textureFontFamilies)) {
|
||||
return false;
|
||||
}
|
||||
if (!isNearlyTransparentPositionedText(cue)) {
|
||||
return true;
|
||||
}
|
||||
@@ -1358,7 +1415,7 @@ function recoverCanonicalAssEvents({
|
||||
.filter(
|
||||
(cue) =>
|
||||
compactCueMatchText(cue).length >= MIN_CANONICAL_DIALOGUE_TEXT_LENGTH &&
|
||||
hasAssAnimationEvidence([cue]),
|
||||
(cue.assLayout?.kind === 'source-order' || hasAssAnimationEvidence([cue])),
|
||||
)
|
||||
.sort((left, right) => right.text.length - left.text.length || left.order - right.order)
|
||||
.map((cue) => ({ cue, kind: 'dialogue' as const })),
|
||||
|
||||
@@ -98,6 +98,22 @@ test('parsed secondary text drops a reconstructed grid of positioned sign fragme
|
||||
);
|
||||
});
|
||||
|
||||
test('parsed secondary text keeps phone translations while dropping texture payloads', () => {
|
||||
const ass = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Comment: 2,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain Medium\\fs100\\alpha&HFC&}texture seed',
|
||||
'Dialogue: 90,0:00:01.00,0:00:03.00,Default,,0,0,0,,Why did you choose Hanajo instead?',
|
||||
"Dialogue: 1,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(580,95)\\fnGrain\\fs10\\alpha&H70&}q26D'vrA;\\NE? GS\\NESLhlawEv",
|
||||
"Dialogue: 3,0:00:01.00,0:00:03.00,FrogSigns,,0,0,0,,{\\pos(582,180)\\fnSF Pro Display\\fs66}We're {\\2a0}running {\\2a1}out {\\2a0}of {\\2a1}time!\\N{\\2a0}Where {\\2a1}are {\\2a0}you {\\2a1}right {\\2a0}now?!",
|
||||
].join('\n');
|
||||
|
||||
assert.equal(
|
||||
findActiveSubtitleText(parseSubtitleCues(ass, 'phone.ass'), 2),
|
||||
"Why did you choose Hanajo instead?\nWe're running out of time!\nWhere are you right now?!",
|
||||
);
|
||||
});
|
||||
|
||||
test('parsed secondary lyrics keep explicit ASS vertical order when durations alternate', () => {
|
||||
const lyric = (options: { start: string; end: string; style: string; y: number; text: string }) =>
|
||||
`Dialogue: 0,0:00:${options.start},0:00:${options.end},${options.style},,0,0,0,fx,{\\move(100,${options.y},120,${options.y})\\t(0,200,\\fscx110)}${options.text}\\N{\\p1}m 0 0 l 0 5`;
|
||||
|
||||
Reference in New Issue
Block a user