Compare commits

..
3 Commits
Author SHA1 Message Date
sudacode d1e356f53f fix(youtube): preserve explicit 3000ms caption durations
- Distinguish generated rolling cues from plain-text cues
- Keep explicit sound-cue spans intact
2026-08-27 23:21:58 -07:00
sudacode fa73aea2f9 fix(youtube): keep auto captions timed and paged correctly
- Page oversized rolling captions within YouTube’s row and column limits
- Preserve explicit durations for sound cues
2026-08-27 19:06:59 -07:00
sudacode f8ca8681dc fix(youtube): keep auto captions on screen for their full span
YouTube's sentence-level ASR emits long caption rows with a placeholder
d="3000", while the caption actually displays until the next window
event. Trusting `d` made long lines vanish mid-speech and leave a blank
gap until the next cue.

Rolling auto-caption documents (rows with a="1") now end each cue at the
next event timestamp instead of t + d, matching YouTube's own display
timing. Manual and non-rolling TimedText keep duration-based timing so
real silence gaps are preserved.
2026-08-26 00:07:42 -07:00
29 changed files with 137 additions and 1006 deletions
-15
View File
@@ -1,20 +1,5 @@
# Changelog
## v0.19.5 (2026-08-30)
### Fixed
- **Anki Card Update Progress**: The card-update spinner now stays visible until audio and image updates finish, instead of disappearing early.
- **Anki Word-Card Fields**: Word-card enrichment now writes sentence text and audio to the fields configured in AnkiConnect, while the dedicated sentence-card and audio-card actions keep their existing compatible field names.
- **Overlapping Subtitles**:
- 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.
- Subtitles shown at the same time now stack by their authored screen position, with top signs and song lines above bottom dialogue.
- Half-size ASS furigana is no longer shown as if it were a dialogue line.
- **YouTube Auto Captions**:
- Auto-generated captions now follow their intended timing and two-row roll-up layout.
- Long speech is paged instead of covering the video with a wall of text.
- Explicitly timed sound cues like `[音楽]` no longer cover later dialogue.
## v0.19.4 (2026-08-25)
### Added
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Kept the Anki card update spinner visible until audio and image updates finish.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: youtube
- YouTube auto-generated captions now follow their intended timing and two-row roll-up layout: long speech is paged instead of covering the video with a wall of text, while explicitly timed sound cues such as `[音楽]` no longer cover later dialogue.
+1 -5
View File
@@ -136,8 +136,6 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error.
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
### Minimal Config
@@ -235,7 +233,7 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
When media is available, mined-card overlay and system notifications include the same current-frame thumbnail.
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio in `ankiConnect.fields.audio`, even when `overwriteAudio` is disabled.
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged.
## AI Translation
@@ -289,8 +287,6 @@ Sentence card creation and audio card marking require a non-empty `ankiConnect.i
Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is created directly via AnkiConnect with the sentence, audio, and image filled in.
The dedicated sentence-card and audio-card shortcuts use the Lapis/Kiku-compatible `Sentence` and `SentenceAudio` fields. This does not affect the configured fields used to enrich normal word cards.
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (19) to select how many recent lines to combine.
## Word Card Type (Kiku/Lapis)
-15
View File
@@ -1,20 +1,5 @@
# Changelog
## v0.19.5 (2026-08-30)
**Fixed**
- **Anki Card Update Progress**: The card-update spinner now stays visible until audio and image updates finish, instead of disappearing early.
- **Anki Word-Card Fields**: Word-card enrichment now writes sentence text and audio to the fields configured in AnkiConnect, while the dedicated sentence-card and audio-card actions keep their existing compatible field names.
- **Overlapping Subtitles**:
- 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.
- Subtitles shown at the same time now stack by their authored screen position, with top signs and song lines above bottom dialogue.
- Half-size ASS furigana is no longer shown as if it were a dialogue line.
- **YouTube Auto Captions**:
- Auto-generated captions now follow their intended timing and two-row roll-up layout.
- Long speech is paged instead of covering the video with a wall of text.
- Explicitly timed sound cues like `[音楽]` no longer cover later dialogue.
## v0.19.4 (2026-08-25)
**Added**
+1 -1
View File
@@ -41,7 +41,7 @@ If you prefer a hands-on approach (animecards-style), you can copy the current s
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1``9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation - the same fields auto-update would fill.
Manual clipboard updates always replace generated sentence audio in `ankiConnect.fields.audio`, even when `ankiConnect.behavior.overwriteAudio` is disabled. Normal word-card updates use the configured sentence and audio fields even when Lapis or Kiku support is enabled.
Manual clipboard updates always replace generated sentence audio, even when `ankiConnect.behavior.overwriteAudio` is disabled. The word audio field is left unchanged because the word itself does not change in this flow.
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
@@ -129,9 +129,6 @@ coming and prefetching would otherwise idle for the rest of the cue.
between ordinary, hard, or ideographic spaces appear once.
- Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their
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
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
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.5",
"version": "0.19.4",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
-33
View File
@@ -1,33 +0,0 @@
## Highlights
### Fixed
- **Anki Card Update Progress**: The update spinner now stays visible until audio and image updates actually finish, so you won't mistake an in-progress update for a failure.
- **Word-Card Field Enrichment**: Word-card enrichment now reliably writes sentence text and audio into whichever AnkiConnect fields you've configured, while the dedicated Lapis/Kiku sentence-card and audio-card actions still use their expected field names.
- **Overlapping Subtitles**:
- Lines that start while another is still on screen now show together instead of staying hidden until you switch tracks or seek.
- Subtitles shown at the same time now stack by their authored screen position, with signs and song lyrics above dialogue.
- Half-size ASS furigana no longer shows up as if it were its own subtitle line.
- **YouTube Auto-Generated Captions**:
- Captions now follow their intended timing instead of drifting off sync.
- Long speech is paged across two rows instead of piling into a wall of text.
- Timed sound cues like `[音楽]` no longer linger over later dialogue.
## What's Changed
- fix(anki): keep overlay progress visible through card updates by @ksyasuda in #218
- fix(youtube): keep auto captions on screen for their full span by @ksyasuda in #219
- fix(subtitles): keep overlapping lines that join an already active cue by @ksyasuda in #221
- fix(anki): respect configured fields for word-card enrichment by @ksyasuda in #223
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+3 -5
View File
@@ -608,7 +608,6 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
const integration = new AnkiIntegration(
{
fields: {
audio: 'ExpressionAudio',
image: 'Picture',
},
media: {
@@ -662,7 +661,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
noteIds.map((noteId) => ({
noteId,
fields: {
ExpressionAudio: { value: '' },
SentenceAudio: { value: '' },
Picture: { value: '' },
},
})),
@@ -947,7 +946,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
noteInfo: {
noteId: 404,
fields: {
ExpressionAudio: { value: '' },
SentenceAudio: { value: '' },
Picture: { value: '' },
},
},
@@ -959,8 +958,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
assert.equal(queued, true);
assert.equal(updatedNotes.length, 1);
assert.equal(updatedNotes[0]?.noteId, 404);
assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio_/);
assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined);
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
assert.equal(storedMedia.length, 2);
assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
+11
View File
@@ -383,6 +383,8 @@ export class AnkiIntegration {
getCachedMediaPath: this.getCachedMediaPath,
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
getResolvedSentenceAudioFieldName: (noteInfo) =>
this.getResolvedSentenceAudioFieldName(noteInfo),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
mergeFieldValue: (existing, newValue, overwrite) =>
@@ -659,6 +661,8 @@ export class AnkiIntegration {
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
getResolvedSentenceAudioFieldName: (noteInfo) =>
this.getResolvedSentenceAudioFieldName(noteInfo),
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
mergeFieldValue: (existing, newValue, overwrite) =>
this.mergeFieldValue(existing, newValue, overwrite),
@@ -1563,6 +1567,13 @@ export class AnkiIntegration {
return sentenceCardConfig.audioField || 'SentenceAudio';
}
private getResolvedSentenceAudioFieldName(noteInfo: NoteInfo): string | null {
return (
this.resolveNoteFieldName(noteInfo, this.getPreferredSentenceAudioFieldName()) ||
this.resolveConfiguredFieldName(noteInfo, this.config.fields?.audio)
);
}
private getConfiguredWordFieldName(): string {
return getConfiguredWordFieldName(this.config);
}
@@ -143,7 +143,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
};
}
test('manual clipboard subtitle update replaces audio in the configured field', async () => {
test('manual clipboard subtitle update replaces sentence audio without touching expression audio', async () => {
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService();
await service.updateLastAddedFromClipboard('字幕');
@@ -151,144 +151,14 @@ test('manual clipboard subtitle update replaces audio in the configured field',
assert.equal(updatedFields.length, 1);
assert.equal(storedMedia.length, 1);
const audioValue = `[sound:${storedMedia[0]}]`;
assert.equal(updatedFields[0]?.ExpressionAudio, audioValue);
assert.equal('SentenceAudio' in updatedFields[0]!, false);
assert.equal(updatedFields[0]?.SentenceAudio, audioValue);
assert.equal('ExpressionAudio' in updatedFields[0]!, false);
assert.deepEqual(
mergeCalls.map((call) => call.overwrite),
[true],
);
});
test('manual clipboard word-card update uses configured fields with Lapis and Kiku enabled', async () => {
const { service, updatedFields } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Context',
audio: 'ContextAudio',
},
media: {
generateAudio: true,
generateImage: false,
maxMediaDuration: 30,
},
behavior: {
overwriteAudio: false,
overwriteImage: false,
},
ai: false,
}) as AnkiConnectConfig,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Expression: { value: '単語' },
Sentence: { value: '' },
SentenceAudio: { value: '' },
Context: { value: '' },
ContextAudio: { value: '' },
},
},
],
updateNoteFields: async (_noteId, fields) => {
updatedFields.push(fields);
},
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: true,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
});
await service.updateLastAddedFromClipboard('字幕');
assert.equal(updatedFields.length, 1);
assert.match(updatedFields[0]?.ContextAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
assert.deepEqual(Object.keys(updatedFields[0] ?? {}).sort(), ['Context', 'ContextAudio']);
assert.equal(updatedFields[0]?.Context, '字幕');
});
test('audio-card action keeps Lapis and Kiku sentence fields', async () => {
const { service, updatedFields } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Context',
audio: 'ContextAudio',
},
media: {
generateAudio: true,
generateImage: false,
maxMediaDuration: 30,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentAudioStreamIndex: 0,
currentSubText: '字幕',
currentSubStart: 12,
currentSubEnd: 14,
}) as never,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Expression: { value: '単語' },
Sentence: { value: '' },
SentenceAudio: { value: '' },
Context: { value: '' },
ContextAudio: { value: '' },
},
},
],
updateNoteFields: async (_noteId, fields) => {
updatedFields.push(fields);
},
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: true,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
});
await service.markLastCardAsAudioCard();
assert.equal(updatedFields.length, 1);
assert.equal(updatedFields[0]?.Sentence, '字幕');
assert.match(updatedFields[0]?.SentenceAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
assert.equal('Context' in (updatedFields[0] ?? {}), false);
assert.equal('ContextAudio' in (updatedFields[0] ?? {}), false);
});
test('manual clipboard subtitle update marks Kiku word cards as word-and-sentence cards when enabled', async () => {
const { service, updatedFields } = createManualUpdateService({
getConfig: () =>
@@ -355,7 +225,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
});
});
test('manual clipboard subtitle update uses configured audio when SentenceAudio is missing', async () => {
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
client: {
addNote: async () => 0,
@@ -385,9 +255,8 @@ test('manual clipboard subtitle update uses configured audio when SentenceAudio
assert.equal(storedMedia.length, 1);
assert.equal(updatedFields.length, 1);
assert.match(updatedFields[0]?.ExpressionAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
assert.equal(updatedFields[0]?.Sentence, '字幕');
assert.equal(mergeCalls.length, 1);
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
assert.equal(mergeCalls.length, 0);
});
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
+21 -11
View File
@@ -260,16 +260,9 @@ export class CardCreationService {
fields,
this.deps.getConfig(),
);
const config = this.deps.getConfig();
const sentenceAudioField = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
);
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceField = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence,
);
const sentenceField = sentenceCardConfig.sentenceField;
const sentence = blocks.join(' ');
const updatedFields: Record<string, string> = {};
@@ -291,6 +284,7 @@ export class CardCreationService {
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
);
const config = this.deps.getConfig();
const generateAudio = shouldGenerateAudio(config);
const generateImage = shouldGenerateImage(config);
const mediaResolverOptions = this.getMediaResolverOptions();
@@ -463,13 +457,13 @@ export class CardCreationService {
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), 'audio');
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceField = sentenceCardConfig.sentenceField;
const sentenceField = this.deps.getConfig().fields?.sentence;
if (sentenceField) {
const processedSentence = this.deps.processSentence(mpvClient.currentSubText, fields);
updatedFields[sentenceField] = processedSentence;
}
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const audioFieldName = sentenceCardConfig.audioField;
try {
const audioFilename = this.generateAudioFilename();
@@ -812,6 +806,22 @@ export class CardCreationService {
}
}
private getResolvedSentenceAudioFieldName(noteInfo: CardCreationNoteInfo): string | null {
return (
this.deps.resolveNoteFieldName(
noteInfo,
this.deps.getEffectiveSentenceCardConfig().audioField || 'SentenceAudio',
) || this.deps.resolveConfiguredFieldName(noteInfo, this.deps.getConfig().fields?.audio)
);
}
private getResolvedSentenceOnlyAudioFieldName(noteInfo: CardCreationNoteInfo): string | null {
return this.deps.resolveNoteFieldName(
noteInfo,
this.deps.getEffectiveSentenceCardConfig().audioField || 'SentenceAudio',
);
}
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
return {
noteId: -1,
@@ -80,6 +80,7 @@ function createWorkflowHarness() {
const names = Object.keys(noteInfo.fields);
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
},
getResolvedSentenceAudioFieldName: () => null,
getAnimatedImageLeadInSeconds: async () => 0,
mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
generateAudioFilename: () => 'audio_1.mp3',
@@ -119,49 +120,6 @@ test('NoteUpdateWorkflow updates sentence field and emits notification', async (
assert.equal(harness.notifications.length, 1);
});
test('NoteUpdateWorkflow uses configured fields for word-card enrichment with Lapis and Kiku enabled', async () => {
const harness = createWorkflowHarness();
harness.deps.getConfig = () => ({
fields: {
sentence: 'Context',
audio: 'ContextAudio',
},
media: {
generateAudio: true,
generateImage: false,
},
behavior: {},
});
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
lapisEnabled: true,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
});
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
SentenceAudio: { value: '' },
Context: { value: '' },
ContextAudio: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.generateAudio = async () => Buffer.from('audio');
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Context: 'subtitle-text',
ContextAudio: '[sound:audio_1.mp3]',
});
});
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () =>
@@ -474,7 +432,6 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
image: 'Picture',
miscInfo: 'MiscInfo',
},
@@ -487,6 +444,7 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
});
harness.deps.getCurrentSubtitleText = () => 'current primary line';
harness.deps.getCurrentSubtitleStart = () => 20;
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
audioContext = context ?? null;
return Buffer.from('audio');
@@ -543,7 +501,6 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
image: 'Picture',
miscInfo: 'MiscInfo',
},
@@ -554,6 +511,7 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
},
behavior: {},
});
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
harness.deps.captureSubtitleMediaContext = () => {
captureCalls += 1;
return capturedContext;
+5 -10
View File
@@ -19,7 +19,6 @@ export interface NoteUpdateWorkflowDeps {
fields?: {
word?: string;
sentence?: string;
audio?: string;
image?: string;
miscInfo?: string;
};
@@ -76,6 +75,7 @@ export interface NoteUpdateWorkflowDeps {
noteInfo: NoteUpdateWorkflowNoteInfo,
...preferredNames: (string | undefined)[]
) => string | null;
getResolvedSentenceAudioFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo) => string | null;
getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
generateAudioFilename: () => string;
@@ -198,13 +198,11 @@ export class NoteUpdateWorkflow {
const updatedFields: Record<string, string> = {};
let updatePerformed = false;
let miscInfoFilename: string | null = null;
const configuredSentenceField =
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence;
const sentenceField = this.deps.resolveConfiguredFieldName(noteInfo, configuredSentenceField);
const sentenceField = sentenceCardConfig.sentenceField;
const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext(
fields,
sentenceField ?? configuredSentenceField,
configuredSentenceField,
sentenceField,
config.fields?.sentence,
);
// Audio and image generation run sequentially and audio extraction can take tens of
// seconds, so resolve the clip range exactly once up front; reading live mpv sub
@@ -260,10 +258,7 @@ export class NoteUpdateWorkflow {
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const sentenceAudioField = this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
);
const sentenceAudioField = this.deps.getResolvedSentenceAudioFieldName(noteInfo);
if (sentenceAudioField) {
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
@@ -31,6 +31,7 @@ function createDeps(
getCachedMediaPath: async () => null,
shouldRequireRemoteMediaCache: () => true,
getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }),
getResolvedSentenceAudioFieldName: () => 'SentenceAudio',
resolveConfiguredFieldName: () => 'Picture',
mergeFieldValue: (_existing, newValue) => newValue,
getAnimatedImageLeadInSeconds: async () => 0,
@@ -132,7 +133,7 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
noteIds.map((noteId) => ({
noteId,
fields: {
ExpressionAudio: { value: '' },
SentenceAudio: { value: '' },
Picture: { value: '' },
},
})),
@@ -143,16 +144,13 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
storedMedia.push(filename);
},
},
getConfig: () =>
({ media: {}, fields: { audio: 'ExpressionAudio', image: 'Picture' } }) as AnkiConnectConfig,
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
preferredNames.find((name) => name && name in noteInfo.fields) ?? null,
getConfig: () => ({ media: {}, fields: { image: 'Picture' } }) as AnkiConnectConfig,
});
const queue = new PendingYoutubeMediaQueue(deps);
const queued = await queue.queueFromNote({
noteId: 42,
noteInfo: { noteId: 42, fields: { ExpressionAudio: { value: '' } } },
noteInfo: { noteId: 42, fields: {} },
label: 'demo',
});
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
@@ -160,8 +158,7 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
assert.equal(queued, true);
assert.equal(updatedNotes.length, 1);
assert.equal(storedMedia.length, 2);
assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio\.mp3\]$/);
assert.equal('SentenceAudio' in (updatedNotes[0]?.fields ?? {}), false);
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio\.mp3\]$/);
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image\.webp">$/);
});
@@ -39,6 +39,7 @@ export interface PendingYoutubeMediaQueueDeps {
startTime: number;
endTime: number;
};
getResolvedSentenceAudioFieldName: (noteInfo: PendingYoutubeMediaNoteInfo) => string | null;
resolveConfiguredFieldName: (
noteInfo: PendingYoutubeMediaNoteInfo,
...preferredNames: (string | undefined)[]
@@ -135,7 +136,7 @@ export class PendingYoutubeMediaQueue {
startTime: mediaRange.startTime,
endTime: mediaRange.endTime,
label: job.label,
audioFieldName: this.resolveConfiguredAudioFieldName(job.noteInfo) ?? undefined,
audioFieldName: this.deps.getResolvedSentenceAudioFieldName(job.noteInfo) ?? undefined,
imageFieldName:
this.deps.resolveConfiguredFieldName(
job.noteInfo,
@@ -246,14 +247,6 @@ export class PendingYoutubeMediaQueue {
return matched;
}
private resolveConfiguredAudioFieldName(noteInfo: PendingYoutubeMediaNoteInfo): string | null {
const config = this.deps.getConfig();
return this.deps.resolveConfiguredFieldName(
noteInfo,
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
);
}
private async applyUpdate(
job: PendingYoutubeMediaUpdate,
cachedPath: string,
@@ -290,7 +283,7 @@ export class PendingYoutubeMediaQueue {
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const audioField =
job.audioFieldName || this.resolveConfiguredAudioFieldName(noteInfo) || null;
job.audioFieldName || this.deps.getResolvedSentenceAudioFieldName(noteInfo) || null;
if (audioField) {
const existingAudio = noteInfo.fields[audioField]?.value || '';
mediaFields[audioField] = this.deps.mergeFieldValue(
-15
View File
@@ -218,18 +218,3 @@ test('removeLiveGlyphFragmentLines leaves ordinary short lines alone', () => {
const text = 'え\nはい。\nそうだな';
assert.equal(removeLiveGlyphFragmentLines(text), text);
});
test('normalizePlainSubtitleText folds cue-boundary blank lines for text consumers', () => {
// The display layer splits on the blank line before normalizing; everyone else --
// tokenizer, cache key, dedup gate, mined sentence -- wants the plain line form.
assert.equal(
normalizePlainSubtitleText('\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee'),
'\u4e00\u884c\u76ee\n\u4e8c\u884c\u76ee',
);
assert.equal(
normalizePlainSubtitleText('\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee', {
collapseLineBreaks: true,
}),
'\u4e00\u884c\u76ee \u4e8c\u884c\u76ee',
);
});
-4
View File
@@ -153,10 +153,6 @@ export function normalizePlainSubtitleText(
);
if (collapseLineBreaks) {
normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ');
} else {
// Simultaneous cues reach the display layer separated by a blank line; every other
// consumer wants the plain one-break-per-line form.
normalized = normalized.replace(/\n{2,}/g, '\n');
}
return trim ? normalized.trim() : normalized;
+8 -113
View File
@@ -1442,7 +1442,7 @@ const eventsHeader = [
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
];
test('parseSubtitleCues keeps tall CC-style base dialogue publishable after removing furigana', () => {
test('parseSubtitleCues keeps a tall CC-style dialogue block publishable, not a fragment grid', () => {
const content = [
...eventsHeader,
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(212,383)\\fscx50\\fscy50}たき',
@@ -1451,94 +1451,9 @@ test('parseSubtitleCues keeps tall CC-style base dialogue publishable after remo
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?',
].join('\n');
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 ?? []),
[],
);
const cue = parseSubtitleCues(content, 'test.ass')[0];
assert.equal(cue?.text, 'たき(立希)ともりお前… 燈をバンドに誘ったの?');
assert.equal(cue?.assLayout?.kind, 'positioned');
});
test('parseSubtitleCues marks re-shown countdown frames as a fragment grid', () => {
@@ -2012,7 +1927,9 @@ test('parseSubtitleCues does not double a line rendered whole beside its glyph s
['わ', 1022],
['ね', 1064],
] as const;
const wholeLine = glyphs.map(([glyph]) => `{\\an5\\fad(300,500)\\pos(960,50)}${glyph}`).join('');
const wholeLine = glyphs
.map(([glyph]) => `{\\an5\\fad(300,500)\\pos(960,50)}${glyph}`)
.join('');
const content = [
...eventsHeader,
`Dialogue: 1,0:00:17.29,0:00:18.99,OP - JP,,0,0,0,,${wholeLine}`,
@@ -2035,7 +1952,7 @@ test('parseSubtitleCues drops a wall of near-invisible positioned texture string
// faint translation is one or two events and stays published.
const content = [
...eventsHeader,
"Dialogue: 90,0:00:12.66,0:00:14.91,Default,,0,0,0,,We'll play as a band, and then...",
'Dialogue: 90,0:00:12.66,0:00:14.91,Default,,0,0,0,,We\'ll play as a band, and then...',
...Array.from(
{ length: 12 },
(_, index) =>
@@ -2080,25 +1997,3 @@ test('parseSubtitleCues keeps hidden events hidden when a transform animates an
['grows into view', 'wipes into view'],
);
});
test('parseAssCues records the vertical band from style alignment, overrides, and \\pos', () => {
const ass = [
'[Script Info]',
'PlayResY: 720',
'',
'[V4+ Styles]',
'Format: Name, Fontname, Fontsize, PrimaryColour, Bold, Alignment, MarginV, Encoding',
'Style: Bottom,Arial,54,&H00FFFFFF,0,2,30,1',
'Style: TopSong,Arial,54,&H00FFFFFF,0,9,12,1',
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,\u4e0b\u306e\u30bb\u30ea\u30d5',
'Dialogue: 0,0:00:01.00,0:00:03.00,TopSong,,0,0,0,,\u6b4c\u8a5e\u306e\u884c',
'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,{\\an8}\u4e0a\u66f8\u304d\u306e\u884c',
'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,{\\pos(640,20)}\u770b\u677f\u306e\u884c',
].join('\n');
const bands = parseAssCues(ass).map((cue) => cue.assLayout?.verticalBand);
assert.deepEqual(bands, ['bottom', 'top', 'top', 'top']);
});
+21 -284
View File
@@ -10,29 +10,15 @@ import {
} from './ass-text';
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
/** Vertical third of the screen a cue is authored to occupy. */
export type AssVerticalBand = 'top' | 'middle' | 'bottom';
export type AssCueLayout =
| {
kind: 'positioned';
sourceOrder: number;
x?: number;
y: number;
verticalBand?: AssVerticalBand;
}
| { kind: 'fragment-grid'; sourceOrder: number; verticalBand?: AssVerticalBand }
| { kind: 'source-order'; sourceOrder: number; verticalBand?: AssVerticalBand };
| { kind: 'positioned'; sourceOrder: number; y: number }
| { kind: 'fragment-grid'; sourceOrder: number }
| { kind: 'source-order'; sourceOrder: number };
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';
/**
@@ -111,17 +97,10 @@ function sanitizeAssCueText(text: string): string {
return removeAssControlDebrisLines(decodeSubtitleCueText(text)).trim();
}
function attachAssMetadata<T extends SubtitleCue>(
cue: T,
assLayout: AssCueLayout | undefined,
assFurigana: readonly string[] | undefined,
): T {
function attachAssLayout<T extends SubtitleCue>(cue: T, assLayout: AssCueLayout | 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;
}
@@ -136,7 +115,6 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
animationEndTime,
style,
assLayout,
assFurigana,
}) => {
const common = {
startTime,
@@ -144,7 +122,7 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
text,
};
if (source === 'reconstructed-ass') {
return attachAssMetadata(
return attachAssLayout(
{
...common,
source,
@@ -153,13 +131,11 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
assStyle: style,
},
assLayout,
assFurigana,
);
}
return attachAssMetadata(
return attachAssLayout(
source ? { ...common, source, animationStartTime, animationEndTime } : common,
assLayout,
assFurigana,
);
},
);
@@ -505,7 +481,9 @@ function structuralOverrideSignature(cue: AnnotatedSubtitleCue): string {
let signature = structuralSignatureCache.get(cue);
if (signature === undefined) {
const names = new Set(
cue.overrides.map((command) => `${command.animated ? '~' : ''}${command.name.toLowerCase()}`),
cue.overrides.map(
(command) => `${command.animated ? '~' : ''}${command.name.toLowerCase()}`,
),
);
signature = [...names].sort().join(',');
structuralSignatureCache.set(cue, signature);
@@ -566,7 +544,9 @@ function buildCoalescedCopy(members: readonly AnnotatedSubtitleCue[]): Annotated
* while the anchor says one glyph. Merging each stack into a single presence spanning
* the union window lets timing clusters see the authored line instead of its phases.
*/
function coalesceAssAnchorCopies(events: readonly AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
function coalesceAssAnchorCopies(
events: readonly AnnotatedSubtitleCue[],
): AnnotatedSubtitleCue[] {
const buckets = new Map<string, number[]>();
const anchorPoints: (AssFragmentPosition[] | null)[] = events.map(() => null);
events.forEach((event, index) => {
@@ -1382,7 +1362,8 @@ function isRepeatedGlyphText(cue: AnnotatedSubtitleCue): boolean {
function isClippedRepeatedGlyphFragment(cue: AnnotatedSubtitleCue): boolean {
return (
isRepeatedGlyphText(cue) && (hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'))
isRepeatedGlyphText(cue) &&
(hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'))
);
}
@@ -2040,9 +2021,6 @@ 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,
@@ -2050,7 +2028,6 @@ function recoverCanonicalAssEvents({
animationStartTime,
animationEndTime,
source: 'canonical-ass',
...(assFurigana.length === 0 ? {} : { assFurigana }),
};
recovered.push(recoveredCue);
recoveredByOwner.set(candidate, recoveredCue);
@@ -2078,111 +2055,6 @@ function recoverCanonicalAssEvents({
);
}
function bandFromNumpadAlignment(alignment: number): AssVerticalBand | null {
if (alignment >= 7 && alignment <= 9) return 'top';
if (alignment >= 4 && alignment <= 6) return 'middle';
if (alignment >= 1 && alignment <= 3) return 'bottom';
return null;
}
// SSA v4 alignment reuses the legacy `\a` codes: 1-3 bottom, +4 top, +8 middle.
function bandFromLegacyAlignment(alignment: number): AssVerticalBand | null {
if (alignment >= 9 && alignment <= 11) return 'middle';
if (alignment >= 5 && alignment <= 7) return 'top';
if (alignment >= 1 && alignment <= 3) return 'bottom';
return null;
}
interface AssPlacementContext {
playResY: number | null;
/** Lowercased style name -> vertical band from the style's Alignment column. */
styleBands: Map<string, AssVerticalBand>;
}
const EMPTY_PLACEMENT_CONTEXT: AssPlacementContext = { playResY: null, styleBands: new Map() };
function parseAssPlacementContext(content: string): AssPlacementContext {
const styleBands = new Map<string, AssVerticalBand>();
let playResY: number | null = null;
let section: 'info' | 'v4plus' | 'v4' | null = null;
let alignmentIndex = -1;
let nameIndex = -1;
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
const sectionName = trimmed.toLowerCase();
section =
sectionName === '[script info]'
? 'info'
: sectionName === '[v4+ styles]'
? 'v4plus'
: sectionName === '[v4 styles]'
? 'v4'
: null;
alignmentIndex = -1;
nameIndex = -1;
continue;
}
if (section === 'info') {
const resMatch = trimmed.match(/^playresy\s*:\s*(\d+(?:\.\d+)?)\s*$/i);
if (resMatch) playResY = Number(resMatch[1]);
continue;
}
if (section !== 'v4plus' && section !== 'v4') continue;
const separator = trimmed.indexOf(':');
if (separator < 0) continue;
const key = trimmed.slice(0, separator).trim().toLowerCase();
const fields = trimmed.slice(separator + 1).split(',');
if (key === 'format') {
const names = fields.map((field) => field.trim().toLowerCase());
alignmentIndex = names.indexOf('alignment');
nameIndex = names.indexOf('name');
continue;
}
if (key !== 'style' || alignmentIndex < 0 || nameIndex < 0) continue;
const styleName = fields[nameIndex]?.trim().toLowerCase();
const alignment = Number(fields[alignmentIndex]?.trim());
if (!styleName || !Number.isFinite(alignment)) continue;
const band =
section === 'v4plus'
? bandFromNumpadAlignment(alignment)
: bandFromLegacyAlignment(alignment);
if (band) styleBands.set(styleName, band);
}
return { playResY, styleBands };
}
/**
* Where on screen mpv will draw this event: an explicit `\pos`/`\move` coordinate when
* the script declares its coordinate space, else an `\an`/`\a` override, else the
* style's Alignment. Constant for the life of the event, which is what lets simultaneous
* lines keep a stable stacking order in the overlay.
*/
function resolveVerticalBand(
overrides: readonly AssOverrideCommand[],
y: number | null,
style: string,
context: AssPlacementContext,
): AssVerticalBand | undefined {
if (y !== null && context.playResY && context.playResY > 0) {
const ratio = y / context.playResY;
return ratio < 1 / 3 ? 'top' : ratio < 2 / 3 ? 'middle' : 'bottom';
}
for (const command of overrides) {
if (command.animated) continue;
const name = command.name.toLowerCase();
if (name !== 'an' && name !== 'a') continue;
const band =
name === 'an'
? bandFromNumpadAlignment(Number(command.args))
: bandFromLegacyAlignment(Number(command.args));
if (band) return band;
}
return context.styleBands.get(style.trim().toLowerCase());
}
function parseAssCoordinate(value: string | undefined): number | null {
if (!value?.trim()) return null;
const coordinate = Number(value.trim());
@@ -2192,157 +2064,29 @@ function parseAssCoordinate(value: string | undefined): number | null {
function buildAssCueLayout(
overrides: readonly AssOverrideCommand[],
sourceOrder: number,
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, ...(x === null ? {} : { x }), y };
return verticalBand ? { ...base, verticalBand } : base;
return y === null
? { kind: 'source-order', sourceOrder }
: { kind: 'positioned', sourceOrder, y };
}
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 {
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
const cues: AnnotatedSubtitleCue[] = [];
const comments: AnnotatedSubtitleCue[] = [];
const lines = content.split(/\r?\n/);
@@ -2441,13 +2185,12 @@ function parseAnnotatedAssEvents(content: string, placement: AssPlacementContext
const effect = readField(fields, fieldIndex.effect);
const layer = Number(readField(fields, fieldIndex.layer));
const overrides = collectAssOverrideCommands(rawText);
const style = readField(fields, fieldIndex.style);
const cue: AnnotatedSubtitleCue = {
startTime,
endTime,
text,
rawText,
style,
style: readField(fields, fieldIndex.style),
layer: Number.isFinite(layer) ? layer : 0,
name: readField(fields, fieldIndex.name),
effect,
@@ -2455,7 +2198,7 @@ function parseAnnotatedAssEvents(content: string, placement: AssPlacementContext
overrides,
overrideSignature: assOverrideSignature(overrides),
order: eventOrder,
assLayout: buildAssCueLayout(overrides, eventOrder, style, placement),
assLayout: buildAssCueLayout(overrides, eventOrder),
};
eventOrder += 1;
if (eventPrefix === ASS_COMMENT_PREFIX) {
@@ -2469,13 +2212,7 @@ function parseAnnotatedAssEvents(content: string, placement: AssPlacementContext
}
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
const placement = content.includes('[')
? parseAssPlacementContext(content)
: EMPTY_PLACEMENT_CONTEXT;
const events = removeAssFuriganaEvents(
removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)),
placement.playResY,
);
const events = removeAssFontTextureEvents(parseAnnotatedAssEvents(content));
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
}
@@ -134,7 +134,7 @@ export function createSubtitleProcessingController(
try {
const cachedTokenized = getCachedTokenization(text);
if (cachedTokenized) {
output = { ...cachedTokenized, text };
output = cachedTokenized;
} else {
// Cache miss: show the plain line on time; the tokenized payload
// upgrades it once ready. Skipped on refreshes of an already
@@ -266,7 +266,7 @@ export function createSubtitleProcessingController(
lastEmittedText = text;
lastEmittedGeneration = cacheGeneration;
lastPlainEmittedText = null;
return { ...cached, text };
return cached;
},
hasCachedSubtitle: (text: string) => {
const cacheKey = normalizeSubtitleCacheKey(text);
-17
View File
@@ -84,17 +84,6 @@ function createDeferred<T>() {
};
}
test('tokenizeSubtitle keeps the blank line separating simultaneous cues', async () => {
// The tokenized payload's text drives display; folding the cue boundary would merge
// two speakers back onto one line the moment tokenization upgrades the plain emit.
const result = await tokenizeSubtitle(
'\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee',
makeDeps({ getYomitanExt: () => null }),
);
assert.equal(result.text, '\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee');
});
test('tokenizeSubtitle splits same-line grammar endings before applying annotations', async () => {
const result = await tokenizeSubtitle(
'猫です',
@@ -1693,12 +1682,6 @@ test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async
assert.equal(result.tokens, null);
});
test('tokenizeSubtitle preserves CRLF boundaries between simultaneous cues', async () => {
const result = await tokenizeSubtitle('a\r\n\r\nb', makeDeps());
assert.deepEqual(result, { text: 'a\n\nb', tokens: null });
});
test('tokenizeSubtitle collapses zero-width separators before Yomitan parse request', async () => {
let parseInput = '';
const result = await tokenizeSubtitle(
+1 -9
View File
@@ -887,15 +887,7 @@ export async function tokenizeSubtitle(
text: string,
deps: TokenizerServiceDeps,
): Promise<SubtitleData> {
// Normalize per cue group: the blank line separating simultaneous cues is display
// structure the payload text must keep, or the tokenized upgrade re-merges lines the
// provisional plain emit already showed apart.
const displayText = text
.replace(/\r\n/g, '\n')
.split(/\n{2,}/)
.map((part) => normalizePlainSubtitleText(part))
.filter(Boolean)
.join('\n\n');
const displayText = normalizePlainSubtitleText(text);
// ASS decoding already happened upstream (cue parser for files, mpv for live text), so
// all this drops is whitespace -- but a whitespace-only line still normalizes to empty.
+3 -153
View File
@@ -60,33 +60,7 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
{ startTime: 1, endTime: 3, text: '二行目' },
],
}),
'一行目\n\n二行目',
);
});
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大丈夫。',
'一行目\n二行目',
);
});
@@ -221,7 +195,7 @@ test('resolvePrimarySubtitleText combines parsed dialogue with a reconstructed l
],
});
assert.equal(text, '普通のセリフ\n\n今 手にある');
assert.equal(text, '普通のセリフ\n今 手にある');
});
test('resolvePrimarySubtitleText uses fragment grids only to account for live sign pieces', () => {
@@ -326,32 +300,7 @@ test('resolvePrimarySubtitleText combines simultaneous canonical cues in source
],
});
assert.equal(text, 'first\n\nsecond');
});
test('resolveCanonicalPrimarySubtitle orders active cues from top to bottom', () => {
const resolved = resolveCanonicalPrimarySubtitle({
liveText: 'bottom\ntop',
currentTimeSec: 2,
cues: [
{
startTime: 1,
endTime: 3,
text: 'bottom',
source: 'canonical-ass',
assLayout: { kind: 'source-order', sourceOrder: 1, verticalBand: 'bottom' },
},
{
startTime: 1,
endTime: 3,
text: 'top',
source: 'canonical-ass',
assLayout: { kind: 'source-order', sourceOrder: 0, verticalBand: 'top' },
},
],
});
assert.equal(resolved?.text, 'top\n\nbottom');
assert.equal(text, 'first\nsecond');
});
test('resolvePrimarySubtitleText collapses whitespace variants of a canonical lyric', () => {
@@ -549,23 +498,6 @@ test('stripCanonicalFragmentLines drops a live glyph wall with no nearby canonic
);
});
test('resolvePrimarySubtitleText keeps a line joining an active cue despite stale time-pos', () => {
// Issue #220: mpv publishes the combined sub-text the moment a joining line's first
// frame renders, while the observed time-pos still sits just before that line's
// start. The joining cue must not be filtered out as inactive.
assert.equal(
resolvePrimarySubtitleText({
liveText: 'Балда! Балда, балда, балда!\nСестренка не может остановиться',
currentTimeSec: 767.78,
cues: [
{ startTime: 767.19, endTime: 772.78, text: 'Балда! Балда, балда, балда!' },
{ startTime: 767.79, endTime: 771.15, text: 'Сестренка не может остановиться' },
],
}),
'Балда! Балда, балда, балда!\n\nСестренка не может остановиться',
);
});
test('resolvePrimarySubtitleText drops a finished lyric whose exit ghosts outlive it beside a raw line', () => {
// The reconstructed lyric ended at 6.0 but its exit ghost glyphs stay in the live
// text until 7.0, while the next authored line is a plain raw event. The retired cue
@@ -592,85 +524,3 @@ test('resolvePrimarySubtitleText drops a finished lyric whose exit ghosts outliv
'象徴的なパレード',
);
});
test('resolvePrimarySubtitleText stacks simultaneous cues by screen position, not start order', () => {
// A top-anchored lyric and bottom dialogue: mpv draws the lyric above the dialogue for
// the whole overlap. Whichever event started first must not decide the row, or the
// pair swaps every time one side is replaced mid-overlap.
const lyricLayout = { kind: 'source-order', sourceOrder: 0, verticalBand: 'top' } as const;
const dialogueLayout = { kind: 'source-order', sourceOrder: 1, verticalBand: 'bottom' } as const;
const dialogue = {
startTime: 632.2,
endTime: 634.8,
text: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
assLayout: dialogueLayout,
};
// Lyric started before the dialogue...
assert.equal(
resolvePrimarySubtitleText({
liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff21',
currentTimeSec: 632.5,
cues: [
{ startTime: 629.5, endTime: 633.5, text: '\u6b4c\u8a5e\uff21', assLayout: lyricLayout },
dialogue,
],
}),
'\u6b4c\u8a5e\uff21\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
);
// ...and the next lyric starts after it: the rows must not swap.
assert.equal(
resolvePrimarySubtitleText({
liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff22',
currentTimeSec: 633.8,
cues: [
dialogue,
{ startTime: 633.5, endTime: 637.0, text: '\u6b4c\u8a5e\uff22', assLayout: lyricLayout },
],
}),
'\u6b4c\u8a5e\uff22\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
);
});
test('resolvePrimarySubtitleText puts an unreadable placement above bottom dialogue', () => {
// Dialogue is the case that reliably declares a bottom alignment, so a cue whose
// placement could not be read is more often a sign or song line. Keeping dialogue on
// the bottom row means the line worth reading stays where the eye already is.
assert.equal(
resolvePrimarySubtitleText({
liveText: '\u4e0b\u306e\u30bb\u30ea\u30d5\n\u4e0d\u660e\u306a\u884c',
currentTimeSec: 2,
cues: [
{
startTime: 1,
endTime: 3,
text: '\u4e0b\u306e\u30bb\u30ea\u30d5',
assLayout: { kind: 'source-order', sourceOrder: 0, verticalBand: 'bottom' },
},
{
startTime: 1.5,
endTime: 3,
text: '\u4e0d\u660e\u306a\u884c',
assLayout: { kind: 'source-order', sourceOrder: 1 },
},
],
}),
'\u4e0d\u660e\u306a\u884c\n\n\u4e0b\u306e\u30bb\u30ea\u30d5',
);
});
test('resolvePrimarySubtitleText keeps source order when no cue declares a placement', () => {
// SRT and websocket cues carry no layout at all: every cue ties, so the stable sort
// must leave them exactly as the cue list had them.
assert.equal(
resolvePrimarySubtitleText({
liveText: 'First line\nSecond line',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: 'First line' },
{ startTime: 1.5, endTime: 3, text: 'Second line' },
],
}),
'First line\n\nSecond line',
);
});
+25 -60
View File
@@ -1,4 +1,4 @@
import type { AssVerticalBand, SubtitleCue } from '../../types';
import type { SubtitleCue } from '../../types';
import {
removeAssControlDebrisLines,
removeLiveGlyphFragmentLines,
@@ -57,52 +57,21 @@ function compactWhitespace(text: string): string {
return text.normalize('NFKC').replace(/\s+/gu, '');
}
/**
* Distinct simultaneous cues are separated by a blank line so the display layer can tell
* a wrap inside one utterance from the boundary between two of them. Consumers that read
* the text rather than display it fold these back to single breaks.
*/
const CUE_BOUNDARY = '\n\n';
const VERTICAL_BAND_RANK: Record<AssVerticalBand, number> = { top: 0, middle: 1, bottom: 2 };
/**
* Stack simultaneous cues the way they sit on screen: mpv keeps a top-anchored lyric or
* sign above bottom dialogue for its whole run, while cue-list order follows start time
* and would swap the pair whenever one side is replaced mid-overlap. The band is
* constant per event, so a line never changes rows while it is displayed.
*
* A cue whose placement could not be read -- an unknown style, a script with no styles
* section -- sorts to the top. Dialogue is the case that reliably declares a bottom
* alignment, so what is left unresolved is more often a sign or a song line, and keeping
* the dialogue on the bottom row means the line worth reading stays where the eye
* already is. Sort is stable, so cues sharing a rank keep their existing order.
*/
function orderCuesForDisplay(cues: readonly SubtitleCue[]): SubtitleCue[] {
const rank = (cue: SubtitleCue): number =>
VERTICAL_BAND_RANK[cue.assLayout?.verticalBand ?? 'top'];
return [...cues].sort((a, b) => rank(a) - rank(b));
}
// ASS layers can encode the same visible spacing with ordinary, hard, or
// ideographic spaces. Matching and emission must use the same identity or each
// layer reappears as a copy.
function uniqueCueTextGroups(cues: readonly SubtitleCue[]): string[] {
const groups: string[] = [];
function uniqueCueTexts(cues: readonly SubtitleCue[]): string[] {
const texts: string[] = [];
const seen = new Set<string>();
for (const cue of cues) {
const lines: string[] = [];
for (const line of cue.text.split('\n')) {
const compactText = compactWhitespace(line);
if (!compactText || seen.has(compactText)) continue;
seen.add(compactText);
lines.push(line);
}
if (lines.length > 0) {
groups.push(lines.join('\n'));
texts.push(line);
}
}
return groups;
return texts;
}
function compactLineSegments(text: string): string[] {
@@ -149,15 +118,12 @@ function resolveActiveParsedPrimarySubtitle(options: {
return null;
}
const parsedSegments = selected.flatMap((cue) => {
const recovered = cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass';
return [
...compactLineSegments(cue.text).map((segment) => ({ segment, recovered })),
...(cue.assFurigana ?? []).flatMap((text) =>
compactLineSegments(text).map((segment) => ({ segment, recovered: false })),
),
];
});
const parsedSegments = selected.flatMap((cue) =>
compactLineSegments(cue.text).map((segment) => ({
segment,
recovered: cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass',
})),
);
if (
!liveSegments.every((liveSegment) =>
parsedSegments.some(({ segment, recovered }) =>
@@ -168,24 +134,23 @@ function resolveActiveParsedPrimarySubtitle(options: {
return null;
}
// A cue selected only through the edge tolerance on its end has already finished by
// its published timing: a lyric whose exit ghosts linger into the next line. It still
// explains those live fragments above, but must not re-surface beside cues that are
// still running. The start side keeps the tolerance: mpv publishes the combined
// sub-text the moment a joining line's first frame renders, while the observed
// time-pos still sits just before that line's start, and the selection above already
// required the cue's text to be on screen (#220). With every selected cue finished,
// the edge cues remain the display fallback for stale time-pos readings.
const unfinished = selected.filter((cue) => cue.endTime > options.currentTimeSec);
const displayCues = unfinished.length > 0 ? unfinished : selected;
// A cue selected only through the edge tolerance has already ended (or not yet
// started) by its published timing: a finished lyric whose exit ghosts linger into
// the next line. It still explains those live fragments above, but while any cue is
// strictly active, only the active cues supply the displayed text. With no strictly
// active cue, the edge cues remain the display fallback for stale time-pos readings.
const strictlyActive = selected.filter(
(cue) => cue.startTime <= options.currentTimeSec && cue.endTime > options.currentTimeSec,
);
const displayCues = strictlyActive.length > 0 ? strictlyActive : selected;
// Dense sign grids still explain their raw mpv fragments, but are visual
// typesetting rather than a publishable subtitle line.
const groups = uniqueCueTextGroups(
orderCuesForDisplay(displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid')),
const texts = uniqueCueTexts(
displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid'),
);
return {
text: groups.join(CUE_BOUNDARY),
text: texts.join('\n'),
startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
cues: displayCues,
@@ -252,9 +217,9 @@ export function resolveCanonicalPrimarySubtitle(options: {
return null;
}
const groups = uniqueCueTextGroups(orderCuesForDisplay(selected));
const texts = uniqueCueTexts(selected);
return {
text: groups.join(CUE_BOUNDARY),
text: texts.join('\n'),
startTime: Math.min(...selected.map((cue) => cue.startTime)),
endTime: Math.max(...selected.map((cue) => cue.endTime)),
cues: selected,
-29
View File
@@ -11,7 +11,6 @@ import {
getFrequencyRankLabelForToken,
getJlptLevelLabelForToken,
normalizeSubtitle,
normalizeSubtitleForDisplay,
prepareSecondarySubtitleLines,
sanitizeSubtitleHoverTokenColor,
shouldRenderTokenizedSubtitle,
@@ -1005,34 +1004,6 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i
);
});
test('normalizeSubtitleForDisplay always breaks between simultaneous cues', () => {
// The blank line marks two distinct cues on screen at once. Flattening it would run a
// sign or a second speaker into the line beside it as one sentence.
const twoCues =
'\u6b21\u306f\u9b3c\u5b50\u6bcd\u795e\u524d\u3000\u9b3c\u5b50\u6bcd\u795e\u524d\n\n\u611b\u97f3\u3061\u3083\u3093\u3000\u3082\u3046\u5199\u771f\u4e0a\u3052\u3066\u308b';
assert.equal(
normalizeSubtitleForDisplay(twoCues, false),
'\u6b21\u306f\u9b3c\u5b50\u6bcd\u795e\u524d \u9b3c\u5b50\u6bcd\u795e\u524d\n\u611b\u97f3\u3061\u3083\u3093 \u3082\u3046\u5199\u771f\u4e0a\u3052\u3066\u308b',
);
assert.equal(normalizeSubtitleForDisplay(twoCues, true), twoCues.replace('\n\n', '\n'));
});
test('normalizeSubtitleForDisplay preserves CRLF boundaries between simultaneous cues', () => {
assert.equal(normalizeSubtitleForDisplay('a\r\n\r\nb', false), 'a\nb');
});
test('normalizeSubtitleForDisplay still flattens a wrap inside one cue', () => {
// A typesetter's \\N inside a single utterance is what preserveLineBreaks governs.
assert.equal(
normalizeSubtitleForDisplay(
'\u5e38\u4eba\u304c\u4f7f\u3048\u3070\\N\u305d\u306e\u5727\u5012\u7684\u306a\u529b\u306b',
false,
),
'\u5e38\u4eba\u304c\u4f7f\u3048\u3070 \u305d\u306e\u5727\u5012\u7684\u306a\u529b\u306b',
);
});
test('normalizeSubtitle leaves already-decoded text alone', () => {
// Primary subtitle text is decoded from ASS once, upstream: by mpv for live lines and
// by the cue parser for prefetched ones. A brace that survives that is literal text.
+7 -19
View File
@@ -50,21 +50,6 @@ export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks
return normalizePlainSubtitleText(text, { trim, collapseLineBreaks });
}
/**
* Display form of a resolved subtitle. `preserveLineBreaks` governs wrapping inside one
* utterance, which is what a typesetter's `\N` means. The blank line the resolver puts
* between two simultaneous cues is a different thing and always breaks, so a sign or a
* second speaker never runs into the line beside it.
*/
export function normalizeSubtitleForDisplay(text: string, preserveLineBreaks: boolean): string {
return text
.replace(/\r\n/g, '\n')
.split(/\n{2,}/)
.map((cueText) => normalizeSubtitle(cueText, true, !preserveLineBreaks))
.filter((cueText) => cueText.length > 0)
.join('\n');
}
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const SAFE_CSS_COLOR_PATTERN =
/^(?:#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgba?|hsla?)\([^)]*\)|var\([^)]*\)|[a-zA-Z]+)$/;
@@ -429,13 +414,16 @@ function renderWithTokens(
const fragment = document.createDocumentFragment();
if (sourceText) {
const normalizedSource = normalizeSubtitleForDisplay(sourceText, preserveLineBreaks);
const normalizedSource = normalizeSubtitle(sourceText, true, !preserveLineBreaks);
const segments = alignTokensToSourceText(tokens, normalizedSource);
for (const segment of segments) {
if (segment.kind === 'text') {
// Normalization already resolved which breaks survive; every one left is real.
renderPlainTextPreserveLineBreaks(fragment, segment.text);
if (preserveLineBreaks) {
renderPlainTextPreserveLineBreaks(fragment, segment.text);
} else {
fragment.appendChild(document.createTextNode(segment.text));
}
continue;
}
@@ -760,7 +748,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
return;
}
const normalized = normalizeSubtitleForDisplay(text, ctx.state.preserveSubtitleLineBreaks);
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
const hasRenderableTokens =
shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && Boolean(tokens);
if (
+2 -2
View File
@@ -1,4 +1,4 @@
import type { AssVerticalBand, SubtitleCue } from '../core/services/subtitle-cue-parser';
import type { SubtitleCue } from '../core/services/subtitle-cue-parser';
export enum PartOfSpeech {
noun = 'noun',
@@ -187,7 +187,7 @@ export interface ResolvedTokenPos2ExclusionConfig {
export type FrequencyDictionaryMode = 'single' | 'banded';
export type { AssVerticalBand, SubtitleCue };
export type { SubtitleCue };
export type SubtitleSidebarLayout = 'overlay' | 'embedded';