mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-29 00:15:32 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e356f53f
|
||
|
|
fa73aea2f9
|
||
|
|
f8ca8681dc
|
@@ -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.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
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).
|
|
||||||
- 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.
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: youtube
|
||||||
|
|
||||||
|
- YouTube auto-generated captions now follow their intended timing and two-row roll-up layout: long speech is paged instead of covering the video with a wall of text, while explicitly timed sound cues such as `[音楽]` no longer cover later dialogue.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -218,18 +218,3 @@ test('removeLiveGlyphFragmentLines leaves ordinary short lines alone', () => {
|
|||||||
const text = 'え\nはい。\nそうだな';
|
const text = 'え\nはい。\nそうだな';
|
||||||
assert.equal(removeLiveGlyphFragmentLines(text), text);
|
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,10 +153,6 @@ export function normalizePlainSubtitleText(
|
|||||||
);
|
);
|
||||||
if (collapseLineBreaks) {
|
if (collapseLineBreaks) {
|
||||||
normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ');
|
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;
|
return trim ? normalized.trim() : normalized;
|
||||||
|
|||||||
@@ -1927,7 +1927,9 @@ test('parseSubtitleCues does not double a line rendered whole beside its glyph s
|
|||||||
['わ', 1022],
|
['わ', 1022],
|
||||||
['ね', 1064],
|
['ね', 1064],
|
||||||
] as const;
|
] 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 = [
|
const content = [
|
||||||
...eventsHeader,
|
...eventsHeader,
|
||||||
`Dialogue: 1,0:00:17.29,0:00:18.99,OP - JP,,0,0,0,,${wholeLine}`,
|
`Dialogue: 1,0:00:17.29,0:00:18.99,OP - JP,,0,0,0,,${wholeLine}`,
|
||||||
@@ -1950,7 +1952,7 @@ test('parseSubtitleCues drops a wall of near-invisible positioned texture string
|
|||||||
// faint translation is one or two events and stays published.
|
// faint translation is one or two events and stays published.
|
||||||
const content = [
|
const content = [
|
||||||
...eventsHeader,
|
...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(
|
...Array.from(
|
||||||
{ length: 12 },
|
{ length: 12 },
|
||||||
(_, index) =>
|
(_, index) =>
|
||||||
@@ -1995,25 +1997,3 @@ test('parseSubtitleCues keeps hidden events hidden when a transform animates an
|
|||||||
['grows into view', 'wipes into view'],
|
['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,13 +10,10 @@ import {
|
|||||||
} from './ass-text';
|
} from './ass-text';
|
||||||
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
|
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 =
|
export type AssCueLayout =
|
||||||
| { kind: 'positioned'; sourceOrder: number; y: number; verticalBand?: AssVerticalBand }
|
| { kind: 'positioned'; sourceOrder: number; y: number }
|
||||||
| { kind: 'fragment-grid'; sourceOrder: number; verticalBand?: AssVerticalBand }
|
| { kind: 'fragment-grid'; sourceOrder: number }
|
||||||
| { kind: 'source-order'; sourceOrder: number; verticalBand?: AssVerticalBand };
|
| { kind: 'source-order'; sourceOrder: number };
|
||||||
|
|
||||||
export interface SubtitleCue {
|
export interface SubtitleCue {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
@@ -484,7 +481,9 @@ function structuralOverrideSignature(cue: AnnotatedSubtitleCue): string {
|
|||||||
let signature = structuralSignatureCache.get(cue);
|
let signature = structuralSignatureCache.get(cue);
|
||||||
if (signature === undefined) {
|
if (signature === undefined) {
|
||||||
const names = new Set(
|
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(',');
|
signature = [...names].sort().join(',');
|
||||||
structuralSignatureCache.set(cue, signature);
|
structuralSignatureCache.set(cue, signature);
|
||||||
@@ -545,7 +544,9 @@ function buildCoalescedCopy(members: readonly AnnotatedSubtitleCue[]): Annotated
|
|||||||
* while the anchor says one glyph. Merging each stack into a single presence spanning
|
* 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.
|
* 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 buckets = new Map<string, number[]>();
|
||||||
const anchorPoints: (AssFragmentPosition[] | null)[] = events.map(() => null);
|
const anchorPoints: (AssFragmentPosition[] | null)[] = events.map(() => null);
|
||||||
events.forEach((event, index) => {
|
events.forEach((event, index) => {
|
||||||
@@ -1361,7 +1362,8 @@ function isRepeatedGlyphText(cue: AnnotatedSubtitleCue): boolean {
|
|||||||
|
|
||||||
function isClippedRepeatedGlyphFragment(cue: AnnotatedSubtitleCue): boolean {
|
function isClippedRepeatedGlyphFragment(cue: AnnotatedSubtitleCue): boolean {
|
||||||
return (
|
return (
|
||||||
isRepeatedGlyphText(cue) && (hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'))
|
isRepeatedGlyphText(cue) &&
|
||||||
|
(hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2053,111 +2055,6 @@ function recoverCanonicalAssEvents({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bandFromNumpadAlignment(alignment: number): AssVerticalBand | null {
|
|
||||||
if (alignment >= 7 && alignment <= 9) return 'top';
|
|
||||||
if (alignment >= 4 && alignment <= 6) return 'middle';
|
|
||||||
if (alignment >= 1 && alignment <= 3) return 'bottom';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSA v4 alignment reuses the legacy `\a` codes: 1-3 bottom, +4 top, +8 middle.
|
|
||||||
function bandFromLegacyAlignment(alignment: number): AssVerticalBand | null {
|
|
||||||
if (alignment >= 9 && alignment <= 11) return 'middle';
|
|
||||||
if (alignment >= 5 && alignment <= 7) return 'top';
|
|
||||||
if (alignment >= 1 && alignment <= 3) return 'bottom';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AssPlacementContext {
|
|
||||||
playResY: number | null;
|
|
||||||
/** Lowercased style name -> vertical band from the style's Alignment column. */
|
|
||||||
styleBands: Map<string, AssVerticalBand>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_PLACEMENT_CONTEXT: AssPlacementContext = { playResY: null, styleBands: new Map() };
|
|
||||||
|
|
||||||
function parseAssPlacementContext(content: string): AssPlacementContext {
|
|
||||||
const styleBands = new Map<string, AssVerticalBand>();
|
|
||||||
let playResY: number | null = null;
|
|
||||||
let section: 'info' | 'v4plus' | 'v4' | null = null;
|
|
||||||
let alignmentIndex = -1;
|
|
||||||
let nameIndex = -1;
|
|
||||||
|
|
||||||
for (const line of content.split(/\r?\n/)) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
||||||
const sectionName = trimmed.toLowerCase();
|
|
||||||
section =
|
|
||||||
sectionName === '[script info]'
|
|
||||||
? 'info'
|
|
||||||
: sectionName === '[v4+ styles]'
|
|
||||||
? 'v4plus'
|
|
||||||
: sectionName === '[v4 styles]'
|
|
||||||
? 'v4'
|
|
||||||
: null;
|
|
||||||
alignmentIndex = -1;
|
|
||||||
nameIndex = -1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (section === 'info') {
|
|
||||||
const resMatch = trimmed.match(/^playresy\s*:\s*(\d+(?:\.\d+)?)\s*$/i);
|
|
||||||
if (resMatch) playResY = Number(resMatch[1]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (section !== 'v4plus' && section !== 'v4') continue;
|
|
||||||
const separator = trimmed.indexOf(':');
|
|
||||||
if (separator < 0) continue;
|
|
||||||
const key = trimmed.slice(0, separator).trim().toLowerCase();
|
|
||||||
const fields = trimmed.slice(separator + 1).split(',');
|
|
||||||
if (key === 'format') {
|
|
||||||
const names = fields.map((field) => field.trim().toLowerCase());
|
|
||||||
alignmentIndex = names.indexOf('alignment');
|
|
||||||
nameIndex = names.indexOf('name');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (key !== 'style' || alignmentIndex < 0 || nameIndex < 0) continue;
|
|
||||||
const styleName = fields[nameIndex]?.trim().toLowerCase();
|
|
||||||
const alignment = Number(fields[alignmentIndex]?.trim());
|
|
||||||
if (!styleName || !Number.isFinite(alignment)) continue;
|
|
||||||
const band =
|
|
||||||
section === 'v4plus'
|
|
||||||
? bandFromNumpadAlignment(alignment)
|
|
||||||
: bandFromLegacyAlignment(alignment);
|
|
||||||
if (band) styleBands.set(styleName, band);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { playResY, styleBands };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where on screen mpv will draw this event: an explicit `\pos`/`\move` coordinate when
|
|
||||||
* the script declares its coordinate space, else an `\an`/`\a` override, else the
|
|
||||||
* style's Alignment. Constant for the life of the event, which is what lets simultaneous
|
|
||||||
* lines keep a stable stacking order in the overlay.
|
|
||||||
*/
|
|
||||||
function resolveVerticalBand(
|
|
||||||
overrides: readonly AssOverrideCommand[],
|
|
||||||
y: number | null,
|
|
||||||
style: string,
|
|
||||||
context: AssPlacementContext,
|
|
||||||
): AssVerticalBand | undefined {
|
|
||||||
if (y !== null && context.playResY && context.playResY > 0) {
|
|
||||||
const ratio = y / context.playResY;
|
|
||||||
return ratio < 1 / 3 ? 'top' : ratio < 2 / 3 ? 'middle' : 'bottom';
|
|
||||||
}
|
|
||||||
for (const command of overrides) {
|
|
||||||
if (command.animated) continue;
|
|
||||||
const name = command.name.toLowerCase();
|
|
||||||
if (name !== 'an' && name !== 'a') continue;
|
|
||||||
const band =
|
|
||||||
name === 'an'
|
|
||||||
? bandFromNumpadAlignment(Number(command.args))
|
|
||||||
: bandFromLegacyAlignment(Number(command.args));
|
|
||||||
if (band) return band;
|
|
||||||
}
|
|
||||||
return context.styleBands.get(style.trim().toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAssCoordinate(value: string | undefined): number | null {
|
function parseAssCoordinate(value: string | undefined): number | null {
|
||||||
if (!value?.trim()) return null;
|
if (!value?.trim()) return null;
|
||||||
const coordinate = Number(value.trim());
|
const coordinate = Number(value.trim());
|
||||||
@@ -2167,8 +2064,6 @@ function parseAssCoordinate(value: string | undefined): number | null {
|
|||||||
function buildAssCueLayout(
|
function buildAssCueLayout(
|
||||||
overrides: readonly AssOverrideCommand[],
|
overrides: readonly AssOverrideCommand[],
|
||||||
sourceOrder: number,
|
sourceOrder: number,
|
||||||
style: string,
|
|
||||||
placement: AssPlacementContext,
|
|
||||||
): AssCueLayout {
|
): AssCueLayout {
|
||||||
let y: number | null = null;
|
let y: number | null = null;
|
||||||
for (const command of overrides) {
|
for (const command of overrides) {
|
||||||
@@ -2186,18 +2081,14 @@ function buildAssCueLayout(
|
|||||||
y = (startY + endY) / 2;
|
y = (startY + endY) / 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const verticalBand = resolveVerticalBand(overrides, y, style, placement);
|
return y === null
|
||||||
const base: AssCueLayout =
|
? { kind: 'source-order', sourceOrder }
|
||||||
y === null ? { kind: 'source-order', sourceOrder } : { kind: 'positioned', sourceOrder, y };
|
: { kind: 'positioned', sourceOrder, y };
|
||||||
return verticalBand ? { ...base, verticalBand } : base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
function parseAnnotatedAssEvents(content: string): 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;
|
||||||
@@ -2294,13 +2185,12 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
const effect = readField(fields, fieldIndex.effect);
|
const effect = readField(fields, fieldIndex.effect);
|
||||||
const layer = Number(readField(fields, fieldIndex.layer));
|
const layer = Number(readField(fields, fieldIndex.layer));
|
||||||
const overrides = collectAssOverrideCommands(rawText);
|
const overrides = collectAssOverrideCommands(rawText);
|
||||||
const style = readField(fields, fieldIndex.style);
|
|
||||||
const cue: AnnotatedSubtitleCue = {
|
const cue: AnnotatedSubtitleCue = {
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
text,
|
text,
|
||||||
rawText,
|
rawText,
|
||||||
style,
|
style: readField(fields, fieldIndex.style),
|
||||||
layer: Number.isFinite(layer) ? layer : 0,
|
layer: Number.isFinite(layer) ? layer : 0,
|
||||||
name: readField(fields, fieldIndex.name),
|
name: readField(fields, fieldIndex.name),
|
||||||
effect,
|
effect,
|
||||||
@@ -2308,7 +2198,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
overrides,
|
overrides,
|
||||||
overrideSignature: assOverrideSignature(overrides),
|
overrideSignature: assOverrideSignature(overrides),
|
||||||
order: eventOrder,
|
order: eventOrder,
|
||||||
assLayout: buildAssCueLayout(overrides, eventOrder, style, placement),
|
assLayout: buildAssCueLayout(overrides, eventOrder),
|
||||||
};
|
};
|
||||||
eventOrder += 1;
|
eventOrder += 1;
|
||||||
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function createSubtitleProcessingController(
|
|||||||
try {
|
try {
|
||||||
const cachedTokenized = getCachedTokenization(text);
|
const cachedTokenized = getCachedTokenization(text);
|
||||||
if (cachedTokenized) {
|
if (cachedTokenized) {
|
||||||
output = { ...cachedTokenized, text };
|
output = cachedTokenized;
|
||||||
} else {
|
} else {
|
||||||
// Cache miss: show the plain line on time; the tokenized payload
|
// Cache miss: show the plain line on time; the tokenized payload
|
||||||
// upgrades it once ready. Skipped on refreshes of an already
|
// upgrades it once ready. Skipped on refreshes of an already
|
||||||
@@ -266,7 +266,7 @@ export function createSubtitleProcessingController(
|
|||||||
lastEmittedText = text;
|
lastEmittedText = text;
|
||||||
lastEmittedGeneration = cacheGeneration;
|
lastEmittedGeneration = cacheGeneration;
|
||||||
lastPlainEmittedText = null;
|
lastPlainEmittedText = null;
|
||||||
return { ...cached, text };
|
return cached;
|
||||||
},
|
},
|
||||||
hasCachedSubtitle: (text: string) => {
|
hasCachedSubtitle: (text: string) => {
|
||||||
const cacheKey = normalizeSubtitleCacheKey(text);
|
const cacheKey = normalizeSubtitleCacheKey(text);
|
||||||
|
|||||||
@@ -84,17 +84,6 @@ function createDeferred<T>() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('tokenizeSubtitle keeps the blank line separating simultaneous cues', async () => {
|
|
||||||
// The tokenized payload's text drives display; folding the cue boundary would merge
|
|
||||||
// two speakers back onto one line the moment tokenization upgrades the plain emit.
|
|
||||||
const result = await tokenizeSubtitle(
|
|
||||||
'\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee',
|
|
||||||
makeDeps({ getYomitanExt: () => null }),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result.text, '\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tokenizeSubtitle splits same-line grammar endings before applying annotations', async () => {
|
test('tokenizeSubtitle splits same-line grammar endings before applying annotations', async () => {
|
||||||
const result = await tokenizeSubtitle(
|
const result = await tokenizeSubtitle(
|
||||||
'猫です',
|
'猫です',
|
||||||
@@ -1693,12 +1682,6 @@ test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async
|
|||||||
assert.equal(result.tokens, null);
|
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 () => {
|
test('tokenizeSubtitle collapses zero-width separators before Yomitan parse request', async () => {
|
||||||
let parseInput = '';
|
let parseInput = '';
|
||||||
const result = await tokenizeSubtitle(
|
const result = await tokenizeSubtitle(
|
||||||
|
|||||||
@@ -887,15 +887,7 @@ export async function tokenizeSubtitle(
|
|||||||
text: string,
|
text: string,
|
||||||
deps: TokenizerServiceDeps,
|
deps: TokenizerServiceDeps,
|
||||||
): Promise<SubtitleData> {
|
): Promise<SubtitleData> {
|
||||||
// Normalize per cue group: the blank line separating simultaneous cues is display
|
const displayText = normalizePlainSubtitleText(text);
|
||||||
// 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
|
// 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.
|
// all this drops is whitespace -- but a whitespace-only line still normalizes to empty.
|
||||||
|
|||||||
@@ -39,6 +39,118 @@ test('convertYoutubeTimedTextToVtt does not swallow text after zero-length overl
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('convertYoutubeTimedTextToVtt extends rolling captions to the next window event', () => {
|
||||||
|
// Real-world shape of YouTube's sentence-level auto captions: window-append
|
||||||
|
// filler rows (a="1", sometimes without d) mark the display timeline, while
|
||||||
|
// long text rows carry a placeholder d="3000" far shorter than the speech.
|
||||||
|
const result = convertYoutubeTimedTextToVtt(
|
||||||
|
[
|
||||||
|
'<timedtext><body>',
|
||||||
|
'<p t="98550" d="3010" w="1" a="1">\n</p>',
|
||||||
|
'<p t="98560" d="3000" w="1"><s ac="0">ありがとうって言えないよね。こんなんじゃ。</s></p>',
|
||||||
|
'<p t="106950" w="1" a="1">\n</p>',
|
||||||
|
'<p t="106960" d="3799" w="1"><s ac="0">私だったら無理だよ。</s></p>',
|
||||||
|
'</body></timedtext>',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result,
|
||||||
|
[
|
||||||
|
'WEBVTT',
|
||||||
|
'',
|
||||||
|
'00:01:38.560 --> 00:01:46.950',
|
||||||
|
'ありがとうって言えないよね。こんなんじゃ。',
|
||||||
|
'',
|
||||||
|
'00:01:46.960 --> 00:01:50.759',
|
||||||
|
'私だったら無理だよ。',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convertYoutubeTimedTextToVtt pages oversized two-row rolling captions', () => {
|
||||||
|
const text =
|
||||||
|
'あの西に結構こう山田がスーパーアプローチしてるんだけど西気づかないからちょっとこっちも気づかない感じでこう接してあげようかなて思ってんだけどあの唇巻き込んじゃうしあの思ってることも全部縁に出ちゃって自分であちゃったって言っちゃうタイプなんで結構なんかこうドライなんだけどそこがおもろいよねみたいな';
|
||||||
|
const result = convertYoutubeTimedTextToVtt(
|
||||||
|
[
|
||||||
|
'<timedtext format="3">',
|
||||||
|
'<head>',
|
||||||
|
'<ws id="1" mh="2" ju="0" sd="3"/>',
|
||||||
|
'<wp id="1" ap="6" ah="20" av="100" rc="2" cc="40"/>',
|
||||||
|
'</head>',
|
||||||
|
'<body>',
|
||||||
|
'<w t="0" id="1" wp="1" ws="1"/>',
|
||||||
|
`<p t="60440" d="3000" w="1"><s ac="0">${text}</s></p>`,
|
||||||
|
'<p t="72695" w="1" a="1">\n</p>',
|
||||||
|
'</body>',
|
||||||
|
'</timedtext>',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cues = result
|
||||||
|
.trim()
|
||||||
|
.split(/\n\n/)
|
||||||
|
.filter((block) => block.includes('-->'));
|
||||||
|
const cueText = cues.map((cue) => cue.split('\n').slice(1).join('\n'));
|
||||||
|
|
||||||
|
assert.equal(cues.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
cues.map((cue) => cue.split('\n')[0]),
|
||||||
|
['00:01:00.440 --> 00:01:07.064', '00:01:07.064 --> 00:01:12.695'],
|
||||||
|
);
|
||||||
|
assert.ok(cueText.every((page) => [...page].length <= 80));
|
||||||
|
assert.equal(cueText.join(''), text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convertYoutubeTimedTextToVtt leaves pop-on captions intact', () => {
|
||||||
|
const result = convertYoutubeTimedTextToVtt(
|
||||||
|
[
|
||||||
|
'<timedtext format="3">',
|
||||||
|
'<head>',
|
||||||
|
'<ws id="1" mh="0"/>',
|
||||||
|
'<wp id="1" rc="2" cc="4"/>',
|
||||||
|
'</head>',
|
||||||
|
'<body>',
|
||||||
|
'<w t="0" id="1" wp="1" ws="1"/>',
|
||||||
|
'<p t="1000" d="3000" w="1">abcdefghijklmnopqrst</p>',
|
||||||
|
'</body>',
|
||||||
|
'</timedtext>',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result,
|
||||||
|
['WEBVTT', '', '00:00:01.000 --> 00:00:04.000', 'abcdefghijklmnopqrst', ''].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convertYoutubeTimedTextToVtt keeps explicit 3000ms sound-cue durations in rolling documents', () => {
|
||||||
|
const result = convertYoutubeTimedTextToVtt(
|
||||||
|
[
|
||||||
|
'<timedtext><body>',
|
||||||
|
'<p t="20305" d="3000" w="1">[音楽]</p>',
|
||||||
|
'<p t="26269" w="1" a="1">\n</p>',
|
||||||
|
'<p t="26279" d="3000" w="1"><s ac="0">じゃあ、君からお願いします。</s></p>',
|
||||||
|
'</body></timedtext>',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
result,
|
||||||
|
[
|
||||||
|
'WEBVTT',
|
||||||
|
'',
|
||||||
|
'00:00:20.305 --> 00:00:23.305',
|
||||||
|
'[音楽]',
|
||||||
|
'',
|
||||||
|
'00:00:26.279 --> 00:00:29.279',
|
||||||
|
'じゃあ、君からお願いします。',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => {
|
test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => {
|
||||||
const result = normalizeYoutubeAutoVtt(
|
const result = normalizeYoutubeAutoVtt(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -2,9 +2,31 @@ interface YoutubeTimedTextRow {
|
|||||||
startMs: number;
|
startMs: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
isGenerated: boolean;
|
||||||
|
rollingWindow: YoutubeRollingWindow | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YoutubeRollingWindow {
|
||||||
|
rowCount: number;
|
||||||
|
columnCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YoutubeTimedTextWindowDefinitions {
|
||||||
|
rollingStyleIds: Set<string>;
|
||||||
|
positions: Map<string, YoutubeRollingWindow>;
|
||||||
|
windows: Map<string, YoutubeRollingWindow>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YoutubeTimedTextDocument {
|
||||||
|
rows: YoutubeTimedTextRow[];
|
||||||
|
// Start times of every <p> event, including empty window-append fillers.
|
||||||
|
// Rolling speech rows with a 3000ms placeholder display until the next event.
|
||||||
|
eventStartsMs: number[];
|
||||||
|
hasRollingWindowEvents: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']);
|
const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']);
|
||||||
|
const YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS = 3_000;
|
||||||
|
|
||||||
function decodeNumericEntity(match: string, codePoint: number): string {
|
function decodeNumericEntity(match: string, codePoint: number): string {
|
||||||
if (
|
if (
|
||||||
@@ -39,27 +61,129 @@ function parseAttributeMap(raw: string): Map<string, string> {
|
|||||||
return attrs;
|
return attrs;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] {
|
function parsePositiveInteger(value: string | undefined): number | null {
|
||||||
|
if (value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractYoutubeTimedTextWindowDefinitions(xml: string): YoutubeTimedTextWindowDefinitions {
|
||||||
|
const rollingStyleIds = new Set<string>();
|
||||||
|
for (const match of xml.matchAll(/<ws\b([^>]*)\/?\s*>/g)) {
|
||||||
|
const attrs = parseAttributeMap(match[1] ?? '');
|
||||||
|
const id = attrs.get('id');
|
||||||
|
if (id !== undefined && attrs.get('mh') === '2') {
|
||||||
|
rollingStyleIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = new Map<string, YoutubeRollingWindow>();
|
||||||
|
for (const match of xml.matchAll(/<wp\b([^>]*)\/?\s*>/g)) {
|
||||||
|
const attrs = parseAttributeMap(match[1] ?? '');
|
||||||
|
const id = attrs.get('id');
|
||||||
|
const rowCount = parsePositiveInteger(attrs.get('rc'));
|
||||||
|
const columnCount = parsePositiveInteger(attrs.get('cc'));
|
||||||
|
if (id !== undefined && rowCount !== null && columnCount !== null) {
|
||||||
|
positions.set(id, { rowCount, columnCount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const windows = new Map<string, YoutubeRollingWindow>();
|
||||||
|
for (const match of xml.matchAll(/<w\b([^>]*)\/?\s*>/g)) {
|
||||||
|
const attrs = parseAttributeMap(match[1] ?? '');
|
||||||
|
const id = attrs.get('id');
|
||||||
|
const styleId = attrs.get('ws');
|
||||||
|
const positionId = attrs.get('wp');
|
||||||
|
const position = positionId === undefined ? undefined : positions.get(positionId);
|
||||||
|
if (
|
||||||
|
id !== undefined &&
|
||||||
|
styleId !== undefined &&
|
||||||
|
rollingStyleIds.has(styleId) &&
|
||||||
|
position !== undefined
|
||||||
|
) {
|
||||||
|
windows.set(id, position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rollingStyleIds, positions, windows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRollingWindow(
|
||||||
|
attrs: Map<string, string>,
|
||||||
|
definitions: YoutubeTimedTextWindowDefinitions,
|
||||||
|
): YoutubeRollingWindow | null {
|
||||||
|
const windowId = attrs.get('w');
|
||||||
|
if (windowId !== undefined) {
|
||||||
|
return definitions.windows.get(windowId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styleId = attrs.get('ws');
|
||||||
|
const positionId = attrs.get('wp');
|
||||||
|
if (
|
||||||
|
styleId === undefined ||
|
||||||
|
positionId === undefined ||
|
||||||
|
!definitions.rollingStyleIds.has(styleId)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return definitions.positions.get(positionId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument {
|
||||||
const rows: YoutubeTimedTextRow[] = [];
|
const rows: YoutubeTimedTextRow[] = [];
|
||||||
|
const eventStartsMs: number[] = [];
|
||||||
|
let hasRollingWindowEvents = false;
|
||||||
|
const windowDefinitions = extractYoutubeTimedTextWindowDefinitions(xml);
|
||||||
|
|
||||||
for (const match of xml.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/g)) {
|
for (const match of xml.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/g)) {
|
||||||
const attrs = parseAttributeMap(match[1] ?? '');
|
const attrs = parseAttributeMap(match[1] ?? '');
|
||||||
const startMs = Number(attrs.get('t'));
|
const startMs = Number(attrs.get('t'));
|
||||||
|
if (!Number.isFinite(startMs)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
eventStartsMs.push(startMs);
|
||||||
|
if (attrs.get('a') === '1') {
|
||||||
|
hasRollingWindowEvents = true;
|
||||||
|
}
|
||||||
|
|
||||||
const durationMs = Number(attrs.get('d'));
|
const durationMs = Number(attrs.get('d'));
|
||||||
if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) {
|
if (!Number.isFinite(durationMs)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inner = (match[2] ?? '').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
|
const rawInner = match[2] ?? '';
|
||||||
|
const inner = rawInner.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
|
||||||
const text = decodeHtmlEntities(inner).trim();
|
const text = decodeHtmlEntities(inner).trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.push({ startMs, durationMs, text });
|
rows.push({
|
||||||
|
startMs,
|
||||||
|
durationMs,
|
||||||
|
text,
|
||||||
|
isGenerated: /<s\b/.test(rawInner),
|
||||||
|
rollingWindow: resolveRollingWindow(attrs, windowDefinitions),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
eventStartsMs.sort((a, b) => a - b);
|
||||||
|
return { rows, eventStartsMs, hasRollingWindowEvents };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNextEventStartMs(eventStartsMs: number[], afterMs: number): number | undefined {
|
||||||
|
for (const startMs of eventStartsMs) {
|
||||||
|
if (startMs > afterMs) {
|
||||||
|
return startMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGeneratedRollingCue(row: YoutubeTimedTextRow, hasRollingWindowEvents: boolean): boolean {
|
||||||
|
return row.isGenerated && (row.rollingWindow !== null || hasRollingWindowEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatVttTimestamp(ms: number): string {
|
function formatVttTimestamp(ms: number): string {
|
||||||
@@ -71,6 +195,79 @@ function formatVttTimestamp(ms: number): string {
|
|||||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROLLING_PAGE_BREAK_PATTERN = /[\s、。!?!?]/u;
|
||||||
|
|
||||||
|
// VTT cannot carry SRV3's row and column limits. Page only roll-up windows so
|
||||||
|
// the overlay keeps their bounded presentation without changing authored cues.
|
||||||
|
function splitRollingCaptionIntoPages(text: string, rollingWindow: YoutubeRollingWindow): string[] {
|
||||||
|
const pageCapacity = rollingWindow.rowCount * rollingWindow.columnCount;
|
||||||
|
const characters = [...text];
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(pageCapacity) ||
|
||||||
|
pageCapacity <= 0 ||
|
||||||
|
characters.length <= pageCapacity
|
||||||
|
) {
|
||||||
|
return [text];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: string[] = [];
|
||||||
|
let pageStart = 0;
|
||||||
|
while (pageStart < characters.length) {
|
||||||
|
let pageEnd = Math.min(pageStart + pageCapacity, characters.length);
|
||||||
|
if (pageEnd < characters.length) {
|
||||||
|
const earliestNaturalBreak = pageStart + Math.ceil(pageCapacity * 0.6);
|
||||||
|
for (let index = pageEnd - 1; index >= earliestNaturalBreak; index -= 1) {
|
||||||
|
if (ROLLING_PAGE_BREAK_PATTERN.test(characters[index]!)) {
|
||||||
|
pageEnd = index + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pages.push(characters.slice(pageStart, pageEnd).join(''));
|
||||||
|
pageStart = pageEnd;
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimedCaptionPage {
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeCaptionPages(input: {
|
||||||
|
text: string;
|
||||||
|
pages: string[];
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}): TimedCaptionPage[] {
|
||||||
|
const durationMs = input.endMs - input.startMs;
|
||||||
|
if (input.pages.length === 1 || durationMs < input.pages.length) {
|
||||||
|
return [{ startMs: input.startMs, endMs: input.endMs, text: input.text }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCharacters = [...input.text].length;
|
||||||
|
const timedPages: TimedCaptionPage[] = [];
|
||||||
|
let consumedCharacters = 0;
|
||||||
|
let pageStartMs = input.startMs;
|
||||||
|
// Automatic captions often omit span offsets, so distribute the known cue
|
||||||
|
// duration by page length while guaranteeing every page at least one ms.
|
||||||
|
for (let index = 0; index < input.pages.length; index += 1) {
|
||||||
|
const page = input.pages[index]!;
|
||||||
|
consumedCharacters += [...page].length;
|
||||||
|
const remainingPages = input.pages.length - index - 1;
|
||||||
|
const proportionalEndMs =
|
||||||
|
input.startMs + Math.round((durationMs * consumedCharacters) / totalCharacters);
|
||||||
|
const pageEndMs =
|
||||||
|
remainingPages === 0
|
||||||
|
? input.endMs
|
||||||
|
: Math.min(Math.max(proportionalEndMs, pageStartMs + 1), input.endMs - remainingPages);
|
||||||
|
timedPages.push({ startMs: pageStartMs, endMs: pageEndMs, text: page });
|
||||||
|
pageStartMs = pageEndMs;
|
||||||
|
}
|
||||||
|
return timedPages;
|
||||||
|
}
|
||||||
|
|
||||||
export function isYoutubeTimedTextExtension(value: string | undefined): boolean {
|
export function isYoutubeTimedTextExtension(value: string | undefined): boolean {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return false;
|
return false;
|
||||||
@@ -79,7 +276,7 @@ export function isYoutubeTimedTextExtension(value: string | undefined): boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function convertYoutubeTimedTextToVtt(xml: string): string {
|
export function convertYoutubeTimedTextToVtt(xml: string): string {
|
||||||
const rows = extractYoutubeTimedTextRows(xml);
|
const { rows, eventStartsMs, hasRollingWindowEvents } = extractYoutubeTimedTextDocument(xml);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return 'WEBVTT\n';
|
return 'WEBVTT\n';
|
||||||
}
|
}
|
||||||
@@ -90,10 +287,19 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
|
|||||||
const row = rows[index]!;
|
const row = rows[index]!;
|
||||||
const nextRow = rows[index + 1];
|
const nextRow = rows[index + 1];
|
||||||
const unclampedEnd = row.startMs + row.durationMs;
|
const unclampedEnd = row.startMs + row.durationMs;
|
||||||
|
// YouTube uses exactly 3000ms as a placeholder for generated rolling speech.
|
||||||
|
// Plain-text cues can explicitly use the same duration and must keep it.
|
||||||
|
const nextEventStart =
|
||||||
|
isGeneratedRollingCue(row, hasRollingWindowEvents) &&
|
||||||
|
row.durationMs === YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS
|
||||||
|
? findNextEventStartMs(eventStartsMs, row.startMs)
|
||||||
|
: undefined;
|
||||||
const clampedEnd =
|
const clampedEnd =
|
||||||
nextRow && unclampedEnd > nextRow.startMs
|
nextEventStart !== undefined
|
||||||
? Math.max(row.startMs, nextRow.startMs - 1)
|
? nextEventStart
|
||||||
: unclampedEnd;
|
: nextRow && unclampedEnd > nextRow.startMs
|
||||||
|
? Math.max(row.startMs, nextRow.startMs - 1)
|
||||||
|
: unclampedEnd;
|
||||||
if (clampedEnd <= row.startMs) {
|
if (clampedEnd <= row.startMs) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -106,9 +312,19 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
|
|||||||
if (!text) {
|
if (!text) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
blocks.push(
|
const pages = row.rollingWindow
|
||||||
`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`,
|
? splitRollingCaptionIntoPages(text, row.rollingWindow)
|
||||||
);
|
: [text];
|
||||||
|
for (const page of timeCaptionPages({
|
||||||
|
text,
|
||||||
|
pages,
|
||||||
|
startMs: row.startMs,
|
||||||
|
endMs: clampedEnd,
|
||||||
|
})) {
|
||||||
|
blocks.push(
|
||||||
|
`${formatVttTimestamp(page.startMs)} --> ${formatVttTimestamp(page.endMs)}\n${page.text}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
|
return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
|
|||||||
{ startTime: 1, endTime: 3, text: '二行目' },
|
{ startTime: 1, endTime: 3, text: '二行目' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
'一行目\n\n二行目',
|
'一行目\n二行目',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ test('resolvePrimarySubtitleText combines parsed dialogue with a reconstructed l
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(text, '普通のセリフ\n\n今 手にある');
|
assert.equal(text, '普通のセリフ\n今 手にある');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolvePrimarySubtitleText uses fragment grids only to account for live sign pieces', () => {
|
test('resolvePrimarySubtitleText uses fragment grids only to account for live sign pieces', () => {
|
||||||
@@ -300,32 +300,7 @@ test('resolvePrimarySubtitleText combines simultaneous canonical cues in source
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(text, 'first\n\nsecond');
|
assert.equal(text, 'first\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', () => {
|
test('resolvePrimarySubtitleText collapses whitespace variants of a canonical lyric', () => {
|
||||||
@@ -523,23 +498,6 @@ test('stripCanonicalFragmentLines drops a live glyph wall with no nearby canonic
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolvePrimarySubtitleText keeps a line joining an active cue despite stale time-pos', () => {
|
|
||||||
// Issue #220: mpv publishes the combined sub-text the moment a joining line's first
|
|
||||||
// frame renders, while the observed time-pos still sits just before that line's
|
|
||||||
// start. The joining cue must not be filtered out as inactive.
|
|
||||||
assert.equal(
|
|
||||||
resolvePrimarySubtitleText({
|
|
||||||
liveText: 'Балда! Балда, балда, балда!\nСестренка не может остановиться',
|
|
||||||
currentTimeSec: 767.78,
|
|
||||||
cues: [
|
|
||||||
{ startTime: 767.19, endTime: 772.78, text: 'Балда! Балда, балда, балда!' },
|
|
||||||
{ startTime: 767.79, endTime: 771.15, text: 'Сестренка не может остановиться' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
'Балда! Балда, балда, балда!\n\nСестренка не может остановиться',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolvePrimarySubtitleText drops a finished lyric whose exit ghosts outlive it beside a raw line', () => {
|
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
|
// 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
|
// text until 7.0, while the next authored line is a plain raw event. The retired cue
|
||||||
@@ -566,85 +524,3 @@ test('resolvePrimarySubtitleText drops a finished lyric whose exit ghosts outliv
|
|||||||
'象徴的なパレード',
|
'象徴的なパレード',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolvePrimarySubtitleText stacks simultaneous cues by screen position, not start order', () => {
|
|
||||||
// A top-anchored lyric and bottom dialogue: mpv draws the lyric above the dialogue for
|
|
||||||
// the whole overlap. Whichever event started first must not decide the row, or the
|
|
||||||
// pair swaps every time one side is replaced mid-overlap.
|
|
||||||
const lyricLayout = { kind: 'source-order', sourceOrder: 0, verticalBand: 'top' } as const;
|
|
||||||
const dialogueLayout = { kind: 'source-order', sourceOrder: 1, verticalBand: 'bottom' } as const;
|
|
||||||
const dialogue = {
|
|
||||||
startTime: 632.2,
|
|
||||||
endTime: 634.8,
|
|
||||||
text: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
|
|
||||||
assLayout: dialogueLayout,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Lyric started before the dialogue...
|
|
||||||
assert.equal(
|
|
||||||
resolvePrimarySubtitleText({
|
|
||||||
liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff21',
|
|
||||||
currentTimeSec: 632.5,
|
|
||||||
cues: [
|
|
||||||
{ startTime: 629.5, endTime: 633.5, text: '\u6b4c\u8a5e\uff21', assLayout: lyricLayout },
|
|
||||||
dialogue,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
'\u6b4c\u8a5e\uff21\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
|
|
||||||
);
|
|
||||||
// ...and the next lyric starts after it: the rows must not swap.
|
|
||||||
assert.equal(
|
|
||||||
resolvePrimarySubtitleText({
|
|
||||||
liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff22',
|
|
||||||
currentTimeSec: 633.8,
|
|
||||||
cues: [
|
|
||||||
dialogue,
|
|
||||||
{ startTime: 633.5, endTime: 637.0, text: '\u6b4c\u8a5e\uff22', assLayout: lyricLayout },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
'\u6b4c\u8a5e\uff22\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolvePrimarySubtitleText puts an unreadable placement above bottom dialogue', () => {
|
|
||||||
// Dialogue is the case that reliably declares a bottom alignment, so a cue whose
|
|
||||||
// placement could not be read is more often a sign or song line. Keeping dialogue on
|
|
||||||
// the bottom row means the line worth reading stays where the eye already is.
|
|
||||||
assert.equal(
|
|
||||||
resolvePrimarySubtitleText({
|
|
||||||
liveText: '\u4e0b\u306e\u30bb\u30ea\u30d5\n\u4e0d\u660e\u306a\u884c',
|
|
||||||
currentTimeSec: 2,
|
|
||||||
cues: [
|
|
||||||
{
|
|
||||||
startTime: 1,
|
|
||||||
endTime: 3,
|
|
||||||
text: '\u4e0b\u306e\u30bb\u30ea\u30d5',
|
|
||||||
assLayout: { kind: 'source-order', sourceOrder: 0, verticalBand: 'bottom' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
startTime: 1.5,
|
|
||||||
endTime: 3,
|
|
||||||
text: '\u4e0d\u660e\u306a\u884c',
|
|
||||||
assLayout: { kind: 'source-order', sourceOrder: 1 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
'\u4e0d\u660e\u306a\u884c\n\n\u4e0b\u306e\u30bb\u30ea\u30d5',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolvePrimarySubtitleText keeps source order when no cue declares a placement', () => {
|
|
||||||
// SRT and websocket cues carry no layout at all: every cue ties, so the stable sort
|
|
||||||
// must leave them exactly as the cue list had them.
|
|
||||||
assert.equal(
|
|
||||||
resolvePrimarySubtitleText({
|
|
||||||
liveText: 'First line\nSecond line',
|
|
||||||
currentTimeSec: 2,
|
|
||||||
cues: [
|
|
||||||
{ startTime: 1, endTime: 3, text: 'First line' },
|
|
||||||
{ startTime: 1.5, endTime: 3, text: 'Second line' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
'First line\n\nSecond line',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AssVerticalBand, SubtitleCue } from '../../types';
|
import type { SubtitleCue } from '../../types';
|
||||||
import {
|
import {
|
||||||
removeAssControlDebrisLines,
|
removeAssControlDebrisLines,
|
||||||
removeLiveGlyphFragmentLines,
|
removeLiveGlyphFragmentLines,
|
||||||
@@ -57,52 +57,21 @@ function compactWhitespace(text: string): string {
|
|||||||
return text.normalize('NFKC').replace(/\s+/gu, '');
|
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
|
// ASS layers can encode the same visible spacing with ordinary, hard, or
|
||||||
// ideographic spaces. Matching and emission must use the same identity or each
|
// ideographic spaces. Matching and emission must use the same identity or each
|
||||||
// layer reappears as a copy.
|
// layer reappears as a copy.
|
||||||
function uniqueCueTextGroups(cues: readonly SubtitleCue[]): string[] {
|
function uniqueCueTexts(cues: readonly SubtitleCue[]): string[] {
|
||||||
const groups: string[] = [];
|
const texts: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const cue of cues) {
|
for (const cue of cues) {
|
||||||
const lines: string[] = [];
|
|
||||||
for (const line of cue.text.split('\n')) {
|
for (const line of cue.text.split('\n')) {
|
||||||
const compactText = compactWhitespace(line);
|
const compactText = compactWhitespace(line);
|
||||||
if (!compactText || seen.has(compactText)) continue;
|
if (!compactText || seen.has(compactText)) continue;
|
||||||
seen.add(compactText);
|
seen.add(compactText);
|
||||||
lines.push(line);
|
texts.push(line);
|
||||||
}
|
|
||||||
if (lines.length > 0) {
|
|
||||||
groups.push(lines.join('\n'));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return groups;
|
return texts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compactLineSegments(text: string): string[] {
|
function compactLineSegments(text: string): string[] {
|
||||||
@@ -165,24 +134,23 @@ function resolveActiveParsedPrimarySubtitle(options: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A cue selected only through the edge tolerance on its end has already finished by
|
// A cue selected only through the edge tolerance has already ended (or not yet
|
||||||
// its published timing: a lyric whose exit ghosts linger into the next line. It still
|
// started) by its published timing: a finished lyric whose exit ghosts linger into
|
||||||
// explains those live fragments above, but must not re-surface beside cues that are
|
// the next line. It still explains those live fragments above, but while any cue is
|
||||||
// still running. The start side keeps the tolerance: mpv publishes the combined
|
// strictly active, only the active cues supply the displayed text. With no strictly
|
||||||
// sub-text the moment a joining line's first frame renders, while the observed
|
// active cue, the edge cues remain the display fallback for stale time-pos readings.
|
||||||
// time-pos still sits just before that line's start, and the selection above already
|
const strictlyActive = selected.filter(
|
||||||
// required the cue's text to be on screen (#220). With every selected cue finished,
|
(cue) => cue.startTime <= options.currentTimeSec && cue.endTime > options.currentTimeSec,
|
||||||
// the edge cues remain the display fallback for stale time-pos readings.
|
);
|
||||||
const unfinished = selected.filter((cue) => cue.endTime > options.currentTimeSec);
|
const displayCues = strictlyActive.length > 0 ? strictlyActive : selected;
|
||||||
const displayCues = unfinished.length > 0 ? unfinished : selected;
|
|
||||||
|
|
||||||
// Dense sign grids still explain their raw mpv fragments, but are visual
|
// Dense sign grids still explain their raw mpv fragments, but are visual
|
||||||
// typesetting rather than a publishable subtitle line.
|
// typesetting rather than a publishable subtitle line.
|
||||||
const groups = uniqueCueTextGroups(
|
const texts = uniqueCueTexts(
|
||||||
orderCuesForDisplay(displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid')),
|
displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid'),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
text: groups.join(CUE_BOUNDARY),
|
text: texts.join('\n'),
|
||||||
startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
|
startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
|
||||||
endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
|
endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
|
||||||
cues: displayCues,
|
cues: displayCues,
|
||||||
@@ -249,9 +217,9 @@ export function resolveCanonicalPrimarySubtitle(options: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = uniqueCueTextGroups(orderCuesForDisplay(selected));
|
const texts = uniqueCueTexts(selected);
|
||||||
return {
|
return {
|
||||||
text: groups.join(CUE_BOUNDARY),
|
text: texts.join('\n'),
|
||||||
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
||||||
endTime: Math.max(...selected.map((cue) => cue.endTime)),
|
endTime: Math.max(...selected.map((cue) => cue.endTime)),
|
||||||
cues: selected,
|
cues: selected,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
getFrequencyRankLabelForToken,
|
getFrequencyRankLabelForToken,
|
||||||
getJlptLevelLabelForToken,
|
getJlptLevelLabelForToken,
|
||||||
normalizeSubtitle,
|
normalizeSubtitle,
|
||||||
normalizeSubtitleForDisplay,
|
|
||||||
prepareSecondarySubtitleLines,
|
prepareSecondarySubtitleLines,
|
||||||
sanitizeSubtitleHoverTokenColor,
|
sanitizeSubtitleHoverTokenColor,
|
||||||
shouldRenderTokenizedSubtitle,
|
shouldRenderTokenizedSubtitle,
|
||||||
@@ -1005,34 +1004,6 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizeSubtitleForDisplay always breaks between simultaneous cues', () => {
|
|
||||||
// The blank line marks two distinct cues on screen at once. Flattening it would run a
|
|
||||||
// sign or a second speaker into the line beside it as one sentence.
|
|
||||||
const twoCues =
|
|
||||||
'\u6b21\u306f\u9b3c\u5b50\u6bcd\u795e\u524d\u3000\u9b3c\u5b50\u6bcd\u795e\u524d\n\n\u611b\u97f3\u3061\u3083\u3093\u3000\u3082\u3046\u5199\u771f\u4e0a\u3052\u3066\u308b';
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
normalizeSubtitleForDisplay(twoCues, false),
|
|
||||||
'\u6b21\u306f\u9b3c\u5b50\u6bcd\u795e\u524d \u9b3c\u5b50\u6bcd\u795e\u524d\n\u611b\u97f3\u3061\u3083\u3093 \u3082\u3046\u5199\u771f\u4e0a\u3052\u3066\u308b',
|
|
||||||
);
|
|
||||||
assert.equal(normalizeSubtitleForDisplay(twoCues, true), twoCues.replace('\n\n', '\n'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('normalizeSubtitleForDisplay preserves CRLF boundaries between simultaneous cues', () => {
|
|
||||||
assert.equal(normalizeSubtitleForDisplay('a\r\n\r\nb', false), 'a\nb');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('normalizeSubtitleForDisplay still flattens a wrap inside one cue', () => {
|
|
||||||
// A typesetter's \\N inside a single utterance is what preserveLineBreaks governs.
|
|
||||||
assert.equal(
|
|
||||||
normalizeSubtitleForDisplay(
|
|
||||||
'\u5e38\u4eba\u304c\u4f7f\u3048\u3070\\N\u305d\u306e\u5727\u5012\u7684\u306a\u529b\u306b',
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
'\u5e38\u4eba\u304c\u4f7f\u3048\u3070 \u305d\u306e\u5727\u5012\u7684\u306a\u529b\u306b',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('normalizeSubtitle leaves already-decoded text alone', () => {
|
test('normalizeSubtitle leaves already-decoded text alone', () => {
|
||||||
// Primary subtitle text is decoded from ASS once, upstream: by mpv for live lines and
|
// 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.
|
// by the cue parser for prefetched ones. A brace that survives that is literal text.
|
||||||
|
|||||||
@@ -50,21 +50,6 @@ export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks
|
|||||||
return normalizePlainSubtitleText(text, { trim, 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 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 =
|
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]+)$/;
|
/^(?:#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgba?|hsla?)\([^)]*\)|var\([^)]*\)|[a-zA-Z]+)$/;
|
||||||
@@ -429,13 +414,16 @@ function renderWithTokens(
|
|||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
if (sourceText) {
|
if (sourceText) {
|
||||||
const normalizedSource = normalizeSubtitleForDisplay(sourceText, preserveLineBreaks);
|
const normalizedSource = normalizeSubtitle(sourceText, true, !preserveLineBreaks);
|
||||||
const segments = alignTokensToSourceText(tokens, normalizedSource);
|
const segments = alignTokensToSourceText(tokens, normalizedSource);
|
||||||
|
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
if (segment.kind === 'text') {
|
if (segment.kind === 'text') {
|
||||||
// Normalization already resolved which breaks survive; every one left is real.
|
if (preserveLineBreaks) {
|
||||||
renderPlainTextPreserveLineBreaks(fragment, segment.text);
|
renderPlainTextPreserveLineBreaks(fragment, segment.text);
|
||||||
|
} else {
|
||||||
|
fragment.appendChild(document.createTextNode(segment.text));
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,7 +748,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = normalizeSubtitleForDisplay(text, ctx.state.preserveSubtitleLineBreaks);
|
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
|
||||||
const hasRenderableTokens =
|
const hasRenderableTokens =
|
||||||
shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && Boolean(tokens);
|
shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && Boolean(tokens);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AssVerticalBand, SubtitleCue } from '../core/services/subtitle-cue-parser';
|
import type { SubtitleCue } from '../core/services/subtitle-cue-parser';
|
||||||
|
|
||||||
export enum PartOfSpeech {
|
export enum PartOfSpeech {
|
||||||
noun = 'noun',
|
noun = 'noun',
|
||||||
@@ -187,7 +187,7 @@ export interface ResolvedTokenPos2ExclusionConfig {
|
|||||||
|
|
||||||
export type FrequencyDictionaryMode = 'single' | 'banded';
|
export type FrequencyDictionaryMode = 'single' | 'banded';
|
||||||
|
|
||||||
export type { AssVerticalBand, SubtitleCue };
|
export type { SubtitleCue };
|
||||||
|
|
||||||
export type SubtitleSidebarLayout = 'overlay' | 'embedded';
|
export type SubtitleSidebarLayout = 'overlay' | 'embedded';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user