fix(subtitles): drop ASS furigana from published cues (#224)

This commit is contained in:
2026-08-29 13:15:30 -07:00
committed by GitHub
parent 051140f910
commit 5cc21113fd
6 changed files with 291 additions and 21 deletions
+89 -4
View File
@@ -1442,7 +1442,7 @@ const eventsHeader = [
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
];
test('parseSubtitleCues keeps a tall CC-style dialogue block publishable, not a fragment grid', () => {
test('parseSubtitleCues keeps tall CC-style base dialogue publishable after removing furigana', () => {
const content = [
...eventsHeader,
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(212,383)\\fscx50\\fscy50}たき',
@@ -1451,9 +1451,94 @@ test('parseSubtitleCues keeps a tall CC-style dialogue block publishable, not a
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?',
].join('\n');
const cue = parseSubtitleCues(content, 'test.ass')[0];
assert.equal(cue?.text, 'たき(立希)ともりお前… 燈をバンドに誘ったの?');
assert.equal(cue?.assLayout?.kind, 'positioned');
const cues = parseSubtitleCues(content, 'test.ass');
assert.deepEqual(
cues.map((cue) => cue.text),
['(立希)', 'お前… 燈をバンドに誘ったの?'],
);
assert.deepEqual(cues[0]?.assFurigana, ['たき']);
assert.deepEqual(cues[1]?.assFurigana, ['ともり']);
assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned'));
});
test('parseSubtitleCues removes half-size positioned furigana from broadcast captions', () => {
const content = [
'[Script Info]',
'PlayResY: 540',
'',
...eventsHeader,
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(192,77)\\fscx50}{\\fscx100}山田{\\fscx50}{\\fscx100}ごめん{\\fscx50} {\\fscx100}結局{\\fscx50} {\\fscx100}ぬれたな{\\fscx50}。',
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,113)\\fscx50\\fscy50}だいじょうぶ',
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)}大丈夫{\\fscx50}。',
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,407)\\fscx50}{\\fscx100}山田の母{\\fscx50}{\\fscx100}ほんなら',
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,443)\\fscx50\\fscy50}かく',
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(312,443)\\fscx50\\fscy50}ちょぞう',
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,497)}隠し貯蔵のミルクまんじゅう➡',
'Dialogue: 0,0:04:00.00,0:04:03.00,Default,,0,0,0,,{\\pos(232,443)\\fscx50\\fscy50}ぜったい ちが',
'Dialogue: 0,0:04:00.00,0:04:03.00,Default,,0,0,0,,{\\pos(232,497)}絶対違う',
].join('\n');
const cues = parseSubtitleCues(content, 'polar-opposites-s02e08.ass');
assert.deepEqual(
cues.map((cue) => cue.text),
[
'(山田)ごめん 結局 ぬれたな。',
'大丈夫。',
'(山田の母)ほんなら',
'隠し貯蔵のミルクまんじゅう➡',
'絶対違う',
],
);
assert.deepEqual(cues[1]?.assFurigana, ['だいじょうぶ']);
assert.deepEqual(cues[3]?.assFurigana, ['かく', 'ちょぞう']);
assert.deepEqual(cues[4]?.assFurigana, ['ぜったい ちが']);
});
test('parseSubtitleCues scales furigana geometry by PlayResY', () => {
const content = [
'[Script Info]',
'PlayResY: 1080',
'',
...eventsHeader,
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(1104,226)\\fscx50\\fscy50}だいじょうぶ',
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(1104,334)}大丈夫{\\fscx50}。',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.deepEqual(
cues.map((cue) => cue.text),
['大丈夫。'],
);
assert.deepEqual(cues[0]?.assFurigana, ['だいじょうぶ']);
});
test('parseSubtitleCues preserves small kana without a matching kanji base caption', () => {
const content = [
...eventsHeader,
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,200)\\fscx50\\fscy50}ひそひそ',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,254)}ordinary dialogue',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['ひそひそ', 'ordinary dialogue'],
);
});
test('parseSubtitleCues preserves small kana horizontally separated from a kanji caption', () => {
const content = [
...eventsHeader,
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(800,200)\\fscx50\\fscy50}ひそひそ',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,254)}漢字',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.match(cues.map((cue) => cue.text).join('\n'), /ひそひそ/);
assert.deepEqual(
cues.flatMap((cue) => cue.assFurigana ?? []),
[],
);
});
test('parseSubtitleCues marks re-shown countdown frames as a fragment grid', () => {
+163 -10
View File
@@ -14,7 +14,13 @@ import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedu
export type AssVerticalBand = 'top' | 'middle' | 'bottom';
export type AssCueLayout =
| { kind: 'positioned'; sourceOrder: number; y: number; verticalBand?: AssVerticalBand }
| {
kind: 'positioned';
sourceOrder: number;
x?: number;
y: number;
verticalBand?: AssVerticalBand;
}
| { kind: 'fragment-grid'; sourceOrder: number; verticalBand?: AssVerticalBand }
| { kind: 'source-order'; sourceOrder: number; verticalBand?: AssVerticalBand };
@@ -22,6 +28,11 @@ export interface SubtitleCue {
startTime: number;
endTime: number;
text: string;
/**
* ASS ruby text removed from the published cue. Kept only so live `sub-text` matching
* can account for the extra lines mpv still reports from the source track.
*/
assFurigana?: readonly string[];
/** How a complete line was recovered from generated ASS animation events. */
source?: 'canonical-ass' | 'reconstructed-ass';
/**
@@ -100,10 +111,17 @@ function sanitizeAssCueText(text: string): string {
return removeAssControlDebrisLines(decodeSubtitleCueText(text)).trim();
}
function attachAssLayout<T extends SubtitleCue>(cue: T, assLayout: AssCueLayout | undefined): T {
function attachAssMetadata<T extends SubtitleCue>(
cue: T,
assLayout: AssCueLayout | undefined,
assFurigana: readonly string[] | undefined,
): T {
if (assLayout) {
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
}
if (assFurigana?.length) {
Object.defineProperty(cue, 'assFurigana', { value: assFurigana, enumerable: false });
}
return cue;
}
@@ -118,6 +136,7 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
animationEndTime,
style,
assLayout,
assFurigana,
}) => {
const common = {
startTime,
@@ -125,7 +144,7 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
text,
};
if (source === 'reconstructed-ass') {
return attachAssLayout(
return attachAssMetadata(
{
...common,
source,
@@ -134,11 +153,13 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
assStyle: style,
},
assLayout,
assFurigana,
);
}
return attachAssLayout(
return attachAssMetadata(
source ? { ...common, source, animationStartTime, animationEndTime } : common,
assLayout,
assFurigana,
);
},
);
@@ -2019,6 +2040,9 @@ function recoverCanonicalAssEvents({
const animationEndTime = latestEndTime(generatedEvents, candidate.endTime);
const startTime = kind === 'comment' ? candidate.startTime : animationStartTime;
const endTime = kind === 'comment' ? candidate.endTime : animationEndTime;
const assFurigana = [
...new Set([candidate, ...generatedEvents].flatMap((cue) => cue.assFurigana ?? [])),
];
const recoveredCue: AnnotatedSubtitleCue = {
...candidate,
startTime,
@@ -2026,6 +2050,7 @@ function recoverCanonicalAssEvents({
animationStartTime,
animationEndTime,
source: 'canonical-ass',
...(assFurigana.length === 0 ? {} : { assFurigana }),
};
recovered.push(recoveredCue);
recoveredByOwner.set(candidate, recoveredCue);
@@ -2170,34 +2195,156 @@ function buildAssCueLayout(
style: string,
placement: AssPlacementContext,
): AssCueLayout {
let x: number | null = null;
let y: number | null = null;
for (const command of overrides) {
if (command.animated) continue;
const name = command.name.toLowerCase();
const args = command.args.split(',');
if (name === 'pos') {
x = parseAssCoordinate(args[0]) ?? x;
y = parseAssCoordinate(args[1]) ?? y;
continue;
}
if (name !== 'move') continue;
const startX = parseAssCoordinate(args[0]);
const startY = parseAssCoordinate(args[1]);
const endX = parseAssCoordinate(args[2]);
const endY = parseAssCoordinate(args[3]);
if (startX !== null && endX !== null) {
x = (startX + endX) / 2;
}
if (startY !== null && endY !== null) {
y = (startY + endY) / 2;
}
}
const verticalBand = resolveVerticalBand(overrides, y, style, placement);
const base: AssCueLayout =
y === null ? { kind: 'source-order', sourceOrder } : { kind: 'positioned', sourceOrder, y };
y === null
? { kind: 'source-order', sourceOrder }
: { kind: 'positioned', sourceOrder, ...(x === null ? {} : { x }), y };
return verticalBand ? { ...base, verticalBand } : base;
}
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
const ASS_FURIGANA_TEXT_PATTERN = /^[\p{Script=Hiragana}\p{Script=Katakana} \t\u3000]+$/u;
const ASS_KANJI_PATTERN = /\p{Script=Han}/u;
const MAX_ASS_FURIGANA_SCALE_PERCENT = 60;
// The pixel geometry below is authored in the 540-line coordinate space Caption2Ass-style
// broadcast CC converters emit, and is multiplied by PlayResY/540 so the same on-screen
// window applies to scripts declaring other resolutions. Without a declaration the tuned
// space is assumed.
const ASS_FURIGANA_REFERENCE_PLAY_RES_Y = 540;
const MIN_ASS_FURIGANA_BASE_GAP = 40;
const MAX_ASS_FURIGANA_BASE_GAP = 68;
const MIN_ASS_FURIGANA_HORIZONTAL_TOLERANCE = 80;
const ASS_BASE_CHARACTER_WIDTH_ESTIMATE = 40;
function assFuriganaGeometryScale(playResY: number | null): number {
return playResY && playResY > 0 ? playResY / ASS_FURIGANA_REFERENCE_PLAY_RES_Y : 1;
}
function staticAssScalePercent(cue: AnnotatedSubtitleCue, axis: 'fscx' | 'fscy'): number | null {
let scale: number | null = null;
for (const command of cue.overrides) {
if (command.animated || command.name.toLowerCase() !== axis) continue;
const value = Number(command.args.trim());
if (Number.isFinite(value) && value > 0) {
scale = value;
}
}
return scale;
}
function isAssFuriganaCandidate(cue: AnnotatedSubtitleCue): boolean {
const scaleX = staticAssScalePercent(cue, 'fscx');
const scaleY = staticAssScalePercent(cue, 'fscy');
return (
cue.assLayout?.kind === 'positioned' &&
ASS_FURIGANA_TEXT_PATTERN.test(cue.text) &&
scaleX !== null &&
scaleX <= MAX_ASS_FURIGANA_SCALE_PERCENT &&
scaleY !== null &&
scaleY <= MAX_ASS_FURIGANA_SCALE_PERCENT
);
}
function findAssFuriganaBase(
furigana: AnnotatedSubtitleCue,
cues: readonly AnnotatedSubtitleCue[],
geometryScale: number,
): AnnotatedSubtitleCue | null {
if (furigana.assLayout?.kind !== 'positioned' || furigana.assLayout.x === undefined) {
return null;
}
let nearest: { cue: AnnotatedSubtitleCue; gap: number } | null = null;
for (const cue of cues) {
if (
cue === furigana ||
cue.startTime !== furigana.startTime ||
cue.endTime !== furigana.endTime ||
cue.style !== furigana.style ||
cue.layer !== furigana.layer ||
cue.name !== furigana.name ||
cue.assLayout?.kind !== 'positioned' ||
!ASS_KANJI_PATTERN.test(cue.text)
) {
continue;
}
const scaleY = staticAssScalePercent(cue, 'fscy');
if (scaleY !== null && scaleY <= MAX_ASS_FURIGANA_SCALE_PERCENT) continue;
const gap = cue.assLayout.y - furigana.assLayout.y;
if (
gap < MIN_ASS_FURIGANA_BASE_GAP * geometryScale ||
gap > MAX_ASS_FURIGANA_BASE_GAP * geometryScale
) {
continue;
}
if (cue.assLayout.x === undefined) continue;
const baseCharacterCount = [...cue.text.replace(/[ \t\u3000]/g, '')].length;
const horizontalTolerance =
Math.max(
MIN_ASS_FURIGANA_HORIZONTAL_TOLERANCE,
baseCharacterCount * ASS_BASE_CHARACTER_WIDTH_ESTIMATE,
) * geometryScale;
if (Math.abs(cue.assLayout.x - furigana.assLayout.x) > horizontalTolerance) continue;
if (!nearest || gap < nearest.gap || (gap === nearest.gap && cue.order < nearest.cue.order)) {
nearest = { cue, gap };
}
}
return nearest?.cue ?? null;
}
function removeAssFuriganaFromCueList(
cues: AnnotatedSubtitleCue[],
geometryScale: number,
): AnnotatedSubtitleCue[] {
const removed = new Set<AnnotatedSubtitleCue>();
for (const cue of cues) {
if (!isAssFuriganaCandidate(cue)) continue;
const base = findAssFuriganaBase(cue, cues, geometryScale);
if (!base) continue;
base.assFurigana = [...new Set([...(base.assFurigana ?? []), cue.text])];
removed.add(cue);
}
return removed.size === 0 ? cues : cues.filter((cue) => !removed.has(cue));
}
function removeAssFuriganaEvents(
events: ParsedAssEvents,
playResY: number | null,
): ParsedAssEvents {
const geometryScale = assFuriganaGeometryScale(playResY);
return {
dialogue: removeAssFuriganaFromCueList(events.dialogue, geometryScale),
comments: removeAssFuriganaFromCueList(events.comments, geometryScale),
};
}
function parseAnnotatedAssEvents(content: string, placement: AssPlacementContext): ParsedAssEvents {
const cues: AnnotatedSubtitleCue[] = [];
const comments: AnnotatedSubtitleCue[] = [];
const placement = content.includes('[')
? parseAssPlacementContext(content)
: EMPTY_PLACEMENT_CONTEXT;
const lines = content.split(/\r?\n/);
let inEventsSection = false;
let eventOrder = 0;
@@ -2322,7 +2469,13 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
}
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
const events = removeAssFontTextureEvents(parseAnnotatedAssEvents(content));
const placement = content.includes('[')
? parseAssPlacementContext(content)
: EMPTY_PLACEMENT_CONTEXT;
const events = removeAssFuriganaEvents(
removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)),
placement.playResY,
);
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
}