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:
@@ -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 })),
|
||||
|
||||
Reference in New Issue
Block a user