mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-29 12:15:27 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2509b6cd7
|
@@ -2,4 +2,4 @@ type: fixed
|
|||||||
area: overlay
|
area: overlay
|
||||||
|
|
||||||
- Subtitle lines that start while another line is still on screen now appear alongside it instead of staying hidden until a track switch or seek (#220).
|
- Subtitle lines that start while another line is still on screen now appear alongside it instead of staying hidden until a track switch or seek (#220).
|
||||||
- Two subtitles shown at the same time now always render on separate lines, stacked by their authored screen position (top signs and song lines above bottom dialogue), so lines no longer merge into one sentence or swap rows mid-display.
|
- Genuine subtitles shown at the same time now render on separate lines, stacked by their authored screen position (top signs and song lines above bottom dialogue), while half-size ASS furigana is omitted instead of appearing as dialogue.
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
|||||||
between ordinary, hard, or ideographic spaces appear once.
|
between ordinary, hard, or ideographic spaces appear once.
|
||||||
- Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their
|
- Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their
|
||||||
authored source order when no usable position exists.
|
authored source order when no usable position exists.
|
||||||
|
- Half-size kana positioned directly above a same-timed kanji caption is treated as ASS
|
||||||
|
furigana. The parser omits it from published cues but retains hidden matching metadata so
|
||||||
|
mpv's raw live text can be reconciled without displaying or mining the reading.
|
||||||
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
|
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
|
||||||
survive concatenation. Latin fragment typesetting with no literal spaces also recovers word
|
survive concatenation. Latin fragment typesetting with no literal spaces also recovers word
|
||||||
boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that
|
boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that
|
||||||
|
|||||||
@@ -1442,7 +1442,7 @@ const eventsHeader = [
|
|||||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
'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 = [
|
const content = [
|
||||||
...eventsHeader,
|
...eventsHeader,
|
||||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(212,383)\\fscx50\\fscy50}たき',
|
'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}燈をバンドに誘ったの?',
|
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const cue = parseSubtitleCues(content, 'test.ass')[0];
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
assert.equal(cue?.text, 'たき(立希)ともりお前… 燈をバンドに誘ったの?');
|
assert.deepEqual(
|
||||||
assert.equal(cue?.assLayout?.kind, 'positioned');
|
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', () => {
|
test('parseSubtitleCues marks re-shown countdown frames as a fragment grid', () => {
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedu
|
|||||||
export type AssVerticalBand = 'top' | 'middle' | 'bottom';
|
export type AssVerticalBand = 'top' | 'middle' | 'bottom';
|
||||||
|
|
||||||
export type AssCueLayout =
|
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: 'fragment-grid'; sourceOrder: number; verticalBand?: AssVerticalBand }
|
||||||
| { kind: 'source-order'; sourceOrder: number; verticalBand?: AssVerticalBand };
|
| { kind: 'source-order'; sourceOrder: number; verticalBand?: AssVerticalBand };
|
||||||
|
|
||||||
@@ -22,6 +28,11 @@ export interface SubtitleCue {
|
|||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
text: string;
|
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. */
|
/** How a complete line was recovered from generated ASS animation events. */
|
||||||
source?: 'canonical-ass' | 'reconstructed-ass';
|
source?: 'canonical-ass' | 'reconstructed-ass';
|
||||||
/**
|
/**
|
||||||
@@ -100,10 +111,17 @@ function sanitizeAssCueText(text: string): string {
|
|||||||
return removeAssControlDebrisLines(decodeSubtitleCueText(text)).trim();
|
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) {
|
if (assLayout) {
|
||||||
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
|
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
|
||||||
}
|
}
|
||||||
|
if (assFurigana?.length) {
|
||||||
|
Object.defineProperty(cue, 'assFurigana', { value: assFurigana, enumerable: false });
|
||||||
|
}
|
||||||
return cue;
|
return cue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +136,7 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
|||||||
animationEndTime,
|
animationEndTime,
|
||||||
style,
|
style,
|
||||||
assLayout,
|
assLayout,
|
||||||
|
assFurigana,
|
||||||
}) => {
|
}) => {
|
||||||
const common = {
|
const common = {
|
||||||
startTime,
|
startTime,
|
||||||
@@ -125,7 +144,7 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
|||||||
text,
|
text,
|
||||||
};
|
};
|
||||||
if (source === 'reconstructed-ass') {
|
if (source === 'reconstructed-ass') {
|
||||||
return attachAssLayout(
|
return attachAssMetadata(
|
||||||
{
|
{
|
||||||
...common,
|
...common,
|
||||||
source,
|
source,
|
||||||
@@ -134,11 +153,13 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
|||||||
assStyle: style,
|
assStyle: style,
|
||||||
},
|
},
|
||||||
assLayout,
|
assLayout,
|
||||||
|
assFurigana,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return attachAssLayout(
|
return attachAssMetadata(
|
||||||
source ? { ...common, source, animationStartTime, animationEndTime } : common,
|
source ? { ...common, source, animationStartTime, animationEndTime } : common,
|
||||||
assLayout,
|
assLayout,
|
||||||
|
assFurigana,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -2019,6 +2040,9 @@ function recoverCanonicalAssEvents({
|
|||||||
const animationEndTime = latestEndTime(generatedEvents, candidate.endTime);
|
const animationEndTime = latestEndTime(generatedEvents, candidate.endTime);
|
||||||
const startTime = kind === 'comment' ? candidate.startTime : animationStartTime;
|
const startTime = kind === 'comment' ? candidate.startTime : animationStartTime;
|
||||||
const endTime = kind === 'comment' ? candidate.endTime : animationEndTime;
|
const endTime = kind === 'comment' ? candidate.endTime : animationEndTime;
|
||||||
|
const assFurigana = [
|
||||||
|
...new Set([candidate, ...generatedEvents].flatMap((cue) => cue.assFurigana ?? [])),
|
||||||
|
];
|
||||||
const recoveredCue: AnnotatedSubtitleCue = {
|
const recoveredCue: AnnotatedSubtitleCue = {
|
||||||
...candidate,
|
...candidate,
|
||||||
startTime,
|
startTime,
|
||||||
@@ -2026,6 +2050,7 @@ function recoverCanonicalAssEvents({
|
|||||||
animationStartTime,
|
animationStartTime,
|
||||||
animationEndTime,
|
animationEndTime,
|
||||||
source: 'canonical-ass',
|
source: 'canonical-ass',
|
||||||
|
...(assFurigana.length === 0 ? {} : { assFurigana }),
|
||||||
};
|
};
|
||||||
recovered.push(recoveredCue);
|
recovered.push(recoveredCue);
|
||||||
recoveredByOwner.set(candidate, recoveredCue);
|
recoveredByOwner.set(candidate, recoveredCue);
|
||||||
@@ -2170,34 +2195,156 @@ function buildAssCueLayout(
|
|||||||
style: string,
|
style: string,
|
||||||
placement: AssPlacementContext,
|
placement: AssPlacementContext,
|
||||||
): AssCueLayout {
|
): AssCueLayout {
|
||||||
|
let x: number | null = null;
|
||||||
let y: number | null = null;
|
let y: number | null = null;
|
||||||
for (const command of overrides) {
|
for (const command of overrides) {
|
||||||
if (command.animated) continue;
|
if (command.animated) continue;
|
||||||
const name = command.name.toLowerCase();
|
const name = command.name.toLowerCase();
|
||||||
const args = command.args.split(',');
|
const args = command.args.split(',');
|
||||||
if (name === 'pos') {
|
if (name === 'pos') {
|
||||||
|
x = parseAssCoordinate(args[0]) ?? x;
|
||||||
y = parseAssCoordinate(args[1]) ?? y;
|
y = parseAssCoordinate(args[1]) ?? y;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (name !== 'move') continue;
|
if (name !== 'move') continue;
|
||||||
|
const startX = parseAssCoordinate(args[0]);
|
||||||
const startY = parseAssCoordinate(args[1]);
|
const startY = parseAssCoordinate(args[1]);
|
||||||
|
const endX = parseAssCoordinate(args[2]);
|
||||||
const endY = parseAssCoordinate(args[3]);
|
const endY = parseAssCoordinate(args[3]);
|
||||||
|
if (startX !== null && endX !== null) {
|
||||||
|
x = (startX + endX) / 2;
|
||||||
|
}
|
||||||
if (startY !== null && endY !== null) {
|
if (startY !== null && endY !== null) {
|
||||||
y = (startY + endY) / 2;
|
y = (startY + endY) / 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const verticalBand = resolveVerticalBand(overrides, y, style, placement);
|
const verticalBand = resolveVerticalBand(overrides, y, style, placement);
|
||||||
const base: AssCueLayout =
|
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;
|
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 cues: AnnotatedSubtitleCue[] = [];
|
||||||
const comments: AnnotatedSubtitleCue[] = [];
|
const comments: AnnotatedSubtitleCue[] = [];
|
||||||
const placement = content.includes('[')
|
|
||||||
? parseAssPlacementContext(content)
|
|
||||||
: EMPTY_PLACEMENT_CONTEXT;
|
|
||||||
const lines = content.split(/\r?\n/);
|
const lines = content.split(/\r?\n/);
|
||||||
let inEventsSection = false;
|
let inEventsSection = false;
|
||||||
let eventOrder = 0;
|
let eventOrder = 0;
|
||||||
@@ -2322,7 +2469,13 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
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));
|
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,32 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolvePrimarySubtitleText accounts for live ASS furigana after canonical recovery', () => {
|
||||||
|
const ass = [
|
||||||
|
'[Script Info]',
|
||||||
|
'PlayResY: 540',
|
||||||
|
'',
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(192,77)}ごめん 結局 ぬれたな。',
|
||||||
|
'Comment: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,大丈夫。',
|
||||||
|
'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)\\clip(m 1 1)}大丈夫。',
|
||||||
|
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 2 2)}大丈夫。',
|
||||||
|
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 3 3)}大丈夫。',
|
||||||
|
].join('\n');
|
||||||
|
const cues = parseSubtitleCues(ass, 'polar-opposites-s02e08.ass');
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolvePrimarySubtitleText({
|
||||||
|
liveText: 'ごめん 結局 ぬれたな。\nだいじょうぶ\n大丈夫。',
|
||||||
|
currentTimeSec: 159,
|
||||||
|
cues,
|
||||||
|
}),
|
||||||
|
'ごめん 結局 ぬれたな。\n\n大丈夫。',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('resolvePrimarySubtitleText removes duplicate lines across multiline parsed cues', () => {
|
test('resolvePrimarySubtitleText removes duplicate lines across multiline parsed cues', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolvePrimarySubtitleText({
|
resolvePrimarySubtitleText({
|
||||||
|
|||||||
@@ -149,12 +149,15 @@ function resolveActiveParsedPrimarySubtitle(options: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedSegments = selected.flatMap((cue) =>
|
const parsedSegments = selected.flatMap((cue) => {
|
||||||
compactLineSegments(cue.text).map((segment) => ({
|
const recovered = cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass';
|
||||||
segment,
|
return [
|
||||||
recovered: cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass',
|
...compactLineSegments(cue.text).map((segment) => ({ segment, recovered })),
|
||||||
})),
|
...(cue.assFurigana ?? []).flatMap((text) =>
|
||||||
);
|
compactLineSegments(text).map((segment) => ({ segment, recovered: false })),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
!liveSegments.every((liveSegment) =>
|
!liveSegments.every((liveSegment) =>
|
||||||
parsedSegments.some(({ segment, recovered }) =>
|
parsedSegments.some(({ segment, recovered }) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user