mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 17:16:18 -07:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ad491e95c
|
||
|
|
6fffcc731f | ||
|
|
5cc21113fd | ||
|
|
051140f910 | ||
|
|
6e945f0872 |
@@ -1,5 +1,20 @@
|
||||
# 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,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Kept the Anki card update spinner visible until audio and image updates finish.
|
||||
@@ -1,4 +0,0 @@
|
||||
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.
|
||||
@@ -136,6 +136,8 @@ 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
|
||||
@@ -233,7 +235,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, while leaving the word audio field unchanged.
|
||||
`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.
|
||||
|
||||
## AI Translation
|
||||
|
||||
@@ -287,6 +289,8 @@ 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 (1–9) to select how many recent lines to combine.
|
||||
|
||||
## Word Card Type (Kiku/Lapis)
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 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**
|
||||
|
||||
@@ -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, even when `ankiConnect.behavior.overwriteAudio` is disabled. The word audio field is left unchanged because the word itself does not change in this flow.
|
||||
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.
|
||||
|
||||
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
|
||||
|
||||
|
||||
@@ -129,6 +129,9 @@ 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
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.4",
|
||||
"version": "0.19.5",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
## 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`.
|
||||
@@ -608,6 +608,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
fields: {
|
||||
audio: 'ExpressionAudio',
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
@@ -661,7 +662,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
ExpressionAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
})),
|
||||
@@ -946,7 +947,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
||||
noteInfo: {
|
||||
noteId: 404,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
ExpressionAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
},
|
||||
@@ -958,7 +959,8 @@ 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.SentenceAudio ?? '', /^\[sound:audio_/);
|
||||
assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio_/);
|
||||
assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined);
|
||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
|
||||
|
||||
@@ -383,8 +383,6 @@ 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) =>
|
||||
@@ -661,8 +659,6 @@ 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),
|
||||
@@ -1567,13 +1563,6 @@ 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 sentence audio without touching expression audio', async () => {
|
||||
test('manual clipboard subtitle update replaces audio in the configured field', async () => {
|
||||
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService();
|
||||
|
||||
await service.updateLastAddedFromClipboard('字幕');
|
||||
@@ -151,14 +151,144 @@ test('manual clipboard subtitle update replaces sentence audio without touching
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.equal(storedMedia.length, 1);
|
||||
const audioValue = `[sound:${storedMedia[0]}]`;
|
||||
assert.equal(updatedFields[0]?.SentenceAudio, audioValue);
|
||||
assert.equal('ExpressionAudio' in updatedFields[0]!, false);
|
||||
assert.equal(updatedFields[0]?.ExpressionAudio, audioValue);
|
||||
assert.equal('SentenceAudio' 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: () =>
|
||||
@@ -225,7 +355,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
||||
});
|
||||
});
|
||||
|
||||
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
|
||||
test('manual clipboard subtitle update uses configured audio when SentenceAudio is missing', async () => {
|
||||
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
|
||||
client: {
|
||||
addNote: async () => 0,
|
||||
@@ -255,8 +385,9 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
||||
|
||||
assert.equal(storedMedia.length, 1);
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
|
||||
assert.equal(mergeCalls.length, 0);
|
||||
assert.match(updatedFields[0]?.ExpressionAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
|
||||
assert.equal(updatedFields[0]?.Sentence, '字幕');
|
||||
assert.equal(mergeCalls.length, 1);
|
||||
});
|
||||
|
||||
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
|
||||
|
||||
@@ -260,9 +260,16 @@ export class CardCreationService {
|
||||
fields,
|
||||
this.deps.getConfig(),
|
||||
);
|
||||
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
||||
const config = this.deps.getConfig();
|
||||
const sentenceAudioField = this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
|
||||
);
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
const sentenceField = this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence,
|
||||
);
|
||||
|
||||
const sentence = blocks.join(' ');
|
||||
const updatedFields: Record<string, string> = {};
|
||||
@@ -284,7 +291,6 @@ 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();
|
||||
@@ -457,13 +463,13 @@ export class CardCreationService {
|
||||
|
||||
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), 'audio');
|
||||
|
||||
const sentenceField = this.deps.getConfig().fields?.sentence;
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
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();
|
||||
@@ -806,22 +812,6 @@ 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,7 +80,6 @@ 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',
|
||||
@@ -120,6 +119,49 @@ 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 () =>
|
||||
@@ -432,6 +474,7 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
audio: 'SentenceAudio',
|
||||
image: 'Picture',
|
||||
miscInfo: 'MiscInfo',
|
||||
},
|
||||
@@ -444,7 +487,6 @@ 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');
|
||||
@@ -501,6 +543,7 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
audio: 'SentenceAudio',
|
||||
image: 'Picture',
|
||||
miscInfo: 'MiscInfo',
|
||||
},
|
||||
@@ -511,7 +554,6 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
|
||||
},
|
||||
behavior: {},
|
||||
});
|
||||
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
|
||||
harness.deps.captureSubtitleMediaContext = () => {
|
||||
captureCalls += 1;
|
||||
return capturedContext;
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface NoteUpdateWorkflowDeps {
|
||||
fields?: {
|
||||
word?: string;
|
||||
sentence?: string;
|
||||
audio?: string;
|
||||
image?: string;
|
||||
miscInfo?: string;
|
||||
};
|
||||
@@ -75,7 +76,6 @@ 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,11 +198,13 @@ export class NoteUpdateWorkflow {
|
||||
const updatedFields: Record<string, string> = {};
|
||||
let updatePerformed = false;
|
||||
let miscInfoFilename: string | null = null;
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
const configuredSentenceField =
|
||||
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence;
|
||||
const sentenceField = this.deps.resolveConfiguredFieldName(noteInfo, configuredSentenceField);
|
||||
const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext(
|
||||
fields,
|
||||
sentenceField,
|
||||
config.fields?.sentence,
|
||||
sentenceField ?? configuredSentenceField,
|
||||
configuredSentenceField,
|
||||
);
|
||||
// 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
|
||||
@@ -258,7 +260,10 @@ export class NoteUpdateWorkflow {
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const sentenceAudioField = this.deps.getResolvedSentenceAudioFieldName(noteInfo);
|
||||
const sentenceAudioField = this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
|
||||
);
|
||||
if (sentenceAudioField) {
|
||||
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
|
||||
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
|
||||
|
||||
@@ -31,7 +31,6 @@ function createDeps(
|
||||
getCachedMediaPath: async () => null,
|
||||
shouldRequireRemoteMediaCache: () => true,
|
||||
getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }),
|
||||
getResolvedSentenceAudioFieldName: () => 'SentenceAudio',
|
||||
resolveConfiguredFieldName: () => 'Picture',
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
@@ -133,7 +132,7 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
ExpressionAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
})),
|
||||
@@ -144,13 +143,16 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
},
|
||||
getConfig: () => ({ media: {}, fields: { image: 'Picture' } }) as AnkiConnectConfig,
|
||||
getConfig: () =>
|
||||
({ media: {}, fields: { audio: 'ExpressionAudio', image: 'Picture' } }) as AnkiConnectConfig,
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||
preferredNames.find((name) => name && name in noteInfo.fields) ?? null,
|
||||
});
|
||||
const queue = new PendingYoutubeMediaQueue(deps);
|
||||
|
||||
const queued = await queue.queueFromNote({
|
||||
noteId: 42,
|
||||
noteInfo: { noteId: 42, fields: {} },
|
||||
noteInfo: { noteId: 42, fields: { ExpressionAudio: { value: '' } } },
|
||||
label: 'demo',
|
||||
});
|
||||
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
@@ -158,7 +160,8 @@ 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.SentenceAudio ?? '', /^\[sound:audio\.mp3\]$/);
|
||||
assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio\.mp3\]$/);
|
||||
assert.equal('SentenceAudio' in (updatedNotes[0]?.fields ?? {}), false);
|
||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image\.webp">$/);
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ export interface PendingYoutubeMediaQueueDeps {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
getResolvedSentenceAudioFieldName: (noteInfo: PendingYoutubeMediaNoteInfo) => string | null;
|
||||
resolveConfiguredFieldName: (
|
||||
noteInfo: PendingYoutubeMediaNoteInfo,
|
||||
...preferredNames: (string | undefined)[]
|
||||
@@ -136,7 +135,7 @@ export class PendingYoutubeMediaQueue {
|
||||
startTime: mediaRange.startTime,
|
||||
endTime: mediaRange.endTime,
|
||||
label: job.label,
|
||||
audioFieldName: this.deps.getResolvedSentenceAudioFieldName(job.noteInfo) ?? undefined,
|
||||
audioFieldName: this.resolveConfiguredAudioFieldName(job.noteInfo) ?? undefined,
|
||||
imageFieldName:
|
||||
this.deps.resolveConfiguredFieldName(
|
||||
job.noteInfo,
|
||||
@@ -247,6 +246,14 @@ 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,
|
||||
@@ -283,7 +290,7 @@ export class PendingYoutubeMediaQueue {
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
const audioField =
|
||||
job.audioFieldName || this.deps.getResolvedSentenceAudioFieldName(noteInfo) || null;
|
||||
job.audioFieldName || this.resolveConfiguredAudioFieldName(noteInfo) || null;
|
||||
if (audioField) {
|
||||
const existingAudio = noteInfo.fields[audioField]?.value || '';
|
||||
mediaFields[audioField] = this.deps.mergeFieldValue(
|
||||
|
||||
@@ -218,3 +218,18 @@ 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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -153,6 +153,10 @@ 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;
|
||||
|
||||
@@ -1442,7 +1442,7 @@ const eventsHeader = [
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
];
|
||||
|
||||
test('parseSubtitleCues keeps a tall CC-style dialogue block publishable, not a fragment grid', () => {
|
||||
test('parseSubtitleCues keeps tall CC-style base dialogue publishable after removing furigana', () => {
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(212,383)\\fscx50\\fscy50}たき',
|
||||
@@ -1451,9 +1451,94 @@ test('parseSubtitleCues keeps a tall CC-style dialogue block publishable, not a
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?',
|
||||
].join('\n');
|
||||
|
||||
const cue = parseSubtitleCues(content, 'test.ass')[0];
|
||||
assert.equal(cue?.text, 'たき(立希)ともりお前… 燈をバンドに誘ったの?');
|
||||
assert.equal(cue?.assLayout?.kind, 'positioned');
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
['(立希)', 'お前… 燈をバンドに誘ったの?'],
|
||||
);
|
||||
assert.deepEqual(cues[0]?.assFurigana, ['たき']);
|
||||
assert.deepEqual(cues[1]?.assFurigana, ['ともり']);
|
||||
assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned'));
|
||||
});
|
||||
|
||||
test('parseSubtitleCues removes half-size positioned furigana from broadcast captions', () => {
|
||||
const content = [
|
||||
'[Script Info]',
|
||||
'PlayResY: 540',
|
||||
'',
|
||||
...eventsHeader,
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(192,77)\\fscx50}({\\fscx100}山田{\\fscx50}){\\fscx100}ごめん{\\fscx50} {\\fscx100}結局{\\fscx50} {\\fscx100}ぬれたな{\\fscx50}。',
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,113)\\fscx50\\fscy50}だいじょうぶ',
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)}大丈夫{\\fscx50}。',
|
||||
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,407)\\fscx50}({\\fscx100}山田の母{\\fscx50}){\\fscx100}ほんなら',
|
||||
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,443)\\fscx50\\fscy50}かく',
|
||||
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(312,443)\\fscx50\\fscy50}ちょぞう',
|
||||
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,497)}隠し貯蔵のミルクまんじゅう➡',
|
||||
'Dialogue: 0,0:04:00.00,0:04:03.00,Default,,0,0,0,,{\\pos(232,443)\\fscx50\\fscy50}ぜったい ちが',
|
||||
'Dialogue: 0,0:04:00.00,0:04:03.00,Default,,0,0,0,,{\\pos(232,497)}絶対違う',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'polar-opposites-s02e08.ass');
|
||||
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
[
|
||||
'(山田)ごめん 結局 ぬれたな。',
|
||||
'大丈夫。',
|
||||
'(山田の母)ほんなら',
|
||||
'隠し貯蔵のミルクまんじゅう➡',
|
||||
'絶対違う',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(cues[1]?.assFurigana, ['だいじょうぶ']);
|
||||
assert.deepEqual(cues[3]?.assFurigana, ['かく', 'ちょぞう']);
|
||||
assert.deepEqual(cues[4]?.assFurigana, ['ぜったい ちが']);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues scales furigana geometry by PlayResY', () => {
|
||||
const content = [
|
||||
'[Script Info]',
|
||||
'PlayResY: 1080',
|
||||
'',
|
||||
...eventsHeader,
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(1104,226)\\fscx50\\fscy50}だいじょうぶ',
|
||||
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(1104,334)}大丈夫{\\fscx50}。',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
['大丈夫。'],
|
||||
);
|
||||
assert.deepEqual(cues[0]?.assFurigana, ['だいじょうぶ']);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues preserves small kana without a matching kanji base caption', () => {
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,200)\\fscx50\\fscy50}ひそひそ',
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,254)}ordinary dialogue',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
|
||||
['ひそひそ', 'ordinary dialogue'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues preserves small kana horizontally separated from a kanji caption', () => {
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(800,200)\\fscx50\\fscy50}ひそひそ',
|
||||
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,254)}漢字',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
assert.match(cues.map((cue) => cue.text).join('\n'), /ひそひそ/);
|
||||
assert.deepEqual(
|
||||
cues.flatMap((cue) => cue.assFurigana ?? []),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues marks re-shown countdown frames as a fragment grid', () => {
|
||||
@@ -1927,9 +2012,7 @@ 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}`,
|
||||
@@ -1952,7 +2035,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) =>
|
||||
@@ -1997,3 +2080,25 @@ 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']);
|
||||
});
|
||||
|
||||
@@ -10,15 +10,29 @@ 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; y: number }
|
||||
| { kind: 'fragment-grid'; sourceOrder: number }
|
||||
| { kind: 'source-order'; sourceOrder: number };
|
||||
| {
|
||||
kind: 'positioned';
|
||||
sourceOrder: number;
|
||||
x?: number;
|
||||
y: number;
|
||||
verticalBand?: AssVerticalBand;
|
||||
}
|
||||
| { kind: 'fragment-grid'; sourceOrder: number; verticalBand?: AssVerticalBand }
|
||||
| { kind: 'source-order'; sourceOrder: number; verticalBand?: AssVerticalBand };
|
||||
|
||||
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';
|
||||
/**
|
||||
@@ -97,10 +111,17 @@ function sanitizeAssCueText(text: string): string {
|
||||
return removeAssControlDebrisLines(decodeSubtitleCueText(text)).trim();
|
||||
}
|
||||
|
||||
function attachAssLayout<T extends SubtitleCue>(cue: T, assLayout: AssCueLayout | undefined): T {
|
||||
function attachAssMetadata<T extends SubtitleCue>(
|
||||
cue: T,
|
||||
assLayout: AssCueLayout | undefined,
|
||||
assFurigana: readonly string[] | undefined,
|
||||
): T {
|
||||
if (assLayout) {
|
||||
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
|
||||
}
|
||||
if (assFurigana?.length) {
|
||||
Object.defineProperty(cue, 'assFurigana', { value: assFurigana, enumerable: false });
|
||||
}
|
||||
return cue;
|
||||
}
|
||||
|
||||
@@ -115,6 +136,7 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
||||
animationEndTime,
|
||||
style,
|
||||
assLayout,
|
||||
assFurigana,
|
||||
}) => {
|
||||
const common = {
|
||||
startTime,
|
||||
@@ -122,7 +144,7 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
||||
text,
|
||||
};
|
||||
if (source === 'reconstructed-ass') {
|
||||
return attachAssLayout(
|
||||
return attachAssMetadata(
|
||||
{
|
||||
...common,
|
||||
source,
|
||||
@@ -131,11 +153,13 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
||||
assStyle: style,
|
||||
},
|
||||
assLayout,
|
||||
assFurigana,
|
||||
);
|
||||
}
|
||||
return attachAssLayout(
|
||||
return attachAssMetadata(
|
||||
source ? { ...common, source, animationStartTime, animationEndTime } : common,
|
||||
assLayout,
|
||||
assFurigana,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -481,9 +505,7 @@ 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);
|
||||
@@ -544,9 +566,7 @@ 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) => {
|
||||
@@ -1362,8 +1382,7 @@ 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'))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2021,6 +2040,9 @@ function recoverCanonicalAssEvents({
|
||||
const animationEndTime = latestEndTime(generatedEvents, candidate.endTime);
|
||||
const startTime = kind === 'comment' ? candidate.startTime : animationStartTime;
|
||||
const endTime = kind === 'comment' ? candidate.endTime : animationEndTime;
|
||||
const assFurigana = [
|
||||
...new Set([candidate, ...generatedEvents].flatMap((cue) => cue.assFurigana ?? [])),
|
||||
];
|
||||
const recoveredCue: AnnotatedSubtitleCue = {
|
||||
...candidate,
|
||||
startTime,
|
||||
@@ -2028,6 +2050,7 @@ function recoverCanonicalAssEvents({
|
||||
animationStartTime,
|
||||
animationEndTime,
|
||||
source: 'canonical-ass',
|
||||
...(assFurigana.length === 0 ? {} : { assFurigana }),
|
||||
};
|
||||
recovered.push(recoveredCue);
|
||||
recoveredByOwner.set(candidate, recoveredCue);
|
||||
@@ -2055,6 +2078,111 @@ 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());
|
||||
@@ -2064,29 +2192,157 @@ 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;
|
||||
}
|
||||
}
|
||||
return y === null
|
||||
? { kind: 'source-order', sourceOrder }
|
||||
: { kind: 'positioned', sourceOrder, y };
|
||||
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;
|
||||
}
|
||||
|
||||
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
const ASS_FURIGANA_TEXT_PATTERN = /^[\p{Script=Hiragana}\p{Script=Katakana}ー・ \t\u3000]+$/u;
|
||||
const ASS_KANJI_PATTERN = /\p{Script=Han}/u;
|
||||
const MAX_ASS_FURIGANA_SCALE_PERCENT = 60;
|
||||
// The pixel geometry below is authored in the 540-line coordinate space Caption2Ass-style
|
||||
// broadcast CC converters emit, and is multiplied by PlayResY/540 so the same on-screen
|
||||
// window applies to scripts declaring other resolutions. Without a declaration the tuned
|
||||
// space is assumed.
|
||||
const ASS_FURIGANA_REFERENCE_PLAY_RES_Y = 540;
|
||||
const MIN_ASS_FURIGANA_BASE_GAP = 40;
|
||||
const MAX_ASS_FURIGANA_BASE_GAP = 68;
|
||||
const MIN_ASS_FURIGANA_HORIZONTAL_TOLERANCE = 80;
|
||||
const ASS_BASE_CHARACTER_WIDTH_ESTIMATE = 40;
|
||||
|
||||
function assFuriganaGeometryScale(playResY: number | null): number {
|
||||
return playResY && playResY > 0 ? playResY / ASS_FURIGANA_REFERENCE_PLAY_RES_Y : 1;
|
||||
}
|
||||
|
||||
function staticAssScalePercent(cue: AnnotatedSubtitleCue, axis: 'fscx' | 'fscy'): number | null {
|
||||
let scale: number | null = null;
|
||||
for (const command of cue.overrides) {
|
||||
if (command.animated || command.name.toLowerCase() !== axis) continue;
|
||||
const value = Number(command.args.trim());
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
scale = value;
|
||||
}
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
function isAssFuriganaCandidate(cue: AnnotatedSubtitleCue): boolean {
|
||||
const scaleX = staticAssScalePercent(cue, 'fscx');
|
||||
const scaleY = staticAssScalePercent(cue, 'fscy');
|
||||
return (
|
||||
cue.assLayout?.kind === 'positioned' &&
|
||||
ASS_FURIGANA_TEXT_PATTERN.test(cue.text) &&
|
||||
scaleX !== null &&
|
||||
scaleX <= MAX_ASS_FURIGANA_SCALE_PERCENT &&
|
||||
scaleY !== null &&
|
||||
scaleY <= MAX_ASS_FURIGANA_SCALE_PERCENT
|
||||
);
|
||||
}
|
||||
|
||||
function findAssFuriganaBase(
|
||||
furigana: AnnotatedSubtitleCue,
|
||||
cues: readonly AnnotatedSubtitleCue[],
|
||||
geometryScale: number,
|
||||
): AnnotatedSubtitleCue | null {
|
||||
if (furigana.assLayout?.kind !== 'positioned' || furigana.assLayout.x === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let nearest: { cue: AnnotatedSubtitleCue; gap: number } | null = null;
|
||||
for (const cue of cues) {
|
||||
if (
|
||||
cue === furigana ||
|
||||
cue.startTime !== furigana.startTime ||
|
||||
cue.endTime !== furigana.endTime ||
|
||||
cue.style !== furigana.style ||
|
||||
cue.layer !== furigana.layer ||
|
||||
cue.name !== furigana.name ||
|
||||
cue.assLayout?.kind !== 'positioned' ||
|
||||
!ASS_KANJI_PATTERN.test(cue.text)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const scaleY = staticAssScalePercent(cue, 'fscy');
|
||||
if (scaleY !== null && scaleY <= MAX_ASS_FURIGANA_SCALE_PERCENT) continue;
|
||||
|
||||
const gap = cue.assLayout.y - furigana.assLayout.y;
|
||||
if (
|
||||
gap < MIN_ASS_FURIGANA_BASE_GAP * geometryScale ||
|
||||
gap > MAX_ASS_FURIGANA_BASE_GAP * geometryScale
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (cue.assLayout.x === undefined) continue;
|
||||
const baseCharacterCount = [...cue.text.replace(/[ \t\u3000]/g, '')].length;
|
||||
const horizontalTolerance =
|
||||
Math.max(
|
||||
MIN_ASS_FURIGANA_HORIZONTAL_TOLERANCE,
|
||||
baseCharacterCount * ASS_BASE_CHARACTER_WIDTH_ESTIMATE,
|
||||
) * geometryScale;
|
||||
if (Math.abs(cue.assLayout.x - furigana.assLayout.x) > horizontalTolerance) continue;
|
||||
if (!nearest || gap < nearest.gap || (gap === nearest.gap && cue.order < nearest.cue.order)) {
|
||||
nearest = { cue, gap };
|
||||
}
|
||||
}
|
||||
return nearest?.cue ?? null;
|
||||
}
|
||||
|
||||
function removeAssFuriganaFromCueList(
|
||||
cues: AnnotatedSubtitleCue[],
|
||||
geometryScale: number,
|
||||
): AnnotatedSubtitleCue[] {
|
||||
const removed = new Set<AnnotatedSubtitleCue>();
|
||||
for (const cue of cues) {
|
||||
if (!isAssFuriganaCandidate(cue)) continue;
|
||||
const base = findAssFuriganaBase(cue, cues, geometryScale);
|
||||
if (!base) continue;
|
||||
base.assFurigana = [...new Set([...(base.assFurigana ?? []), cue.text])];
|
||||
removed.add(cue);
|
||||
}
|
||||
return removed.size === 0 ? cues : cues.filter((cue) => !removed.has(cue));
|
||||
}
|
||||
|
||||
function removeAssFuriganaEvents(
|
||||
events: ParsedAssEvents,
|
||||
playResY: number | null,
|
||||
): ParsedAssEvents {
|
||||
const geometryScale = assFuriganaGeometryScale(playResY);
|
||||
return {
|
||||
dialogue: removeAssFuriganaFromCueList(events.dialogue, geometryScale),
|
||||
comments: removeAssFuriganaFromCueList(events.comments, geometryScale),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAnnotatedAssEvents(content: string, placement: AssPlacementContext): ParsedAssEvents {
|
||||
const cues: AnnotatedSubtitleCue[] = [];
|
||||
const comments: AnnotatedSubtitleCue[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
@@ -2185,12 +2441,13 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
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: readField(fields, fieldIndex.style),
|
||||
style,
|
||||
layer: Number.isFinite(layer) ? layer : 0,
|
||||
name: readField(fields, fieldIndex.name),
|
||||
effect,
|
||||
@@ -2198,7 +2455,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
overrides,
|
||||
overrideSignature: assOverrideSignature(overrides),
|
||||
order: eventOrder,
|
||||
assLayout: buildAssCueLayout(overrides, eventOrder),
|
||||
assLayout: buildAssCueLayout(overrides, eventOrder, style, placement),
|
||||
};
|
||||
eventOrder += 1;
|
||||
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
||||
@@ -2212,7 +2469,13 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
}
|
||||
|
||||
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
const events = removeAssFontTextureEvents(parseAnnotatedAssEvents(content));
|
||||
const placement = content.includes('[')
|
||||
? parseAssPlacementContext(content)
|
||||
: EMPTY_PLACEMENT_CONTEXT;
|
||||
const events = removeAssFuriganaEvents(
|
||||
removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)),
|
||||
placement.playResY,
|
||||
);
|
||||
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ export function createSubtitleProcessingController(
|
||||
try {
|
||||
const cachedTokenized = getCachedTokenization(text);
|
||||
if (cachedTokenized) {
|
||||
output = cachedTokenized;
|
||||
output = { ...cachedTokenized, text };
|
||||
} 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;
|
||||
return { ...cached, text };
|
||||
},
|
||||
hasCachedSubtitle: (text: string) => {
|
||||
const cacheKey = normalizeSubtitleCacheKey(text);
|
||||
|
||||
@@ -84,6 +84,17 @@ 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(
|
||||
'猫です',
|
||||
@@ -1682,6 +1693,12 @@ 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(
|
||||
|
||||
@@ -887,7 +887,15 @@ export async function tokenizeSubtitle(
|
||||
text: string,
|
||||
deps: TokenizerServiceDeps,
|
||||
): Promise<SubtitleData> {
|
||||
const displayText = normalizePlainSubtitleText(text);
|
||||
// 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');
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -60,7 +60,33 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
|
||||
{ startTime: 1, endTime: 3, text: '二行目' },
|
||||
],
|
||||
}),
|
||||
'一行目\n二行目',
|
||||
'一行目\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大丈夫。',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -195,7 +221,7 @@ test('resolvePrimarySubtitleText combines parsed dialogue with a reconstructed l
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, '普通のセリフ\n今 手にある');
|
||||
assert.equal(text, '普通のセリフ\n\n今 手にある');
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText uses fragment grids only to account for live sign pieces', () => {
|
||||
@@ -300,7 +326,32 @@ test('resolvePrimarySubtitleText combines simultaneous canonical cues in source
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, 'first\nsecond');
|
||||
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');
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText collapses whitespace variants of a canonical lyric', () => {
|
||||
@@ -498,6 +549,23 @@ 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
|
||||
@@ -524,3 +592,85 @@ 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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SubtitleCue } from '../../types';
|
||||
import type { AssVerticalBand, SubtitleCue } from '../../types';
|
||||
import {
|
||||
removeAssControlDebrisLines,
|
||||
removeLiveGlyphFragmentLines,
|
||||
@@ -57,21 +57,52 @@ 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 uniqueCueTexts(cues: readonly SubtitleCue[]): string[] {
|
||||
const texts: string[] = [];
|
||||
function uniqueCueTextGroups(cues: readonly SubtitleCue[]): string[] {
|
||||
const groups: 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);
|
||||
texts.push(line);
|
||||
lines.push(line);
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
groups.push(lines.join('\n'));
|
||||
}
|
||||
}
|
||||
return texts;
|
||||
return groups;
|
||||
}
|
||||
|
||||
function compactLineSegments(text: string): string[] {
|
||||
@@ -118,12 +149,15 @@ function resolveActiveParsedPrimarySubtitle(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedSegments = selected.flatMap((cue) =>
|
||||
compactLineSegments(cue.text).map((segment) => ({
|
||||
segment,
|
||||
recovered: cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass',
|
||||
})),
|
||||
);
|
||||
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 })),
|
||||
),
|
||||
];
|
||||
});
|
||||
if (
|
||||
!liveSegments.every((liveSegment) =>
|
||||
parsedSegments.some(({ segment, recovered }) =>
|
||||
@@ -134,23 +168,24 @@ function resolveActiveParsedPrimarySubtitle(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
|
||||
// Dense sign grids still explain their raw mpv fragments, but are visual
|
||||
// typesetting rather than a publishable subtitle line.
|
||||
const texts = uniqueCueTexts(
|
||||
displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid'),
|
||||
const groups = uniqueCueTextGroups(
|
||||
orderCuesForDisplay(displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid')),
|
||||
);
|
||||
return {
|
||||
text: texts.join('\n'),
|
||||
text: groups.join(CUE_BOUNDARY),
|
||||
startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
|
||||
endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
|
||||
cues: displayCues,
|
||||
@@ -217,9 +252,9 @@ export function resolveCanonicalPrimarySubtitle(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const texts = uniqueCueTexts(selected);
|
||||
const groups = uniqueCueTextGroups(orderCuesForDisplay(selected));
|
||||
return {
|
||||
text: texts.join('\n'),
|
||||
text: groups.join(CUE_BOUNDARY),
|
||||
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
||||
endTime: Math.max(...selected.map((cue) => cue.endTime)),
|
||||
cues: selected,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getFrequencyRankLabelForToken,
|
||||
getJlptLevelLabelForToken,
|
||||
normalizeSubtitle,
|
||||
normalizeSubtitleForDisplay,
|
||||
prepareSecondarySubtitleLines,
|
||||
sanitizeSubtitleHoverTokenColor,
|
||||
shouldRenderTokenizedSubtitle,
|
||||
@@ -1004,6 +1005,34 @@ 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.
|
||||
|
||||
@@ -50,6 +50,21 @@ 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]+)$/;
|
||||
@@ -414,16 +429,13 @@ function renderWithTokens(
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
if (sourceText) {
|
||||
const normalizedSource = normalizeSubtitle(sourceText, true, !preserveLineBreaks);
|
||||
const normalizedSource = normalizeSubtitleForDisplay(sourceText, preserveLineBreaks);
|
||||
const segments = alignTokensToSourceText(tokens, normalizedSource);
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.kind === 'text') {
|
||||
if (preserveLineBreaks) {
|
||||
renderPlainTextPreserveLineBreaks(fragment, segment.text);
|
||||
} else {
|
||||
fragment.appendChild(document.createTextNode(segment.text));
|
||||
}
|
||||
// Normalization already resolved which breaks survive; every one left is real.
|
||||
renderPlainTextPreserveLineBreaks(fragment, segment.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -748,7 +760,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
|
||||
const normalized = normalizeSubtitleForDisplay(text, ctx.state.preserveSubtitleLineBreaks);
|
||||
const hasRenderableTokens =
|
||||
shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && Boolean(tokens);
|
||||
if (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SubtitleCue } from '../core/services/subtitle-cue-parser';
|
||||
import type { AssVerticalBand, 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 { SubtitleCue };
|
||||
export type { AssVerticalBand, SubtitleCue };
|
||||
|
||||
export type SubtitleSidebarLayout = 'overlay' | 'embedded';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user