mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-29 12:15:27 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2509b6cd7
|
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anki
|
|
||||||
|
|
||||||
- Word-card enrichment now always writes sentence text and audio to the configured AnkiConnect fields, while dedicated Lapis/Kiku sentence-card and audio-card actions keep their compatible field names.
|
|
||||||
@@ -2,4 +2,4 @@ type: fixed
|
|||||||
area: overlay
|
area: overlay
|
||||||
|
|
||||||
- Subtitle lines that start while another line is still on screen now appear alongside it instead of staying hidden until a track switch or seek (#220).
|
- Subtitle lines that start while another line is still on screen now appear alongside it instead of staying hidden until a track switch or seek (#220).
|
||||||
- Two subtitles shown at the same time now always render on separate lines, stacked by their authored screen position (top signs and song lines above bottom dialogue), so lines no longer merge into one sentence or swap rows mid-display.
|
- Genuine subtitles shown at the same time now render on separate lines, stacked by their authored screen position (top signs and song lines above bottom dialogue), while half-size ASS furigana is omitted instead of appearing as dialogue.
|
||||||
|
|||||||
@@ -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.
|
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).
|
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
|
### 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.
|
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
|
## 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.
|
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.
|
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)
|
## Word Card Type (Kiku/Lapis)
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
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.
|
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.
|
between ordinary, hard, or ideographic spaces appear once.
|
||||||
- Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their
|
- Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their
|
||||||
authored source order when no usable position exists.
|
authored source order when no usable position exists.
|
||||||
|
- Half-size kana positioned directly above a same-timed kanji caption is treated as ASS
|
||||||
|
furigana. The parser omits it from published cues but retains hidden matching metadata so
|
||||||
|
mpv's raw live text can be reconciled without displaying or mining the reading.
|
||||||
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
|
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
|
||||||
survive concatenation. Latin fragment typesetting with no literal spaces also recovers word
|
survive concatenation. Latin fragment typesetting with no literal spaces also recovers word
|
||||||
boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that
|
boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that
|
||||||
|
|||||||
@@ -608,7 +608,6 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
|||||||
const integration = new AnkiIntegration(
|
const integration = new AnkiIntegration(
|
||||||
{
|
{
|
||||||
fields: {
|
fields: {
|
||||||
audio: 'ExpressionAudio',
|
|
||||||
image: 'Picture',
|
image: 'Picture',
|
||||||
},
|
},
|
||||||
media: {
|
media: {
|
||||||
@@ -662,7 +661,7 @@ test('AnkiIntegration applies ready YouTube cache media to every queued note id'
|
|||||||
noteIds.map((noteId) => ({
|
noteIds.map((noteId) => ({
|
||||||
noteId,
|
noteId,
|
||||||
fields: {
|
fields: {
|
||||||
ExpressionAudio: { value: '' },
|
SentenceAudio: { value: '' },
|
||||||
Picture: { value: '' },
|
Picture: { value: '' },
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
@@ -947,7 +946,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
|||||||
noteInfo: {
|
noteInfo: {
|
||||||
noteId: 404,
|
noteId: 404,
|
||||||
fields: {
|
fields: {
|
||||||
ExpressionAudio: { value: '' },
|
SentenceAudio: { value: '' },
|
||||||
Picture: { value: '' },
|
Picture: { value: '' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -959,8 +958,7 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
|||||||
assert.equal(queued, true);
|
assert.equal(queued, true);
|
||||||
assert.equal(updatedNotes.length, 1);
|
assert.equal(updatedNotes.length, 1);
|
||||||
assert.equal(updatedNotes[0]?.noteId, 404);
|
assert.equal(updatedNotes[0]?.noteId, 404);
|
||||||
assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio_/);
|
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
|
||||||
assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined);
|
|
||||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
||||||
assert.equal(storedMedia.length, 2);
|
assert.equal(storedMedia.length, 2);
|
||||||
assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
|
assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
|
||||||
|
|||||||
@@ -383,6 +383,8 @@ export class AnkiIntegration {
|
|||||||
getCachedMediaPath: this.getCachedMediaPath,
|
getCachedMediaPath: this.getCachedMediaPath,
|
||||||
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
|
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
|
||||||
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
|
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
|
||||||
|
getResolvedSentenceAudioFieldName: (noteInfo) =>
|
||||||
|
this.getResolvedSentenceAudioFieldName(noteInfo),
|
||||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||||
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
||||||
mergeFieldValue: (existing, newValue, overwrite) =>
|
mergeFieldValue: (existing, newValue, overwrite) =>
|
||||||
@@ -659,6 +661,8 @@ export class AnkiIntegration {
|
|||||||
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
|
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
|
||||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||||
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
|
||||||
|
getResolvedSentenceAudioFieldName: (noteInfo) =>
|
||||||
|
this.getResolvedSentenceAudioFieldName(noteInfo),
|
||||||
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
|
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
|
||||||
mergeFieldValue: (existing, newValue, overwrite) =>
|
mergeFieldValue: (existing, newValue, overwrite) =>
|
||||||
this.mergeFieldValue(existing, newValue, overwrite),
|
this.mergeFieldValue(existing, newValue, overwrite),
|
||||||
@@ -1563,6 +1567,13 @@ export class AnkiIntegration {
|
|||||||
return sentenceCardConfig.audioField || 'SentenceAudio';
|
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 {
|
private getConfiguredWordFieldName(): string {
|
||||||
return getConfiguredWordFieldName(this.config);
|
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();
|
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService();
|
||||||
|
|
||||||
await service.updateLastAddedFromClipboard('字幕');
|
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(updatedFields.length, 1);
|
||||||
assert.equal(storedMedia.length, 1);
|
assert.equal(storedMedia.length, 1);
|
||||||
const audioValue = `[sound:${storedMedia[0]}]`;
|
const audioValue = `[sound:${storedMedia[0]}]`;
|
||||||
assert.equal(updatedFields[0]?.ExpressionAudio, audioValue);
|
assert.equal(updatedFields[0]?.SentenceAudio, audioValue);
|
||||||
assert.equal('SentenceAudio' in updatedFields[0]!, false);
|
assert.equal('ExpressionAudio' in updatedFields[0]!, false);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
mergeCalls.map((call) => call.overwrite),
|
mergeCalls.map((call) => call.overwrite),
|
||||||
[true],
|
[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 () => {
|
test('manual clipboard subtitle update marks Kiku word cards as word-and-sentence cards when enabled', async () => {
|
||||||
const { service, updatedFields } = createManualUpdateService({
|
const { service, updatedFields } = createManualUpdateService({
|
||||||
getConfig: () =>
|
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({
|
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
|
||||||
client: {
|
client: {
|
||||||
addNote: async () => 0,
|
addNote: async () => 0,
|
||||||
@@ -385,9 +255,8 @@ test('manual clipboard subtitle update uses configured audio when SentenceAudio
|
|||||||
|
|
||||||
assert.equal(storedMedia.length, 1);
|
assert.equal(storedMedia.length, 1);
|
||||||
assert.equal(updatedFields.length, 1);
|
assert.equal(updatedFields.length, 1);
|
||||||
assert.match(updatedFields[0]?.ExpressionAudio ?? '', /^\[sound:audio_\d+\.mp3\]$/);
|
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
|
||||||
assert.equal(updatedFields[0]?.Sentence, '字幕');
|
assert.equal(mergeCalls.length, 0);
|
||||||
assert.equal(mergeCalls.length, 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
|
test('manual clipboard subtitle update uses resolved mpv stream URLs for remote media', async () => {
|
||||||
|
|||||||
@@ -260,16 +260,9 @@ export class CardCreationService {
|
|||||||
fields,
|
fields,
|
||||||
this.deps.getConfig(),
|
this.deps.getConfig(),
|
||||||
);
|
);
|
||||||
const config = this.deps.getConfig();
|
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
||||||
const sentenceAudioField = this.deps.resolveConfiguredFieldName(
|
|
||||||
noteInfo,
|
|
||||||
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
|
|
||||||
);
|
|
||||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||||
const sentenceField = this.deps.resolveConfiguredFieldName(
|
const sentenceField = sentenceCardConfig.sentenceField;
|
||||||
noteInfo,
|
|
||||||
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence,
|
|
||||||
);
|
|
||||||
|
|
||||||
const sentence = blocks.join(' ');
|
const sentence = blocks.join(' ');
|
||||||
const updatedFields: Record<string, string> = {};
|
const updatedFields: Record<string, string> = {};
|
||||||
@@ -291,6 +284,7 @@ export class CardCreationService {
|
|||||||
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
|
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const config = this.deps.getConfig();
|
||||||
const generateAudio = shouldGenerateAudio(config);
|
const generateAudio = shouldGenerateAudio(config);
|
||||||
const generateImage = shouldGenerateImage(config);
|
const generateImage = shouldGenerateImage(config);
|
||||||
const mediaResolverOptions = this.getMediaResolverOptions();
|
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||||
@@ -463,13 +457,13 @@ export class CardCreationService {
|
|||||||
|
|
||||||
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), 'audio');
|
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), 'audio');
|
||||||
|
|
||||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
const sentenceField = this.deps.getConfig().fields?.sentence;
|
||||||
const sentenceField = sentenceCardConfig.sentenceField;
|
|
||||||
if (sentenceField) {
|
if (sentenceField) {
|
||||||
const processedSentence = this.deps.processSentence(mpvClient.currentSubText, fields);
|
const processedSentence = this.deps.processSentence(mpvClient.currentSubText, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||||
const audioFieldName = sentenceCardConfig.audioField;
|
const audioFieldName = sentenceCardConfig.audioField;
|
||||||
try {
|
try {
|
||||||
const audioFilename = this.generateAudioFilename();
|
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 {
|
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
|
||||||
return {
|
return {
|
||||||
noteId: -1,
|
noteId: -1,
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ function createWorkflowHarness() {
|
|||||||
const names = Object.keys(noteInfo.fields);
|
const names = Object.keys(noteInfo.fields);
|
||||||
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
|
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
|
||||||
},
|
},
|
||||||
|
getResolvedSentenceAudioFieldName: () => null,
|
||||||
getAnimatedImageLeadInSeconds: async () => 0,
|
getAnimatedImageLeadInSeconds: async () => 0,
|
||||||
mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
|
mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
|
||||||
generateAudioFilename: () => 'audio_1.mp3',
|
generateAudioFilename: () => 'audio_1.mp3',
|
||||||
@@ -119,49 +120,6 @@ test('NoteUpdateWorkflow updates sentence field and emits notification', async (
|
|||||||
assert.equal(harness.notifications.length, 1);
|
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 () => {
|
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
harness.deps.client.notesInfo = async () =>
|
harness.deps.client.notesInfo = async () =>
|
||||||
@@ -474,7 +432,6 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
|
|||||||
harness.deps.getConfig = () => ({
|
harness.deps.getConfig = () => ({
|
||||||
fields: {
|
fields: {
|
||||||
sentence: 'Sentence',
|
sentence: 'Sentence',
|
||||||
audio: 'SentenceAudio',
|
|
||||||
image: 'Picture',
|
image: 'Picture',
|
||||||
miscInfo: 'MiscInfo',
|
miscInfo: 'MiscInfo',
|
||||||
},
|
},
|
||||||
@@ -487,6 +444,7 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
|
|||||||
});
|
});
|
||||||
harness.deps.getCurrentSubtitleText = () => 'current primary line';
|
harness.deps.getCurrentSubtitleText = () => 'current primary line';
|
||||||
harness.deps.getCurrentSubtitleStart = () => 20;
|
harness.deps.getCurrentSubtitleStart = () => 20;
|
||||||
|
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
|
||||||
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
|
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
|
||||||
audioContext = context ?? null;
|
audioContext = context ?? null;
|
||||||
return Buffer.from('audio');
|
return Buffer.from('audio');
|
||||||
@@ -543,7 +501,6 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
|
|||||||
harness.deps.getConfig = () => ({
|
harness.deps.getConfig = () => ({
|
||||||
fields: {
|
fields: {
|
||||||
sentence: 'Sentence',
|
sentence: 'Sentence',
|
||||||
audio: 'SentenceAudio',
|
|
||||||
image: 'Picture',
|
image: 'Picture',
|
||||||
miscInfo: 'MiscInfo',
|
miscInfo: 'MiscInfo',
|
||||||
},
|
},
|
||||||
@@ -554,6 +511,7 @@ test('NoteUpdateWorkflow snapshots one media range for audio and image without a
|
|||||||
},
|
},
|
||||||
behavior: {},
|
behavior: {},
|
||||||
});
|
});
|
||||||
|
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
|
||||||
harness.deps.captureSubtitleMediaContext = () => {
|
harness.deps.captureSubtitleMediaContext = () => {
|
||||||
captureCalls += 1;
|
captureCalls += 1;
|
||||||
return capturedContext;
|
return capturedContext;
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
fields?: {
|
fields?: {
|
||||||
word?: string;
|
word?: string;
|
||||||
sentence?: string;
|
sentence?: string;
|
||||||
audio?: string;
|
|
||||||
image?: string;
|
image?: string;
|
||||||
miscInfo?: string;
|
miscInfo?: string;
|
||||||
};
|
};
|
||||||
@@ -76,6 +75,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||||
...preferredNames: (string | undefined)[]
|
...preferredNames: (string | undefined)[]
|
||||||
) => string | null;
|
) => string | null;
|
||||||
|
getResolvedSentenceAudioFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo) => string | null;
|
||||||
getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>;
|
getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>;
|
||||||
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
|
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
|
||||||
generateAudioFilename: () => string;
|
generateAudioFilename: () => string;
|
||||||
@@ -198,13 +198,11 @@ export class NoteUpdateWorkflow {
|
|||||||
const updatedFields: Record<string, string> = {};
|
const updatedFields: Record<string, string> = {};
|
||||||
let updatePerformed = false;
|
let updatePerformed = false;
|
||||||
let miscInfoFilename: string | null = null;
|
let miscInfoFilename: string | null = null;
|
||||||
const configuredSentenceField =
|
const sentenceField = sentenceCardConfig.sentenceField;
|
||||||
config.fields?.sentence ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence;
|
|
||||||
const sentenceField = this.deps.resolveConfiguredFieldName(noteInfo, configuredSentenceField);
|
|
||||||
const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext(
|
const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext(
|
||||||
fields,
|
fields,
|
||||||
sentenceField ?? configuredSentenceField,
|
sentenceField,
|
||||||
configuredSentenceField,
|
config.fields?.sentence,
|
||||||
);
|
);
|
||||||
// Audio and image generation run sequentially and audio extraction can take tens of
|
// 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
|
// seconds, so resolve the clip range exactly once up front; reading live mpv sub
|
||||||
@@ -260,10 +258,7 @@ export class NoteUpdateWorkflow {
|
|||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||||
const sentenceAudioField = this.deps.resolveConfiguredFieldName(
|
const sentenceAudioField = this.deps.getResolvedSentenceAudioFieldName(noteInfo);
|
||||||
noteInfo,
|
|
||||||
config.fields?.audio ?? DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
|
|
||||||
);
|
|
||||||
if (sentenceAudioField) {
|
if (sentenceAudioField) {
|
||||||
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
|
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
|
||||||
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
|
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function createDeps(
|
|||||||
getCachedMediaPath: async () => null,
|
getCachedMediaPath: async () => null,
|
||||||
shouldRequireRemoteMediaCache: () => true,
|
shouldRequireRemoteMediaCache: () => true,
|
||||||
getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }),
|
getSubtitleMediaRange: () => ({ startTime: 1, endTime: 2 }),
|
||||||
|
getResolvedSentenceAudioFieldName: () => 'SentenceAudio',
|
||||||
resolveConfiguredFieldName: () => 'Picture',
|
resolveConfiguredFieldName: () => 'Picture',
|
||||||
mergeFieldValue: (_existing, newValue) => newValue,
|
mergeFieldValue: (_existing, newValue) => newValue,
|
||||||
getAnimatedImageLeadInSeconds: async () => 0,
|
getAnimatedImageLeadInSeconds: async () => 0,
|
||||||
@@ -132,7 +133,7 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
|
|||||||
noteIds.map((noteId) => ({
|
noteIds.map((noteId) => ({
|
||||||
noteId,
|
noteId,
|
||||||
fields: {
|
fields: {
|
||||||
ExpressionAudio: { value: '' },
|
SentenceAudio: { value: '' },
|
||||||
Picture: { value: '' },
|
Picture: { value: '' },
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
@@ -143,16 +144,13 @@ test('PendingYoutubeMediaQueue defaults missing media flags to enabled when queu
|
|||||||
storedMedia.push(filename);
|
storedMedia.push(filename);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
getConfig: () =>
|
getConfig: () => ({ media: {}, fields: { image: 'Picture' } }) as AnkiConnectConfig,
|
||||||
({ 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 queue = new PendingYoutubeMediaQueue(deps);
|
||||||
|
|
||||||
const queued = await queue.queueFromNote({
|
const queued = await queue.queueFromNote({
|
||||||
noteId: 42,
|
noteId: 42,
|
||||||
noteInfo: { noteId: 42, fields: { ExpressionAudio: { value: '' } } },
|
noteInfo: { noteId: 42, fields: {} },
|
||||||
label: 'demo',
|
label: 'demo',
|
||||||
});
|
});
|
||||||
await queue.handleReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
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(queued, true);
|
||||||
assert.equal(updatedNotes.length, 1);
|
assert.equal(updatedNotes.length, 1);
|
||||||
assert.equal(storedMedia.length, 2);
|
assert.equal(storedMedia.length, 2);
|
||||||
assert.match(updatedNotes[0]?.fields.ExpressionAudio ?? '', /^\[sound:audio\.mp3\]$/);
|
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio\.mp3\]$/);
|
||||||
assert.equal('SentenceAudio' in (updatedNotes[0]?.fields ?? {}), false);
|
|
||||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image\.webp">$/);
|
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image\.webp">$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface PendingYoutubeMediaQueueDeps {
|
|||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
};
|
};
|
||||||
|
getResolvedSentenceAudioFieldName: (noteInfo: PendingYoutubeMediaNoteInfo) => string | null;
|
||||||
resolveConfiguredFieldName: (
|
resolveConfiguredFieldName: (
|
||||||
noteInfo: PendingYoutubeMediaNoteInfo,
|
noteInfo: PendingYoutubeMediaNoteInfo,
|
||||||
...preferredNames: (string | undefined)[]
|
...preferredNames: (string | undefined)[]
|
||||||
@@ -135,7 +136,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
startTime: mediaRange.startTime,
|
startTime: mediaRange.startTime,
|
||||||
endTime: mediaRange.endTime,
|
endTime: mediaRange.endTime,
|
||||||
label: job.label,
|
label: job.label,
|
||||||
audioFieldName: this.resolveConfiguredAudioFieldName(job.noteInfo) ?? undefined,
|
audioFieldName: this.deps.getResolvedSentenceAudioFieldName(job.noteInfo) ?? undefined,
|
||||||
imageFieldName:
|
imageFieldName:
|
||||||
this.deps.resolveConfiguredFieldName(
|
this.deps.resolveConfiguredFieldName(
|
||||||
job.noteInfo,
|
job.noteInfo,
|
||||||
@@ -246,14 +247,6 @@ export class PendingYoutubeMediaQueue {
|
|||||||
return matched;
|
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(
|
private async applyUpdate(
|
||||||
job: PendingYoutubeMediaUpdate,
|
job: PendingYoutubeMediaUpdate,
|
||||||
cachedPath: string,
|
cachedPath: string,
|
||||||
@@ -290,7 +283,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||||
const audioField =
|
const audioField =
|
||||||
job.audioFieldName || this.resolveConfiguredAudioFieldName(noteInfo) || null;
|
job.audioFieldName || this.deps.getResolvedSentenceAudioFieldName(noteInfo) || null;
|
||||||
if (audioField) {
|
if (audioField) {
|
||||||
const existingAudio = noteInfo.fields[audioField]?.value || '';
|
const existingAudio = noteInfo.fields[audioField]?.value || '';
|
||||||
mediaFields[audioField] = this.deps.mergeFieldValue(
|
mediaFields[audioField] = this.deps.mergeFieldValue(
|
||||||
|
|||||||
@@ -1442,7 +1442,7 @@ const eventsHeader = [
|
|||||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
];
|
];
|
||||||
|
|
||||||
test('parseSubtitleCues keeps a tall CC-style dialogue block publishable, not a fragment grid', () => {
|
test('parseSubtitleCues keeps tall CC-style base dialogue publishable after removing furigana', () => {
|
||||||
const content = [
|
const content = [
|
||||||
...eventsHeader,
|
...eventsHeader,
|
||||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(212,383)\\fscx50\\fscy50}たき',
|
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(212,383)\\fscx50\\fscy50}たき',
|
||||||
@@ -1451,9 +1451,94 @@ test('parseSubtitleCues keeps a tall CC-style dialogue block publishable, not a
|
|||||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?',
|
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const cue = parseSubtitleCues(content, 'test.ass')[0];
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
assert.equal(cue?.text, 'たき(立希)ともりお前… 燈をバンドに誘ったの?');
|
assert.deepEqual(
|
||||||
assert.equal(cue?.assLayout?.kind, 'positioned');
|
cues.map((cue) => cue.text),
|
||||||
|
['(立希)', 'お前… 燈をバンドに誘ったの?'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(cues[0]?.assFurigana, ['たき']);
|
||||||
|
assert.deepEqual(cues[1]?.assFurigana, ['ともり']);
|
||||||
|
assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues removes half-size positioned furigana from broadcast captions', () => {
|
||||||
|
const content = [
|
||||||
|
'[Script Info]',
|
||||||
|
'PlayResY: 540',
|
||||||
|
'',
|
||||||
|
...eventsHeader,
|
||||||
|
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(192,77)\\fscx50}({\\fscx100}山田{\\fscx50}){\\fscx100}ごめん{\\fscx50} {\\fscx100}結局{\\fscx50} {\\fscx100}ぬれたな{\\fscx50}。',
|
||||||
|
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,113)\\fscx50\\fscy50}だいじょうぶ',
|
||||||
|
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)}大丈夫{\\fscx50}。',
|
||||||
|
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,407)\\fscx50}({\\fscx100}山田の母{\\fscx50}){\\fscx100}ほんなら',
|
||||||
|
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,443)\\fscx50\\fscy50}かく',
|
||||||
|
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(312,443)\\fscx50\\fscy50}ちょぞう',
|
||||||
|
'Dialogue: 0,0:03:51.34,0:03:53.68,Default,,0,0,0,,{\\pos(232,497)}隠し貯蔵のミルクまんじゅう➡',
|
||||||
|
'Dialogue: 0,0:04:00.00,0:04:03.00,Default,,0,0,0,,{\\pos(232,443)\\fscx50\\fscy50}ぜったい ちが',
|
||||||
|
'Dialogue: 0,0:04:00.00,0:04:03.00,Default,,0,0,0,,{\\pos(232,497)}絶対違う',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'polar-opposites-s02e08.ass');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
cues.map((cue) => cue.text),
|
||||||
|
[
|
||||||
|
'(山田)ごめん 結局 ぬれたな。',
|
||||||
|
'大丈夫。',
|
||||||
|
'(山田の母)ほんなら',
|
||||||
|
'隠し貯蔵のミルクまんじゅう➡',
|
||||||
|
'絶対違う',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(cues[1]?.assFurigana, ['だいじょうぶ']);
|
||||||
|
assert.deepEqual(cues[3]?.assFurigana, ['かく', 'ちょぞう']);
|
||||||
|
assert.deepEqual(cues[4]?.assFurigana, ['ぜったい ちが']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues scales furigana geometry by PlayResY', () => {
|
||||||
|
const content = [
|
||||||
|
'[Script Info]',
|
||||||
|
'PlayResY: 1080',
|
||||||
|
'',
|
||||||
|
...eventsHeader,
|
||||||
|
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(1104,226)\\fscx50\\fscy50}だいじょうぶ',
|
||||||
|
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(1104,334)}大丈夫{\\fscx50}。',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
assert.deepEqual(
|
||||||
|
cues.map((cue) => cue.text),
|
||||||
|
['大丈夫。'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(cues[0]?.assFurigana, ['だいじょうぶ']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues preserves small kana without a matching kanji base caption', () => {
|
||||||
|
const content = [
|
||||||
|
...eventsHeader,
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,200)\\fscx50\\fscy50}ひそひそ',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,254)}ordinary dialogue',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
|
||||||
|
['ひそひそ', 'ordinary dialogue'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues preserves small kana horizontally separated from a kanji caption', () => {
|
||||||
|
const content = [
|
||||||
|
...eventsHeader,
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(800,200)\\fscx50\\fscy50}ひそひそ',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(200,254)}漢字',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
assert.match(cues.map((cue) => cue.text).join('\n'), /ひそひそ/);
|
||||||
|
assert.deepEqual(
|
||||||
|
cues.flatMap((cue) => cue.assFurigana ?? []),
|
||||||
|
[],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseSubtitleCues marks re-shown countdown frames as a fragment grid', () => {
|
test('parseSubtitleCues marks re-shown countdown frames as a fragment grid', () => {
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedu
|
|||||||
export type AssVerticalBand = 'top' | 'middle' | 'bottom';
|
export type AssVerticalBand = 'top' | 'middle' | 'bottom';
|
||||||
|
|
||||||
export type AssCueLayout =
|
export type AssCueLayout =
|
||||||
| { kind: 'positioned'; sourceOrder: number; y: number; verticalBand?: AssVerticalBand }
|
| {
|
||||||
|
kind: 'positioned';
|
||||||
|
sourceOrder: number;
|
||||||
|
x?: number;
|
||||||
|
y: number;
|
||||||
|
verticalBand?: AssVerticalBand;
|
||||||
|
}
|
||||||
| { kind: 'fragment-grid'; sourceOrder: number; verticalBand?: AssVerticalBand }
|
| { kind: 'fragment-grid'; sourceOrder: number; verticalBand?: AssVerticalBand }
|
||||||
| { kind: 'source-order'; sourceOrder: number; verticalBand?: AssVerticalBand };
|
| { kind: 'source-order'; sourceOrder: number; verticalBand?: AssVerticalBand };
|
||||||
|
|
||||||
@@ -22,6 +28,11 @@ export interface SubtitleCue {
|
|||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
/**
|
||||||
|
* ASS ruby text removed from the published cue. Kept only so live `sub-text` matching
|
||||||
|
* can account for the extra lines mpv still reports from the source track.
|
||||||
|
*/
|
||||||
|
assFurigana?: readonly string[];
|
||||||
/** How a complete line was recovered from generated ASS animation events. */
|
/** How a complete line was recovered from generated ASS animation events. */
|
||||||
source?: 'canonical-ass' | 'reconstructed-ass';
|
source?: 'canonical-ass' | 'reconstructed-ass';
|
||||||
/**
|
/**
|
||||||
@@ -100,10 +111,17 @@ function sanitizeAssCueText(text: string): string {
|
|||||||
return removeAssControlDebrisLines(decodeSubtitleCueText(text)).trim();
|
return removeAssControlDebrisLines(decodeSubtitleCueText(text)).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachAssLayout<T extends SubtitleCue>(cue: T, assLayout: AssCueLayout | undefined): T {
|
function attachAssMetadata<T extends SubtitleCue>(
|
||||||
|
cue: T,
|
||||||
|
assLayout: AssCueLayout | undefined,
|
||||||
|
assFurigana: readonly string[] | undefined,
|
||||||
|
): T {
|
||||||
if (assLayout) {
|
if (assLayout) {
|
||||||
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
|
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
|
||||||
}
|
}
|
||||||
|
if (assFurigana?.length) {
|
||||||
|
Object.defineProperty(cue, 'assFurigana', { value: assFurigana, enumerable: false });
|
||||||
|
}
|
||||||
return cue;
|
return cue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +136,7 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
|||||||
animationEndTime,
|
animationEndTime,
|
||||||
style,
|
style,
|
||||||
assLayout,
|
assLayout,
|
||||||
|
assFurigana,
|
||||||
}) => {
|
}) => {
|
||||||
const common = {
|
const common = {
|
||||||
startTime,
|
startTime,
|
||||||
@@ -125,7 +144,7 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
|||||||
text,
|
text,
|
||||||
};
|
};
|
||||||
if (source === 'reconstructed-ass') {
|
if (source === 'reconstructed-ass') {
|
||||||
return attachAssLayout(
|
return attachAssMetadata(
|
||||||
{
|
{
|
||||||
...common,
|
...common,
|
||||||
source,
|
source,
|
||||||
@@ -134,11 +153,13 @@ function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
|||||||
assStyle: style,
|
assStyle: style,
|
||||||
},
|
},
|
||||||
assLayout,
|
assLayout,
|
||||||
|
assFurigana,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return attachAssLayout(
|
return attachAssMetadata(
|
||||||
source ? { ...common, source, animationStartTime, animationEndTime } : common,
|
source ? { ...common, source, animationStartTime, animationEndTime } : common,
|
||||||
assLayout,
|
assLayout,
|
||||||
|
assFurigana,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -2019,6 +2040,9 @@ function recoverCanonicalAssEvents({
|
|||||||
const animationEndTime = latestEndTime(generatedEvents, candidate.endTime);
|
const animationEndTime = latestEndTime(generatedEvents, candidate.endTime);
|
||||||
const startTime = kind === 'comment' ? candidate.startTime : animationStartTime;
|
const startTime = kind === 'comment' ? candidate.startTime : animationStartTime;
|
||||||
const endTime = kind === 'comment' ? candidate.endTime : animationEndTime;
|
const endTime = kind === 'comment' ? candidate.endTime : animationEndTime;
|
||||||
|
const assFurigana = [
|
||||||
|
...new Set([candidate, ...generatedEvents].flatMap((cue) => cue.assFurigana ?? [])),
|
||||||
|
];
|
||||||
const recoveredCue: AnnotatedSubtitleCue = {
|
const recoveredCue: AnnotatedSubtitleCue = {
|
||||||
...candidate,
|
...candidate,
|
||||||
startTime,
|
startTime,
|
||||||
@@ -2026,6 +2050,7 @@ function recoverCanonicalAssEvents({
|
|||||||
animationStartTime,
|
animationStartTime,
|
||||||
animationEndTime,
|
animationEndTime,
|
||||||
source: 'canonical-ass',
|
source: 'canonical-ass',
|
||||||
|
...(assFurigana.length === 0 ? {} : { assFurigana }),
|
||||||
};
|
};
|
||||||
recovered.push(recoveredCue);
|
recovered.push(recoveredCue);
|
||||||
recoveredByOwner.set(candidate, recoveredCue);
|
recoveredByOwner.set(candidate, recoveredCue);
|
||||||
@@ -2170,34 +2195,156 @@ function buildAssCueLayout(
|
|||||||
style: string,
|
style: string,
|
||||||
placement: AssPlacementContext,
|
placement: AssPlacementContext,
|
||||||
): AssCueLayout {
|
): AssCueLayout {
|
||||||
|
let x: number | null = null;
|
||||||
let y: number | null = null;
|
let y: number | null = null;
|
||||||
for (const command of overrides) {
|
for (const command of overrides) {
|
||||||
if (command.animated) continue;
|
if (command.animated) continue;
|
||||||
const name = command.name.toLowerCase();
|
const name = command.name.toLowerCase();
|
||||||
const args = command.args.split(',');
|
const args = command.args.split(',');
|
||||||
if (name === 'pos') {
|
if (name === 'pos') {
|
||||||
|
x = parseAssCoordinate(args[0]) ?? x;
|
||||||
y = parseAssCoordinate(args[1]) ?? y;
|
y = parseAssCoordinate(args[1]) ?? y;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (name !== 'move') continue;
|
if (name !== 'move') continue;
|
||||||
|
const startX = parseAssCoordinate(args[0]);
|
||||||
const startY = parseAssCoordinate(args[1]);
|
const startY = parseAssCoordinate(args[1]);
|
||||||
|
const endX = parseAssCoordinate(args[2]);
|
||||||
const endY = parseAssCoordinate(args[3]);
|
const endY = parseAssCoordinate(args[3]);
|
||||||
|
if (startX !== null && endX !== null) {
|
||||||
|
x = (startX + endX) / 2;
|
||||||
|
}
|
||||||
if (startY !== null && endY !== null) {
|
if (startY !== null && endY !== null) {
|
||||||
y = (startY + endY) / 2;
|
y = (startY + endY) / 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const verticalBand = resolveVerticalBand(overrides, y, style, placement);
|
const verticalBand = resolveVerticalBand(overrides, y, style, placement);
|
||||||
const base: AssCueLayout =
|
const base: AssCueLayout =
|
||||||
y === null ? { kind: 'source-order', sourceOrder } : { kind: 'positioned', sourceOrder, y };
|
y === null
|
||||||
|
? { kind: 'source-order', sourceOrder }
|
||||||
|
: { kind: 'positioned', sourceOrder, ...(x === null ? {} : { x }), y };
|
||||||
return verticalBand ? { ...base, verticalBand } : base;
|
return verticalBand ? { ...base, verticalBand } : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
const ASS_FURIGANA_TEXT_PATTERN = /^[\p{Script=Hiragana}\p{Script=Katakana}ー・ \t\u3000]+$/u;
|
||||||
|
const ASS_KANJI_PATTERN = /\p{Script=Han}/u;
|
||||||
|
const MAX_ASS_FURIGANA_SCALE_PERCENT = 60;
|
||||||
|
// The pixel geometry below is authored in the 540-line coordinate space Caption2Ass-style
|
||||||
|
// broadcast CC converters emit, and is multiplied by PlayResY/540 so the same on-screen
|
||||||
|
// window applies to scripts declaring other resolutions. Without a declaration the tuned
|
||||||
|
// space is assumed.
|
||||||
|
const ASS_FURIGANA_REFERENCE_PLAY_RES_Y = 540;
|
||||||
|
const MIN_ASS_FURIGANA_BASE_GAP = 40;
|
||||||
|
const MAX_ASS_FURIGANA_BASE_GAP = 68;
|
||||||
|
const MIN_ASS_FURIGANA_HORIZONTAL_TOLERANCE = 80;
|
||||||
|
const ASS_BASE_CHARACTER_WIDTH_ESTIMATE = 40;
|
||||||
|
|
||||||
|
function assFuriganaGeometryScale(playResY: number | null): number {
|
||||||
|
return playResY && playResY > 0 ? playResY / ASS_FURIGANA_REFERENCE_PLAY_RES_Y : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function staticAssScalePercent(cue: AnnotatedSubtitleCue, axis: 'fscx' | 'fscy'): number | null {
|
||||||
|
let scale: number | null = null;
|
||||||
|
for (const command of cue.overrides) {
|
||||||
|
if (command.animated || command.name.toLowerCase() !== axis) continue;
|
||||||
|
const value = Number(command.args.trim());
|
||||||
|
if (Number.isFinite(value) && value > 0) {
|
||||||
|
scale = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAssFuriganaCandidate(cue: AnnotatedSubtitleCue): boolean {
|
||||||
|
const scaleX = staticAssScalePercent(cue, 'fscx');
|
||||||
|
const scaleY = staticAssScalePercent(cue, 'fscy');
|
||||||
|
return (
|
||||||
|
cue.assLayout?.kind === 'positioned' &&
|
||||||
|
ASS_FURIGANA_TEXT_PATTERN.test(cue.text) &&
|
||||||
|
scaleX !== null &&
|
||||||
|
scaleX <= MAX_ASS_FURIGANA_SCALE_PERCENT &&
|
||||||
|
scaleY !== null &&
|
||||||
|
scaleY <= MAX_ASS_FURIGANA_SCALE_PERCENT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAssFuriganaBase(
|
||||||
|
furigana: AnnotatedSubtitleCue,
|
||||||
|
cues: readonly AnnotatedSubtitleCue[],
|
||||||
|
geometryScale: number,
|
||||||
|
): AnnotatedSubtitleCue | null {
|
||||||
|
if (furigana.assLayout?.kind !== 'positioned' || furigana.assLayout.x === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nearest: { cue: AnnotatedSubtitleCue; gap: number } | null = null;
|
||||||
|
for (const cue of cues) {
|
||||||
|
if (
|
||||||
|
cue === furigana ||
|
||||||
|
cue.startTime !== furigana.startTime ||
|
||||||
|
cue.endTime !== furigana.endTime ||
|
||||||
|
cue.style !== furigana.style ||
|
||||||
|
cue.layer !== furigana.layer ||
|
||||||
|
cue.name !== furigana.name ||
|
||||||
|
cue.assLayout?.kind !== 'positioned' ||
|
||||||
|
!ASS_KANJI_PATTERN.test(cue.text)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const scaleY = staticAssScalePercent(cue, 'fscy');
|
||||||
|
if (scaleY !== null && scaleY <= MAX_ASS_FURIGANA_SCALE_PERCENT) continue;
|
||||||
|
|
||||||
|
const gap = cue.assLayout.y - furigana.assLayout.y;
|
||||||
|
if (
|
||||||
|
gap < MIN_ASS_FURIGANA_BASE_GAP * geometryScale ||
|
||||||
|
gap > MAX_ASS_FURIGANA_BASE_GAP * geometryScale
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (cue.assLayout.x === undefined) continue;
|
||||||
|
const baseCharacterCount = [...cue.text.replace(/[ \t\u3000]/g, '')].length;
|
||||||
|
const horizontalTolerance =
|
||||||
|
Math.max(
|
||||||
|
MIN_ASS_FURIGANA_HORIZONTAL_TOLERANCE,
|
||||||
|
baseCharacterCount * ASS_BASE_CHARACTER_WIDTH_ESTIMATE,
|
||||||
|
) * geometryScale;
|
||||||
|
if (Math.abs(cue.assLayout.x - furigana.assLayout.x) > horizontalTolerance) continue;
|
||||||
|
if (!nearest || gap < nearest.gap || (gap === nearest.gap && cue.order < nearest.cue.order)) {
|
||||||
|
nearest = { cue, gap };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearest?.cue ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAssFuriganaFromCueList(
|
||||||
|
cues: AnnotatedSubtitleCue[],
|
||||||
|
geometryScale: number,
|
||||||
|
): AnnotatedSubtitleCue[] {
|
||||||
|
const removed = new Set<AnnotatedSubtitleCue>();
|
||||||
|
for (const cue of cues) {
|
||||||
|
if (!isAssFuriganaCandidate(cue)) continue;
|
||||||
|
const base = findAssFuriganaBase(cue, cues, geometryScale);
|
||||||
|
if (!base) continue;
|
||||||
|
base.assFurigana = [...new Set([...(base.assFurigana ?? []), cue.text])];
|
||||||
|
removed.add(cue);
|
||||||
|
}
|
||||||
|
return removed.size === 0 ? cues : cues.filter((cue) => !removed.has(cue));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAssFuriganaEvents(
|
||||||
|
events: ParsedAssEvents,
|
||||||
|
playResY: number | null,
|
||||||
|
): ParsedAssEvents {
|
||||||
|
const geometryScale = assFuriganaGeometryScale(playResY);
|
||||||
|
return {
|
||||||
|
dialogue: removeAssFuriganaFromCueList(events.dialogue, geometryScale),
|
||||||
|
comments: removeAssFuriganaFromCueList(events.comments, geometryScale),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnnotatedAssEvents(content: string, placement: AssPlacementContext): ParsedAssEvents {
|
||||||
const cues: AnnotatedSubtitleCue[] = [];
|
const cues: AnnotatedSubtitleCue[] = [];
|
||||||
const comments: AnnotatedSubtitleCue[] = [];
|
const comments: AnnotatedSubtitleCue[] = [];
|
||||||
const placement = content.includes('[')
|
|
||||||
? parseAssPlacementContext(content)
|
|
||||||
: EMPTY_PLACEMENT_CONTEXT;
|
|
||||||
const lines = content.split(/\r?\n/);
|
const lines = content.split(/\r?\n/);
|
||||||
let inEventsSection = false;
|
let inEventsSection = false;
|
||||||
let eventOrder = 0;
|
let eventOrder = 0;
|
||||||
@@ -2322,7 +2469,13 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||||
const events = removeAssFontTextureEvents(parseAnnotatedAssEvents(content));
|
const placement = content.includes('[')
|
||||||
|
? parseAssPlacementContext(content)
|
||||||
|
: EMPTY_PLACEMENT_CONTEXT;
|
||||||
|
const events = removeAssFuriganaEvents(
|
||||||
|
removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)),
|
||||||
|
placement.playResY,
|
||||||
|
);
|
||||||
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
|
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,32 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolvePrimarySubtitleText accounts for live ASS furigana after canonical recovery', () => {
|
||||||
|
const ass = [
|
||||||
|
'[Script Info]',
|
||||||
|
'PlayResY: 540',
|
||||||
|
'',
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(192,77)}ごめん 結局 ぬれたな。',
|
||||||
|
'Comment: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,大丈夫。',
|
||||||
|
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,113)\\fscx50\\fscy50}だいじょうぶ',
|
||||||
|
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 1 1)}大丈夫。',
|
||||||
|
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 2 2)}大丈夫。',
|
||||||
|
'Dialogue: 0,0:02:38.20,0:02:41.87,Default,,0,0,0,,{\\pos(552,167)\\clip(m 3 3)}大丈夫。',
|
||||||
|
].join('\n');
|
||||||
|
const cues = parseSubtitleCues(ass, 'polar-opposites-s02e08.ass');
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolvePrimarySubtitleText({
|
||||||
|
liveText: 'ごめん 結局 ぬれたな。\nだいじょうぶ\n大丈夫。',
|
||||||
|
currentTimeSec: 159,
|
||||||
|
cues,
|
||||||
|
}),
|
||||||
|
'ごめん 結局 ぬれたな。\n\n大丈夫。',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('resolvePrimarySubtitleText removes duplicate lines across multiline parsed cues', () => {
|
test('resolvePrimarySubtitleText removes duplicate lines across multiline parsed cues', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolvePrimarySubtitleText({
|
resolvePrimarySubtitleText({
|
||||||
|
|||||||
@@ -149,12 +149,15 @@ function resolveActiveParsedPrimarySubtitle(options: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedSegments = selected.flatMap((cue) =>
|
const parsedSegments = selected.flatMap((cue) => {
|
||||||
compactLineSegments(cue.text).map((segment) => ({
|
const recovered = cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass';
|
||||||
segment,
|
return [
|
||||||
recovered: cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass',
|
...compactLineSegments(cue.text).map((segment) => ({ segment, recovered })),
|
||||||
})),
|
...(cue.assFurigana ?? []).flatMap((text) =>
|
||||||
);
|
compactLineSegments(text).map((segment) => ({ segment, recovered: false })),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
!liveSegments.every((liveSegment) =>
|
!liveSegments.every((liveSegment) =>
|
||||||
parsedSegments.some(({ segment, recovered }) =>
|
parsedSegments.some(({ segment, recovered }) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user