mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 05:16:27 -07:00
feat(anki): add Senren scene-switching field grouping
- Support auto, manual, and disabled Senren duplicate-card merges - Group sentence, furigana, audio, picture, and miscInfo fields
This commit is contained in:
@@ -155,6 +155,7 @@ function createFieldGroupingMergeCollaborator(options?: {
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
fieldGroupingProvider: 'kiku' as const,
|
||||
}),
|
||||
getCurrentSubtitleText: () => options?.currentSubtitleText,
|
||||
resolveFieldName,
|
||||
|
||||
+40
-3
@@ -852,6 +852,19 @@ export class AnkiIntegration {
|
||||
};
|
||||
}
|
||||
|
||||
private getSenrenConfig(): {
|
||||
enabled: boolean;
|
||||
fieldGrouping?: 'auto' | 'manual' | 'disabled';
|
||||
deleteDuplicateInAuto?: boolean;
|
||||
} {
|
||||
const senren = this.config.isSenren;
|
||||
return {
|
||||
enabled: senren?.enabled === true,
|
||||
fieldGrouping: senren?.fieldGrouping,
|
||||
deleteDuplicateInAuto: senren?.deleteDuplicateInAuto,
|
||||
};
|
||||
}
|
||||
|
||||
private getEffectiveSentenceCardConfig(): {
|
||||
model?: string;
|
||||
sentenceField: string;
|
||||
@@ -860,10 +873,27 @@ export class AnkiIntegration {
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
kikuDeleteDuplicateInAuto: boolean;
|
||||
senrenEnabled: boolean;
|
||||
fieldGroupingProvider: 'kiku' | 'senren' | null;
|
||||
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
|
||||
fieldGroupingDeleteDuplicateInAuto: boolean;
|
||||
wordCardKind: WordCardKind;
|
||||
} {
|
||||
const lapis = this.getLapisConfig();
|
||||
const kiku = this.getKikuConfig();
|
||||
const senren = this.getSenrenConfig();
|
||||
|
||||
const kikuFieldGrouping = (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled';
|
||||
const senrenFieldGrouping = (senren.fieldGrouping || 'auto') as 'auto' | 'manual' | 'disabled';
|
||||
// Kiku and Senren are mutually exclusive; config resolution enforces it, and
|
||||
// Kiku wins here too in case a runtime patch re-enables both.
|
||||
const fieldGroupingProvider = kiku.enabled ? 'kiku' : senren.enabled ? 'senren' : null;
|
||||
const fieldGroupingMode =
|
||||
fieldGroupingProvider === 'kiku'
|
||||
? kikuFieldGrouping
|
||||
: fieldGroupingProvider === 'senren'
|
||||
? senrenFieldGrouping
|
||||
: 'disabled';
|
||||
|
||||
return {
|
||||
model: lapis.sentenceCardModel,
|
||||
@@ -871,8 +901,15 @@ export class AnkiIntegration {
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: lapis.enabled,
|
||||
kikuEnabled: kiku.enabled,
|
||||
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
||||
kikuFieldGrouping,
|
||||
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
||||
senrenEnabled: senren.enabled,
|
||||
fieldGroupingProvider,
|
||||
fieldGroupingMode,
|
||||
fieldGroupingDeleteDuplicateInAuto:
|
||||
fieldGroupingProvider === 'senren'
|
||||
? senren.deleteDuplicateInAuto !== false
|
||||
: kiku.deleteDuplicateInAuto !== false,
|
||||
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
|
||||
};
|
||||
}
|
||||
@@ -891,7 +928,7 @@ export class AnkiIntegration {
|
||||
|
||||
private async processNewCard(
|
||||
noteId: number,
|
||||
options?: { skipKikuFieldGrouping?: boolean },
|
||||
options?: { skipFieldGrouping?: boolean },
|
||||
): Promise<void> {
|
||||
await this.noteUpdateWorkflow.execute(noteId, options);
|
||||
}
|
||||
@@ -1521,7 +1558,7 @@ export class AnkiIntegration {
|
||||
trackedDuplicateNoteIdsBeforeCreate: Set<number>,
|
||||
): boolean {
|
||||
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
|
||||
if (!sentenceCardConfig.kikuEnabled || sentenceCardConfig.kikuFieldGrouping === 'disabled') {
|
||||
if (sentenceCardConfig.fieldGroupingMode === 'disabled') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -125,8 +125,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
@@ -211,8 +210,7 @@ test('manual clipboard word-card update uses configured fields with Lapis and Ki
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -279,8 +277,7 @@ test('audio-card action keeps Lapis and Kiku sentence fields', async () => {
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -343,8 +340,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
setCardTypeFields,
|
||||
});
|
||||
|
||||
@@ -120,8 +120,7 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
|
||||
@@ -70,8 +70,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
@@ -171,8 +170,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
@@ -272,8 +270,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
@@ -394,8 +391,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
@@ -499,8 +495,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
@@ -640,8 +635,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
@@ -741,8 +735,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'manual',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'manual',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
@@ -832,8 +825,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'manual',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'manual',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
|
||||
@@ -139,8 +139,7 @@ interface CardCreationDeps {
|
||||
audioField: string;
|
||||
lapisEnabled: boolean;
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
kikuDeleteDuplicateInAuto: boolean;
|
||||
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
|
||||
wordCardKind?: WordCardKind;
|
||||
};
|
||||
getFallbackDurationSeconds: () => number;
|
||||
@@ -697,8 +696,7 @@ export class CardCreationService {
|
||||
).trim();
|
||||
let duplicateNoteIds: number[] = [];
|
||||
if (
|
||||
sentenceCardConfig.kikuEnabled &&
|
||||
sentenceCardConfig.kikuFieldGrouping !== 'disabled' &&
|
||||
sentenceCardConfig.fieldGroupingMode !== 'disabled' &&
|
||||
pendingExpressionText &&
|
||||
this.deps.findDuplicateNoteIds
|
||||
) {
|
||||
|
||||
@@ -26,6 +26,7 @@ function createCollaborator(
|
||||
miscInfoValue?: string;
|
||||
};
|
||||
warnings?: Array<{ fieldName: string; reason: string; detail?: string }>;
|
||||
fieldGroupingProvider?: 'kiku' | 'senren' | null;
|
||||
} = {},
|
||||
) {
|
||||
const warnings = options.warnings ?? [];
|
||||
@@ -46,6 +47,8 @@ function createCollaborator(
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
fieldGroupingProvider:
|
||||
options.fieldGroupingProvider === undefined ? 'kiku' : options.fieldGroupingProvider,
|
||||
}),
|
||||
getCurrentSubtitleText: () => options.currentSubtitleText,
|
||||
resolveFieldName,
|
||||
@@ -251,7 +254,218 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen
|
||||
assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>');
|
||||
});
|
||||
|
||||
test('computeFieldGroupingMergedFields clears SentenceFurigana when either note lacks it', async () => {
|
||||
test('computeFieldGroupingMergedFields merges Senren notes into scene-switching markup', async () => {
|
||||
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
300,
|
||||
200,
|
||||
makeNote(300, {
|
||||
word: '語',
|
||||
sentence: '<span class="group">前<span class="highlight">語</span>後</span>',
|
||||
sentenceAudio: '[sound:original.opus]',
|
||||
picture: '<img src="original.webp">',
|
||||
miscInfo: '<span class="group">Show EP1 (0:01:00)</span>',
|
||||
}),
|
||||
makeNote(200, {
|
||||
word: '語',
|
||||
sentence: '<span class="group">次<span class="highlight">語</span>文</span>',
|
||||
sentenceAudio: '[sound:new.opus]',
|
||||
picture: '<img src="new.webp">',
|
||||
miscInfo: 'Show EP2 (0:02:00)',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
merged.sentence,
|
||||
'<span class="group">前<span class="highlight">語</span>後</span>' +
|
||||
'<span class="group2">次<span class="highlight">語</span>文</span>',
|
||||
);
|
||||
assert.equal(merged.sentenceAudio, '[sound:original.opus][sound:new.opus]');
|
||||
assert.equal(merged.picture, '<img src="original.webp"><img src="new.webp">');
|
||||
assert.equal(
|
||||
merged.miscInfo,
|
||||
'<span class="group">Show EP1 (0:01:00)</span><span class="group2">Show EP2 (0:02:00)</span>',
|
||||
);
|
||||
});
|
||||
|
||||
test('Senren merge warns for invalid source audio when kept audio is empty', async () => {
|
||||
const warnings: Array<{ fieldName: string; reason: string; detail?: string }> = [];
|
||||
const { collaborator } = createCollaborator({
|
||||
fieldGroupingProvider: 'senren',
|
||||
warnings,
|
||||
});
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
300,
|
||||
200,
|
||||
makeNote(300, { SentenceAudio: '' }),
|
||||
makeNote(200, { SentenceAudio: 'invalid audio' }),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(merged.SentenceAudio, 'invalid audio');
|
||||
assert.deepEqual(warnings, [
|
||||
{
|
||||
fieldName: 'SentenceAudio',
|
||||
reason: 'missing-sound-tag',
|
||||
detail: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('Senren merge wraps ungrouped legacy content and preserves numbered groups', async () => {
|
||||
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
300,
|
||||
200,
|
||||
makeNote(300, {
|
||||
sentence: 'plain legacy sentence',
|
||||
sentenceAudio: '[sound:a.opus][sound:b.opus]',
|
||||
miscInfo: '<span class="group2">pinned</span> stray text',
|
||||
}),
|
||||
makeNote(200, {
|
||||
sentence: '<span class="group">new sentence</span>',
|
||||
sentenceAudio: '[sound:c.opus]',
|
||||
miscInfo: '<span class="group">new misc</span>',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
merged.sentence,
|
||||
'<span class="group">plain legacy sentence</span><span class="group3">new sentence</span>',
|
||||
);
|
||||
assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus]');
|
||||
assert.equal(
|
||||
merged.miscInfo,
|
||||
'<span class="group2">pinned</span><span class="group">stray text</span>' +
|
||||
'<span class="group3">new misc</span>',
|
||||
);
|
||||
});
|
||||
|
||||
test('Senren merge rebases numbered groups from an appended source note', async () => {
|
||||
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
300,
|
||||
200,
|
||||
makeNote(300, {
|
||||
sentenceAudio: '[sound:keep-a.opus][sound:keep-b.opus]',
|
||||
miscInfo: '<span class="group">keep one</span><span class="group">keep two</span>',
|
||||
}),
|
||||
makeNote(200, {
|
||||
sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]',
|
||||
miscInfo: '<span class="group2">source two</span>',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
merged.sentenceAudio,
|
||||
'[sound:keep-a.opus][sound:keep-b.opus][sound:source-a.opus][sound:source-b.opus]',
|
||||
);
|
||||
assert.equal(
|
||||
merged.miscInfo,
|
||||
'<span class="group">keep one</span><span class="group">keep two</span>' +
|
||||
'<span class="group4">source two</span>',
|
||||
);
|
||||
});
|
||||
|
||||
test('Senren merge rebases plain source groups after empty and sparse kept fields', async () => {
|
||||
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
300,
|
||||
200,
|
||||
makeNote(300, {
|
||||
sentenceAudio: '[sound:keep-a.opus][sound:keep-b.opus]',
|
||||
sentence: '',
|
||||
miscInfo: '<span class="group">keep first</span>',
|
||||
}),
|
||||
makeNote(200, {
|
||||
sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]',
|
||||
sentence: '<span class="group">source first</span>',
|
||||
miscInfo: '<span class="group">source first</span><span class="group2">source second</span>',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(merged.sentence, '<span class="group3">source first</span>');
|
||||
assert.equal(
|
||||
merged.miscInfo,
|
||||
'<span class="group">keep first</span><span class="group3">source first</span>' +
|
||||
'<span class="group4">source second</span>',
|
||||
);
|
||||
});
|
||||
|
||||
test('Senren merge keeps ungrouped text in place around an existing group span', async () => {
|
||||
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
300,
|
||||
200,
|
||||
makeNote(300, {
|
||||
miscInfo: 'leading<span class="group">middle</span>trailing',
|
||||
sentenceAudio: '[sound:a.opus][sound:b.opus][sound:c.opus]',
|
||||
}),
|
||||
makeNote(200, {
|
||||
miscInfo: '<span class="group">appended</span>',
|
||||
sentenceAudio: '[sound:d.opus]',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
// Order must follow the source field, and the two ungrouped runs must stay separate.
|
||||
assert.equal(
|
||||
merged.miscInfo,
|
||||
'<span class="group">leading</span><span class="group">middle</span>' +
|
||||
'<span class="group">trailing</span><span class="group4">appended</span>',
|
||||
);
|
||||
assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus][sound:d.opus]');
|
||||
});
|
||||
|
||||
test('Senren merge closes unclosed group spans so later scenes stay siblings', async () => {
|
||||
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
300,
|
||||
200,
|
||||
makeNote(300, { miscInfo: '<span class="group">a<span class="highlight">b' }),
|
||||
makeNote(200, { miscInfo: '<span class="group">next</span>' }),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
merged.miscInfo,
|
||||
'<span class="group">a<span class="highlight">b</span></span><span class="group">next</span>',
|
||||
);
|
||||
const openTags = merged.miscInfo!.match(/<span\b/g)?.length ?? 0;
|
||||
const closeTags = merged.miscInfo!.match(/<\/span>/g)?.length ?? 0;
|
||||
assert.equal(openTags, closeTags);
|
||||
});
|
||||
|
||||
test('Senren merge closes unclosed trailing markup before appending later scenes', async () => {
|
||||
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
300,
|
||||
200,
|
||||
makeNote(300, { miscInfo: 'leading<span class="highlight">tail' }),
|
||||
makeNote(200, { miscInfo: '<span class="group">next</span>' }),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
merged.miscInfo,
|
||||
'<span class="group">leading<span class="highlight">tail</span></span>' +
|
||||
'<span class="group">next</span>',
|
||||
);
|
||||
});
|
||||
|
||||
test('Kiku merge clears SentenceFurigana when either note lacks it', async () => {
|
||||
const { collaborator } = createCollaborator();
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
@@ -268,3 +482,21 @@ test('computeFieldGroupingMergedFields clears SentenceFurigana when either note
|
||||
|
||||
assert.equal(merged.SentenceFurigana, '');
|
||||
});
|
||||
|
||||
test('Senren merge keeps duplicate SentenceFurigana when the kept field is empty', async () => {
|
||||
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
300,
|
||||
200,
|
||||
makeNote(300, {
|
||||
SentenceFurigana: '',
|
||||
}),
|
||||
makeNote(200, {
|
||||
SentenceFurigana: 'duplicate furigana',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(merged.SentenceFurigana, '<span class="group">duplicate furigana</span>');
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ interface FieldGroupingMergeDeps {
|
||||
getEffectiveSentenceCardConfig: () => {
|
||||
sentenceField: string;
|
||||
audioField: string;
|
||||
fieldGroupingProvider: 'kiku' | 'senren' | null;
|
||||
};
|
||||
getCurrentSubtitleText: () => string | undefined;
|
||||
resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null;
|
||||
@@ -78,6 +79,13 @@ export class FieldGroupingMergeCollaborator {
|
||||
const configuredWordField = getConfiguredWordFieldName(config);
|
||||
const groupableFields = this.getGroupableFieldNames();
|
||||
const keepFieldNames = Object.keys(keepNoteInfo.fields);
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
const senrenSourceSceneOffset =
|
||||
sentenceCardConfig.fieldGroupingProvider === 'senren'
|
||||
? this.countSenrenAudioScenes(
|
||||
this.getResolvedFieldValue(keepNoteInfo, sentenceCardConfig.audioField),
|
||||
)
|
||||
: 0;
|
||||
const sourceFields: Record<string, string> = {};
|
||||
const resolvedKeepFieldByPreferred = new Map<string, string>();
|
||||
for (const preferredFieldName of groupableFields) {
|
||||
@@ -154,14 +162,18 @@ export class FieldGroupingMergeCollaborator {
|
||||
if (!existingValue.trim() && !newValue.trim()) continue;
|
||||
|
||||
if (keepFieldNormalized === 'sentencefurigana') {
|
||||
const hasBothValues = existingValue.trim().length > 0 && newValue.trim().length > 0;
|
||||
const usesSenrenGrouping =
|
||||
this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren';
|
||||
mergedFields[keepFieldName] =
|
||||
existingValue.trim() && newValue.trim()
|
||||
hasBothValues || usesSenrenGrouping
|
||||
? this.applyFieldGrouping(
|
||||
existingValue,
|
||||
newValue,
|
||||
keepNoteId,
|
||||
deleteNoteId,
|
||||
keepFieldName,
|
||||
senrenSourceSceneOffset,
|
||||
)
|
||||
: '';
|
||||
continue;
|
||||
@@ -174,6 +186,7 @@ export class FieldGroupingMergeCollaborator {
|
||||
keepNoteId,
|
||||
deleteNoteId,
|
||||
keepFieldName,
|
||||
senrenSourceSceneOffset,
|
||||
);
|
||||
} else if (existingValue.trim() && newValue.trim()) {
|
||||
mergedFields[keepFieldName] = this.applyFieldGrouping(
|
||||
@@ -182,6 +195,7 @@ export class FieldGroupingMergeCollaborator {
|
||||
keepNoteId,
|
||||
deleteNoteId,
|
||||
keepFieldName,
|
||||
senrenSourceSceneOffset,
|
||||
);
|
||||
} else {
|
||||
if (!newValue.trim()) continue;
|
||||
@@ -342,13 +356,152 @@ export class FieldGroupingMergeCollaborator {
|
||||
return [...entries].sort((a, b) => b.groupId - a.groupId);
|
||||
}
|
||||
|
||||
private isSentenceAudioField(fieldName: string): boolean {
|
||||
const normalized = fieldName.toLowerCase();
|
||||
const audioField = (
|
||||
this.deps.getEffectiveSentenceCardConfig().audioField || 'sentenceaudio'
|
||||
).toLowerCase();
|
||||
return normalized === 'sentenceaudio' || normalized === audioField;
|
||||
}
|
||||
|
||||
private isSenrenGroupOpenTag(openTag: string): boolean {
|
||||
const classMatch =
|
||||
openTag.match(/class\s*=\s*"([^"]*)"/i) || openTag.match(/class\s*=\s*'([^']*)'/i);
|
||||
if (!classMatch) return false;
|
||||
// Senren's templates match class tokens case-sensitively (/^group\d*$/).
|
||||
return classMatch[1]!.split(/\s+/).some((token) => /^group\d*$/.test(token));
|
||||
}
|
||||
|
||||
private countSenrenAudioScenes(value: string): number {
|
||||
const soundEntries = value.match(/\[sound:[^\]]+\]/g)?.length ?? 0;
|
||||
if (soundEntries > 0) return soundEntries;
|
||||
return this.parseSenrenSceneEntries(value).length;
|
||||
}
|
||||
|
||||
private rebaseSenrenGroup(entry: string, sceneOffset: number, sourceEntryIndex: number): string {
|
||||
if (sceneOffset <= 0) return entry;
|
||||
|
||||
return entry.replace(
|
||||
/^(\s*<span\b[^>]*?\bclass\s*=\s*)(["'])([^"']*)\2/i,
|
||||
(_match: string, prefix: string, quote: string, rawClasses: string) => {
|
||||
const classes = rawClasses
|
||||
.split(/(\s+)/)
|
||||
.map((classToken) => {
|
||||
if (classToken === 'group') {
|
||||
return `group${sceneOffset + sourceEntryIndex + 1}`;
|
||||
}
|
||||
const groupMatch = classToken.match(/^group(\d+)$/);
|
||||
if (!groupMatch) return classToken;
|
||||
const targetScene = Number(groupMatch[1]);
|
||||
if (!Number.isSafeInteger(targetScene) || targetScene <= 0) return classToken;
|
||||
return `group${targetScene + sceneOffset}`;
|
||||
})
|
||||
.join('');
|
||||
return `${prefix}${quote}${classes}${quote}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a Senren field into ordered scene entries. Top-level
|
||||
* `<span class="group">`/`"groupN"` spans are kept verbatim (nested markup like
|
||||
* `<span class="highlight">` included); ungrouped runs are wrapped in a group
|
||||
* span at their original position, because Senren discards anything outside a
|
||||
* group span once scene switching activates.
|
||||
*/
|
||||
private parseSenrenSceneEntries(value: string): string[] {
|
||||
const tokenRegex = /<span\b[^>]*>|<\/span>/gi;
|
||||
const entries: string[] = [];
|
||||
const pushUngrouped = (raw: string): void => {
|
||||
const text = raw.replace(/<br\s*\/?>/gi, ' ').trim();
|
||||
if (text) entries.push(`<span class="group">${text}</span>`);
|
||||
};
|
||||
let cursor = 0;
|
||||
let depth = 0;
|
||||
let entryStart = -1;
|
||||
let match;
|
||||
while ((match = tokenRegex.exec(value)) !== null) {
|
||||
const token = match[0]!;
|
||||
if (token[1] !== '/') {
|
||||
if (depth === 0 && this.isSenrenGroupOpenTag(token)) {
|
||||
pushUngrouped(value.slice(cursor, match.index));
|
||||
entryStart = match.index;
|
||||
cursor = match.index;
|
||||
}
|
||||
depth += 1;
|
||||
} else {
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (depth === 0 && entryStart !== -1) {
|
||||
const end = match.index + token.length;
|
||||
entries.push(value.slice(entryStart, end));
|
||||
entryStart = -1;
|
||||
cursor = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entryStart !== -1) {
|
||||
// Unclosed group span: close every span still open (the group and any nested
|
||||
// markup) so the following scenes are siblings rather than nested inside it.
|
||||
entries.push(`${value.slice(entryStart)}${'</span>'.repeat(depth)}`);
|
||||
} else {
|
||||
pushUngrouped(`${value.slice(cursor)}${'</span>'.repeat(depth)}`);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two notes' field values in Senren's scene-switching format. Scenes are
|
||||
* appended in order (existing first, never resorted) so indices stay aligned
|
||||
* across sentence/picture/miscInfo with the sentenceAudio entries, which alone
|
||||
* drive Senren's scene count.
|
||||
*/
|
||||
private applySenrenFieldGrouping(
|
||||
existingValue: string,
|
||||
newValue: string,
|
||||
fieldName: string,
|
||||
sourceSceneOffset: number,
|
||||
): string {
|
||||
if (this.isPictureField(fieldName)) {
|
||||
const tags = [...this.extractImageTags(existingValue), ...this.extractImageTags(newValue)];
|
||||
if (tags.length === 0) return existingValue || newValue;
|
||||
return tags.join('');
|
||||
}
|
||||
|
||||
if (this.isSentenceAudioField(fieldName)) {
|
||||
const existing = existingValue.trim();
|
||||
const added = newValue.trim();
|
||||
if (added && !/\[sound:[^\]]+\]/.test(added)) {
|
||||
this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag');
|
||||
}
|
||||
if (!existing || !added) return existing || added;
|
||||
return existing + added;
|
||||
}
|
||||
|
||||
const sourceEntries = this.parseSenrenSceneEntries(newValue).map((entry, sourceEntryIndex) =>
|
||||
this.rebaseSenrenGroup(entry, sourceSceneOffset, sourceEntryIndex),
|
||||
);
|
||||
const merged = [...this.parseSenrenSceneEntries(existingValue), ...sourceEntries];
|
||||
if (merged.length === 0) return existingValue || newValue;
|
||||
return merged.join('');
|
||||
}
|
||||
|
||||
private applyFieldGrouping(
|
||||
existingValue: string,
|
||||
newValue: string,
|
||||
keepGroupId: number,
|
||||
sourceGroupId: number,
|
||||
fieldName: string,
|
||||
senrenSourceSceneOffset: number,
|
||||
): string {
|
||||
if (this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren') {
|
||||
return this.applySenrenFieldGrouping(
|
||||
existingValue,
|
||||
newValue,
|
||||
fieldName,
|
||||
senrenSourceSceneOffset,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.shouldUseStrictSpanGrouping(fieldName)) {
|
||||
if (this.isPictureField(fieldName)) {
|
||||
const keepEntries = this.parsePictureEntries(existingValue, keepGroupId);
|
||||
|
||||
@@ -71,7 +71,7 @@ function createWorkflowHarness() {
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
kikuDeleteDuplicateInAuto: true,
|
||||
fieldGroupingDeleteDuplicateInAuto: true,
|
||||
}),
|
||||
getCurrentSubtitleText: () => 'subtitle-text',
|
||||
getFieldGroupingCallback: (): FieldGroupingCallback | null => {
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface FieldGroupingWorkflowDeps {
|
||||
getEffectiveSentenceCardConfig: () => {
|
||||
sentenceField: string;
|
||||
audioField: string;
|
||||
kikuDeleteDuplicateInAuto: boolean;
|
||||
fieldGroupingDeleteDuplicateInAuto: boolean;
|
||||
};
|
||||
getCurrentSubtitleText: () => string | undefined;
|
||||
getFieldGroupingCallback:
|
||||
@@ -75,7 +75,7 @@ export class FieldGroupingWorkflow {
|
||||
originalNoteId,
|
||||
newNoteId,
|
||||
this.getExpression(newNoteInfo),
|
||||
sentenceCardConfig.kikuDeleteDuplicateInAuto,
|
||||
sentenceCardConfig.fieldGroupingDeleteDuplicateInAuto,
|
||||
);
|
||||
} catch (error) {
|
||||
this.deps.logError('Field grouping auto merge failed:', (error as Error).message);
|
||||
|
||||
@@ -21,14 +21,14 @@ function createHarness(
|
||||
manualHandled?: boolean;
|
||||
expression?: string | null;
|
||||
currentSentenceImageField?: string | undefined;
|
||||
onProcessNewCard?: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => void;
|
||||
onProcessNewCard?: (noteId: number, options?: { skipFieldGrouping?: boolean }) => void;
|
||||
} = {},
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
const findNotesQueries: Array<{ query: string; maxRetries?: number }> = [];
|
||||
const noteInfoRequests: number[][] = [];
|
||||
const duplicateRequests: Array<{ expression: string; excludeNoteId: number }> = [];
|
||||
const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = [];
|
||||
const processCalls: Array<{ noteId: number; options?: { skipFieldGrouping?: boolean } }> = [];
|
||||
const autoCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = [];
|
||||
const manualCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = [];
|
||||
|
||||
@@ -46,9 +46,8 @@ function createHarness(
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: options.kikuEnabled ?? true,
|
||||
kikuFieldGrouping: options.kikuFieldGrouping ?? 'auto',
|
||||
kikuDeleteDuplicateInAuto: true,
|
||||
fieldGroupingProvider: (options.kikuEnabled ?? true) ? ('kiku' as const) : null,
|
||||
fieldGroupingMode: options.kikuFieldGrouping ?? 'auto',
|
||||
}),
|
||||
isUpdateInProgress: () => false,
|
||||
getDeck: options.deck ? () => options.deck : undefined,
|
||||
@@ -134,7 +133,7 @@ test('triggerFieldGroupingForLastAddedCard stops when kiku mode is disabled', as
|
||||
|
||||
await harness.service.triggerFieldGroupingForLastAddedCard();
|
||||
|
||||
assert.deepEqual(harness.calls, ['osd:Kiku mode is not enabled']);
|
||||
assert.deepEqual(harness.calls, ['osd:Field grouping requires Kiku or Senren mode']);
|
||||
assert.equal(harness.findNotesQueries.length, 0);
|
||||
});
|
||||
|
||||
@@ -143,7 +142,7 @@ test('triggerFieldGroupingForLastAddedCard stops when field grouping is disabled
|
||||
|
||||
await harness.service.triggerFieldGroupingForLastAddedCard();
|
||||
|
||||
assert.deepEqual(harness.calls, ['osd:Kiku field grouping is disabled']);
|
||||
assert.deepEqual(harness.calls, ['osd:Field grouping is disabled']);
|
||||
assert.equal(harness.findNotesQueries.length, 0);
|
||||
});
|
||||
|
||||
@@ -155,9 +154,8 @@ test('triggerFieldGroupingForLastAddedCard stops when an update is already in pr
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'auto',
|
||||
kikuDeleteDuplicateInAuto: true,
|
||||
fieldGroupingProvider: 'kiku' as const,
|
||||
fieldGroupingMode: 'auto' as const,
|
||||
}),
|
||||
isUpdateInProgress: () => true,
|
||||
withUpdateProgress: async () => {
|
||||
@@ -266,7 +264,7 @@ test('triggerFieldGroupingForLastAddedCard prefers tracked duplicate note ids be
|
||||
});
|
||||
|
||||
test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fields are missing', async () => {
|
||||
const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = [];
|
||||
const processCalls: Array<{ noteId: number; options?: { skipFieldGrouping?: boolean } }> = [];
|
||||
const harness = createHarness({
|
||||
noteIds: [11],
|
||||
notesInfo: [
|
||||
@@ -298,7 +296,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi
|
||||
|
||||
await harness.service.triggerFieldGroupingForLastAddedCard();
|
||||
|
||||
assert.deepEqual(processCalls, [{ noteId: 11, options: { skipKikuFieldGrouping: true } }]);
|
||||
assert.deepEqual(processCalls, [{ noteId: 11, options: { skipFieldGrouping: true } }]);
|
||||
assert.deepEqual(harness.manualCalls, []);
|
||||
});
|
||||
|
||||
@@ -352,9 +350,8 @@ test('buildFieldGroupingPreview returns merged compact and full previews', async
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'auto',
|
||||
kikuDeleteDuplicateInAuto: true,
|
||||
fieldGroupingProvider: 'kiku' as const,
|
||||
fieldGroupingMode: 'auto' as const,
|
||||
}),
|
||||
isUpdateInProgress: () => false,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
@@ -417,9 +414,8 @@ test('buildFieldGroupingPreview reports missing notes cleanly', async () => {
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'auto',
|
||||
kikuDeleteDuplicateInAuto: true,
|
||||
fieldGroupingProvider: 'kiku' as const,
|
||||
fieldGroupingMode: 'auto' as const,
|
||||
}),
|
||||
isUpdateInProgress: () => false,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
|
||||
@@ -20,9 +20,8 @@ interface FieldGroupingDeps {
|
||||
sentenceField: string;
|
||||
audioField: string;
|
||||
lapisEnabled: boolean;
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
kikuDeleteDuplicateInAuto: boolean;
|
||||
fieldGroupingProvider: 'kiku' | 'senren' | null;
|
||||
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
|
||||
};
|
||||
isUpdateInProgress: () => boolean;
|
||||
getDeck?: () => string | undefined;
|
||||
@@ -46,7 +45,7 @@ interface FieldGroupingDeps {
|
||||
noteInfo: FieldGroupingNoteInfo,
|
||||
configuredFieldNames: (string | undefined)[],
|
||||
) => boolean;
|
||||
processNewCard: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => Promise<void>;
|
||||
processNewCard: (noteId: number, options?: { skipFieldGrouping?: boolean }) => Promise<void>;
|
||||
getSentenceCardImageFieldName: () => string | undefined;
|
||||
resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null;
|
||||
computeFieldGroupingMergedFields: (
|
||||
@@ -76,12 +75,12 @@ export class FieldGroupingService {
|
||||
|
||||
async triggerFieldGroupingForLastAddedCard(): Promise<void> {
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
if (!sentenceCardConfig.kikuEnabled) {
|
||||
this.deps.showOsdNotification('Kiku mode is not enabled');
|
||||
if (sentenceCardConfig.fieldGroupingProvider === null) {
|
||||
this.deps.showOsdNotification('Field grouping requires Kiku or Senren mode');
|
||||
return;
|
||||
}
|
||||
if (sentenceCardConfig.kikuFieldGrouping === 'disabled') {
|
||||
this.deps.showOsdNotification('Kiku field grouping is disabled');
|
||||
if (sentenceCardConfig.fieldGroupingMode === 'disabled') {
|
||||
this.deps.showOsdNotification('Field grouping is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -134,7 +133,7 @@ export class FieldGroupingService {
|
||||
])
|
||||
) {
|
||||
await this.deps.processNewCard(noteId, {
|
||||
skipKikuFieldGrouping: true,
|
||||
skipFieldGrouping: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -147,7 +146,7 @@ export class FieldGroupingService {
|
||||
|
||||
const noteInfo = refreshedInfo[0]!;
|
||||
|
||||
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
|
||||
if (sentenceCardConfig.fieldGroupingMode === 'auto') {
|
||||
await this.deps.handleFieldGroupingAuto(
|
||||
duplicateNoteId,
|
||||
noteId,
|
||||
|
||||
@@ -59,7 +59,7 @@ function createWorkflowHarness() {
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled' as const,
|
||||
fieldGroupingMode: 'disabled' as const,
|
||||
}),
|
||||
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
|
||||
removeKnownWordNote: (_noteId: number) => undefined,
|
||||
@@ -138,7 +138,7 @@ test('NoteUpdateWorkflow uses configured fields for word-card enrichment with La
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
fieldGroupingMode: 'disabled',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
@@ -195,7 +195,7 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'manual',
|
||||
fieldGroupingMode: 'manual',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
@@ -228,7 +228,7 @@ test('NoteUpdateWorkflow marks the configured word card kind instead of word-and
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'manual',
|
||||
fieldGroupingMode: 'manual',
|
||||
wordCardKind: 'click',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
@@ -264,7 +264,7 @@ test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'manual',
|
||||
fieldGroupingMode: 'manual',
|
||||
wordCardKind: 'none',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
@@ -319,7 +319,7 @@ test('NoteUpdateWorkflow preserves explicit sentence card type during sentence e
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
fieldGroupingMode: 'disabled',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
@@ -362,7 +362,7 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async (
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'auto',
|
||||
fieldGroupingMode: 'auto',
|
||||
});
|
||||
harness.deps.findDuplicateNote = async () => 99;
|
||||
harness.deps.client.notesInfo = async () => {
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface NoteUpdateWorkflowDeps {
|
||||
sentenceField: string;
|
||||
lapisEnabled: boolean;
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
|
||||
wordCardKind?: WordCardKind;
|
||||
};
|
||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||
@@ -170,7 +170,7 @@ export class NoteUpdateWorkflow {
|
||||
return null;
|
||||
}
|
||||
|
||||
async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise<void> {
|
||||
async execute(noteId: number, options?: { skipFieldGrouping?: boolean }): Promise<void> {
|
||||
this.deps.beginUpdateProgress('Updating card');
|
||||
try {
|
||||
const notesInfoResult = await this.deps.client.notesInfo([noteId]);
|
||||
@@ -196,9 +196,7 @@ export class NoteUpdateWorkflow {
|
||||
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
const shouldRunFieldGrouping =
|
||||
!options?.skipKikuFieldGrouping &&
|
||||
sentenceCardConfig.kikuEnabled &&
|
||||
sentenceCardConfig.kikuFieldGrouping !== 'disabled';
|
||||
!options?.skipFieldGrouping && sentenceCardConfig.fieldGroupingMode !== 'disabled';
|
||||
let duplicateNoteId: number | null = null;
|
||||
if (shouldRunFieldGrouping && hasExpressionText) {
|
||||
duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo);
|
||||
@@ -401,7 +399,7 @@ export class NoteUpdateWorkflow {
|
||||
noteInfoForGrouping = refreshedInfo[0]!;
|
||||
}
|
||||
|
||||
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
|
||||
if (sentenceCardConfig.fieldGroupingMode === 'auto') {
|
||||
await this.deps.handleFieldGroupingAuto(
|
||||
duplicateNoteId,
|
||||
noteId,
|
||||
@@ -410,7 +408,7 @@ export class NoteUpdateWorkflow {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
|
||||
if (sentenceCardConfig.fieldGroupingMode === 'manual') {
|
||||
await this.deps.handleFieldGroupingManual(
|
||||
duplicateNoteId,
|
||||
noteId,
|
||||
|
||||
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
||||
...(config.isKiku ?? {}),
|
||||
},
|
||||
isSenren: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.isSenren,
|
||||
...(config.isSenren ?? {}),
|
||||
},
|
||||
lapisKiku: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
|
||||
...(config.lapisKiku ?? {}),
|
||||
@@ -209,6 +213,10 @@ export class AnkiIntegrationRuntime {
|
||||
patch.isKiku !== undefined
|
||||
? { ...this.config.isKiku, ...patch.isKiku }
|
||||
: this.config.isKiku,
|
||||
isSenren:
|
||||
patch.isSenren !== undefined
|
||||
? { ...this.config.isSenren, ...patch.isSenren }
|
||||
: this.config.isSenren,
|
||||
lapisKiku:
|
||||
patch.lapisKiku !== undefined
|
||||
? { ...this.config.lapisKiku, ...patch.lapisKiku }
|
||||
|
||||
@@ -2189,6 +2189,7 @@ test('runtime options registry is centralized', () => {
|
||||
'subtitle.annotation.frequency',
|
||||
'anki.nPlusOneMatchMode',
|
||||
'anki.kikuFieldGrouping',
|
||||
'anki.senrenFieldGrouping',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2776,6 +2777,47 @@ test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('forces Senren off when Kiku is also enabled and validates Senren fieldGrouping', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"isKiku": { "enabled": true },
|
||||
"isSenren": { "enabled": true }
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
assert.equal(service.getConfig().ankiConnect.isKiku.enabled, true);
|
||||
assert.equal(service.getConfig().ankiConnect.isSenren.enabled, false);
|
||||
assert.ok(
|
||||
service.getWarnings().some((warning) => warning.path === 'ankiConnect.isSenren.enabled'),
|
||||
);
|
||||
|
||||
const senrenOnlyDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(senrenOnlyDir, 'config.jsonc'),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"isSenren": { "enabled": true, "fieldGrouping": "sometimes" }
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const senrenOnlyService = new ConfigService(senrenOnlyDir);
|
||||
assert.equal(senrenOnlyService.getConfig().ankiConnect.isSenren.enabled, true);
|
||||
assert.equal(senrenOnlyService.getConfig().ankiConnect.isSenren.fieldGrouping, 'auto');
|
||||
assert.ok(
|
||||
senrenOnlyService
|
||||
.getWarnings()
|
||||
.some((warning) => warning.path === 'ankiConnect.isSenren.fieldGrouping'),
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts valid ankiConnect knownWords deck object', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -92,6 +92,11 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
fieldGrouping: 'disabled',
|
||||
deleteDuplicateInAuto: true,
|
||||
},
|
||||
isSenren: {
|
||||
enabled: false,
|
||||
fieldGrouping: 'auto',
|
||||
deleteDuplicateInAuto: true,
|
||||
},
|
||||
lapisKiku: {
|
||||
wordCardKind: 'word-and-sentence',
|
||||
},
|
||||
|
||||
@@ -371,6 +371,28 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'When Kiku field grouping is "auto", delete the duplicate source card after grouping completes.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isSenren.fieldGrouping',
|
||||
kind: 'enum',
|
||||
enumValues: ['auto', 'manual', 'disabled'],
|
||||
defaultValue: defaultConfig.ankiConnect.isSenren.fieldGrouping,
|
||||
description: 'Senren duplicate-card field grouping mode (scene switching).',
|
||||
runtime: runtimeOptionById.get('anki.senrenFieldGrouping'),
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isSenren.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.isSenren.enabled,
|
||||
description:
|
||||
'Enable Senren-specific duplicate handling (scene-switching field grouping, including miscInfo grouping). Mutually exclusive with isKiku.enabled.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isSenren.deleteDuplicateInAuto',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.isSenren.deleteDuplicateInAuto,
|
||||
description:
|
||||
'When Senren field grouping is "auto", delete the duplicate source card after grouping completes.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isLapis.enabled',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -138,5 +138,22 @@ export function buildRuntimeOptionRegistry(
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'anki.senrenFieldGrouping',
|
||||
path: 'ankiConnect.isSenren.fieldGrouping',
|
||||
label: 'Senren Field Grouping',
|
||||
scope: 'ankiConnect',
|
||||
valueType: 'enum',
|
||||
allowedValues: ['auto', 'manual', 'disabled'],
|
||||
defaultValue: 'auto',
|
||||
requiresRestart: false,
|
||||
formatValueForOsd: (value) => String(value),
|
||||
toAnkiPatch: (value) => ({
|
||||
isSenren: {
|
||||
fieldGrouping:
|
||||
value === 'auto' || value === 'manual' || value === 'disabled' ? value : 'auto',
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
title: 'AnkiConnect Integration',
|
||||
description: ['Automatic Anki updates and media generation options.'],
|
||||
notes: [
|
||||
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
|
||||
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
|
||||
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||
'Most other AnkiConnect settings still require restart.',
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ResolveContext } from './context';
|
||||
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
|
||||
import { applyAnkiKikuResolution } from './anki-connect/kiku';
|
||||
import { applyAnkiSenrenResolution } from './anki-connect/senren';
|
||||
import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku';
|
||||
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
||||
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
|
||||
@@ -23,5 +24,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
|
||||
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
|
||||
applyAnkiKikuResolution(context);
|
||||
applyAnkiSenrenResolution(context);
|
||||
applyAnkiLapisKikuResolution(context, ankiConnect);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,12 @@ export function initializeAnkiConnectResolution(
|
||||
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
||||
: {}),
|
||||
},
|
||||
isSenren: {
|
||||
...context.resolved.ankiConnect.isSenren,
|
||||
...(isObject(ankiConnect.isSenren)
|
||||
? (ankiConnect.isSenren as (typeof context.resolved)['ankiConnect']['isSenren'])
|
||||
: {}),
|
||||
},
|
||||
lapisKiku: {
|
||||
...context.resolved.ankiConnect.lapisKiku,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { DEFAULT_CONFIG } from '../../definitions';
|
||||
import type { ResolveContext } from '../context';
|
||||
|
||||
export function applyAnkiSenrenResolution(context: ResolveContext): void {
|
||||
if (
|
||||
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'auto' &&
|
||||
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'manual' &&
|
||||
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'disabled'
|
||||
) {
|
||||
context.warn(
|
||||
'ankiConnect.isSenren.fieldGrouping',
|
||||
context.resolved.ankiConnect.isSenren.fieldGrouping,
|
||||
DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping,
|
||||
'Expected auto, manual, or disabled.',
|
||||
);
|
||||
context.resolved.ankiConnect.isSenren.fieldGrouping =
|
||||
DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping;
|
||||
}
|
||||
|
||||
// Kiku and Senren field grouping write incompatible markup into the same note
|
||||
// fields, so only one may be active; Kiku wins to preserve pre-existing setups.
|
||||
if (
|
||||
context.resolved.ankiConnect.isSenren.enabled === true &&
|
||||
context.resolved.ankiConnect.isKiku.enabled === true
|
||||
) {
|
||||
context.warn(
|
||||
'ankiConnect.isSenren.enabled',
|
||||
true,
|
||||
false,
|
||||
'Kiku and Senren are mutually exclusive; disable isKiku.enabled to use Senren field grouping.',
|
||||
);
|
||||
context.resolved.ankiConnect.isSenren.enabled = false;
|
||||
}
|
||||
}
|
||||
@@ -298,10 +298,12 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
|
||||
];
|
||||
assert.equal(miningSections[0], 'AnkiConnect');
|
||||
|
||||
const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features');
|
||||
const kikuLapis = fields.filter(
|
||||
(candidate) => candidate.section === 'Kiku/Lapis/Senren Features',
|
||||
);
|
||||
assert.deepEqual(
|
||||
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath),
|
||||
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'],
|
||||
kikuLapis.slice(0, 3).map((candidate) => candidate.configPath),
|
||||
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled', 'ankiConnect.isSenren.enabled'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -367,6 +369,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
|
||||
'ankiConnect.fields.miscInfo',
|
||||
'ankiConnect.isLapis.sentenceCardModel',
|
||||
'ankiConnect.isKiku.fieldGrouping',
|
||||
'ankiConnect.isSenren.fieldGrouping',
|
||||
]) {
|
||||
assert.equal(field(path).restartBehavior, 'hot-reload', path);
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'AnkiConnect',
|
||||
'Note Fields',
|
||||
'Media Capture',
|
||||
'Kiku/Lapis Features',
|
||||
'Kiku/Lapis/Senren Features',
|
||||
'Anki AI',
|
||||
'AnkiConnect Proxy',
|
||||
'Jimaku',
|
||||
@@ -163,6 +163,7 @@ const PATH_ORDER = new Map<string, number>(
|
||||
'ankiConnect.proxy.enabled',
|
||||
'ankiConnect.isLapis.enabled',
|
||||
'ankiConnect.isKiku.enabled',
|
||||
'ankiConnect.isSenren.enabled',
|
||||
'subtitleStyle.knownWordColor',
|
||||
'ankiConnect.knownWords.matureThresholdDays',
|
||||
'subtitleStyle.knownWordMaturityColors.new',
|
||||
@@ -221,6 +222,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
||||
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
||||
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
||||
'ankiConnect.isSenren.enabled': 'Enable Senren Features',
|
||||
'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type',
|
||||
'stats.toggleKey': 'Toggle Stats Overlay',
|
||||
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
||||
@@ -252,7 +254,9 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||
'ankiConnect.pollingRate':
|
||||
'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.',
|
||||
'ankiConnect.isKiku.enabled':
|
||||
'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping.',
|
||||
'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping. Mutually exclusive with Senren.',
|
||||
'ankiConnect.isSenren.enabled':
|
||||
'Enable Senren-specific duplicate handling: field grouping merges duplicates into Senren scene-switching markup (including miscInfo grouping). Mutually exclusive with Kiku; only one can be enabled at a time.',
|
||||
'ankiConnect.isLapis.enabled':
|
||||
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
|
||||
'ankiConnect.isLapis.sentenceCardModel':
|
||||
@@ -408,9 +412,10 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (
|
||||
path.startsWith('ankiConnect.isKiku.') ||
|
||||
path.startsWith('ankiConnect.isLapis.') ||
|
||||
path.startsWith('ankiConnect.isSenren.') ||
|
||||
path.startsWith('ankiConnect.lapisKiku.')
|
||||
) {
|
||||
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
||||
return { category: 'mining-anki', section: 'Kiku/Lapis/Senren Features' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.ai.')) {
|
||||
return { category: 'mining-anki', section: 'Anki AI' };
|
||||
@@ -711,6 +716,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
path === 'ankiConnect.fields.miscInfo' ||
|
||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||
path === 'ankiConnect.isSenren.fieldGrouping' ||
|
||||
path === 'ankiConnect.lapisKiku.wordCardKind' ||
|
||||
path === 'mpv.aniskipEnabled' ||
|
||||
path === 'mpv.aniskipButtonKey' ||
|
||||
|
||||
@@ -86,6 +86,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'ankiConnect.fields.miscInfo',
|
||||
'ankiConnect.isLapis.sentenceCardModel',
|
||||
'ankiConnect.isKiku.fieldGrouping',
|
||||
'ankiConnect.isSenren.fieldGrouping',
|
||||
'ankiConnect.lapisKiku.wordCardKind',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -131,12 +131,6 @@ export {
|
||||
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
|
||||
ticksToSeconds as jellyfinTicksToSecondsRuntime,
|
||||
} from './jellyfin';
|
||||
export { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
|
||||
export {
|
||||
estimateSubtitleTimingOffset,
|
||||
type SubtitleTimingOffsetOptions,
|
||||
type SubtitleTimingOffsetResult,
|
||||
} from './subtitle-timing-offset';
|
||||
export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
export {
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
|
||||
|
||||
function statePath(name: string): string {
|
||||
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-delay-')), name);
|
||||
}
|
||||
|
||||
test('jellyfin subtitle delay store saves and loads delay by item and stream', () => {
|
||||
const filePath = statePath('delays.json');
|
||||
|
||||
assert.equal(
|
||||
saveJellyfinSubtitleDelay({
|
||||
filePath,
|
||||
itemId: 'episode-1',
|
||||
streamIndex: 3,
|
||||
delaySeconds: 1.25,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 1.25);
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), null);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle delay store preserves other stream delays when updating one stream', () => {
|
||||
const filePath = statePath('delays.json');
|
||||
|
||||
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 1.25 });
|
||||
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4, delaySeconds: -0.5 });
|
||||
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 2 });
|
||||
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 2);
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), -0.5);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle delay store ignores invalid files and values', () => {
|
||||
const filePath = statePath('delays.json');
|
||||
fs.writeFileSync(filePath, '{');
|
||||
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), null);
|
||||
assert.equal(
|
||||
saveJellyfinSubtitleDelay({
|
||||
filePath,
|
||||
itemId: 'episode-1',
|
||||
streamIndex: 3,
|
||||
delaySeconds: Number.NaN,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
type JellyfinSubtitleDelayStore = {
|
||||
version?: unknown;
|
||||
delays?: unknown;
|
||||
};
|
||||
|
||||
type JellyfinSubtitleDelayParams = {
|
||||
filePath: string;
|
||||
itemId: string;
|
||||
streamIndex: number;
|
||||
};
|
||||
|
||||
type SaveJellyfinSubtitleDelayParams = JellyfinSubtitleDelayParams & {
|
||||
delaySeconds: number;
|
||||
};
|
||||
|
||||
function storeKey(itemId: string, streamIndex: number): string {
|
||||
return JSON.stringify([itemId, streamIndex]);
|
||||
}
|
||||
|
||||
function readDelayMap(filePath: string): Record<string, number> {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as JellyfinSubtitleDelayStore;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
!parsed.delays ||
|
||||
typeof parsed.delays !== 'object'
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
const delays: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(parsed.delays as Record<string, unknown>)) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
delays[key] = value;
|
||||
}
|
||||
}
|
||||
return delays;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadJellyfinSubtitleDelay(params: JellyfinSubtitleDelayParams): number | null {
|
||||
const delay = readDelayMap(params.filePath)[storeKey(params.itemId, params.streamIndex)];
|
||||
return typeof delay === 'number' && Number.isFinite(delay) ? delay : null;
|
||||
}
|
||||
|
||||
export function saveJellyfinSubtitleDelay(params: SaveJellyfinSubtitleDelayParams): boolean {
|
||||
if (!Number.isFinite(params.delaySeconds)) return false;
|
||||
try {
|
||||
const delays = readDelayMap(params.filePath);
|
||||
delays[storeKey(params.itemId, params.streamIndex)] = params.delaySeconds;
|
||||
const dir = path.dirname(params.filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(params.filePath, JSON.stringify({ version: 1, delays }, null, 2));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,35 @@ test('handleMultiCopyDigit copies available history and reports truncation', ()
|
||||
assert.equal(osd.at(-1), 'Only 2 lines available, copied 2');
|
||||
});
|
||||
|
||||
test('handleMultiCopyDigit copies backward from the current subtitle after a backward seek', () => {
|
||||
const copied: string[] = [];
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
|
||||
try {
|
||||
tracker.recordSubtitle('A', 1, 2);
|
||||
tracker.recordSubtitle('B', 3, 4);
|
||||
tracker.recordSubtitle('C', 5, 6);
|
||||
tracker.recordSubtitle('B', 3, 4);
|
||||
|
||||
const deps = {
|
||||
subtitleTimingTracker: tracker,
|
||||
writeClipboardText: (text: string) => copied.push(text),
|
||||
showMpvOsd: () => {},
|
||||
};
|
||||
|
||||
handleMultiCopyDigit(1, deps);
|
||||
handleMultiCopyDigit(2, deps);
|
||||
|
||||
assert.deepEqual(copied, ['B', 'A\n\nB']);
|
||||
assert.deepEqual(tracker.getRecentEntries(2), [
|
||||
{ displayText: 'A', startTime: 1, endTime: 2, secondaryText: undefined },
|
||||
{ displayText: 'B', startTime: 3, endTime: 4, secondaryText: undefined },
|
||||
]);
|
||||
} finally {
|
||||
tracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
const osd: string[] = [];
|
||||
const logs: Array<{ message: string; err: unknown }> = [];
|
||||
@@ -344,6 +373,22 @@ test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats
|
||||
}
|
||||
});
|
||||
|
||||
test('subtitle timing history preserves adjacent repeated text with distinct timings', () => {
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
|
||||
try {
|
||||
tracker.recordSubtitle('same', 1, 2);
|
||||
tracker.recordSubtitle('same', 3, 4);
|
||||
|
||||
assert.deepEqual(tracker.getRecentEntries(2), [
|
||||
{ displayText: 'same', startTime: 1, endTime: 2, secondaryText: undefined },
|
||||
{ displayText: 'same', startTime: 3, endTime: 4, secondaryText: undefined },
|
||||
]);
|
||||
} finally {
|
||||
tracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit joins per-entry secondary subtitles when available', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
|
||||
@@ -83,6 +83,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
state.secondarySubText = text;
|
||||
},
|
||||
resolvePendingRequest: () => false,
|
||||
shouldEnforceSecondarySubVisibilityHidden: () => true,
|
||||
setSecondarySubVisibility: () => {},
|
||||
syncCurrentAudioStreamIndex: () => {},
|
||||
setCurrentAudioTrackId: () => {},
|
||||
@@ -198,6 +199,21 @@ test('dispatchMpvProtocolMessage rejects decimal subtitle track IDs', async () =
|
||||
assert.deepEqual(state.events, [{ sid: null }, { sid: null }, { sid: null }, { sid: null }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage hides native secondary subtitles after a track change', async () => {
|
||||
const visibilityChanges: boolean[] = [];
|
||||
const { deps, state } = createDeps({
|
||||
setSecondarySubVisibility: (visible) => visibilityChanges.push(visible),
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sid', data: '4' },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(visibilityChanges, [false]);
|
||||
assert.deepEqual(state.events, [{ sid: 4 }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
|
||||
const { deps, state } = createDeps({
|
||||
isVisibleOverlayVisible: () => true,
|
||||
@@ -239,6 +255,24 @@ test('dispatchMpvProtocolMessage skips sub-visibility suppression when overlay i
|
||||
assert.equal(state.commands.length, 0);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage corrects native secondary subtitle visibility', async () => {
|
||||
const visibilityChanges: boolean[] = [];
|
||||
const { deps } = createDeps({
|
||||
setSecondarySubVisibility: (visible) => visibilityChanges.push(visible),
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sub-visibility', data: 'yes' },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sub-visibility', data: 'no' },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(visibilityChanges, [false]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage sets secondary subtitle track based on track list response', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void;
|
||||
setCurrentSecondarySubText: (text: string) => void;
|
||||
resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean;
|
||||
shouldEnforceSecondarySubVisibilityHidden: () => boolean;
|
||||
setSecondarySubVisibility: (visible: boolean) => void;
|
||||
syncCurrentAudioStreamIndex: () => void;
|
||||
setCurrentAudioTrackId: (value: number | null) => void;
|
||||
@@ -285,6 +286,9 @@ export async function dispatchMpvProtocolMessage(
|
||||
: null;
|
||||
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isInteger(sid) ? sid : null });
|
||||
} else if (msg.name === 'secondary-sid') {
|
||||
if (deps.shouldEnforceSecondarySubVisibilityHidden()) {
|
||||
deps.setSecondarySubVisibility(false);
|
||||
}
|
||||
const sid =
|
||||
typeof msg.data === 'number'
|
||||
? msg.data
|
||||
@@ -375,6 +379,11 @@ export async function dispatchMpvProtocolMessage(
|
||||
if (deps.isVisibleOverlayVisible() && asBoolean(msg.data, false)) {
|
||||
deps.sendCommand({ command: ['set_property', 'sub-visibility', false] });
|
||||
}
|
||||
} else if (msg.name === 'secondary-sub-visibility') {
|
||||
const visible = parseVisibilityProperty(msg.data);
|
||||
if (deps.shouldEnforceSecondarySubVisibilityHidden() && visible === true) {
|
||||
deps.setSecondarySubVisibility(false);
|
||||
}
|
||||
} else if (msg.name === 'sub-use-margins') {
|
||||
deps.emitSubtitleMetricsChange({
|
||||
subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins),
|
||||
|
||||
@@ -652,7 +652,7 @@ test('MpvIpcClient captures and disables secondary subtitle visibility on reques
|
||||
]);
|
||||
});
|
||||
|
||||
test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tracked value', async () => {
|
||||
test('MpvIpcClient restores secondary subtitle visibility and relinquishes suppression', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
const previous: boolean[] = [];
|
||||
@@ -671,6 +671,12 @@ test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tra
|
||||
});
|
||||
client.restorePreviousSecondarySubVisibility();
|
||||
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'secondary-sub-visibility',
|
||||
data: 'yes',
|
||||
});
|
||||
|
||||
assert.equal(previous[0], true);
|
||||
assert.equal(previous.length, 1);
|
||||
assert.deepEqual(commands, [
|
||||
@@ -682,8 +688,53 @@ test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tra
|
||||
},
|
||||
]);
|
||||
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'secondary-sub-visibility',
|
||||
data: 'yes',
|
||||
});
|
||||
assert.equal(commands.length, 2);
|
||||
|
||||
client.restorePreviousSecondarySubVisibility();
|
||||
assert.equal(commands.length, 2);
|
||||
|
||||
const callbacks = (client as any).transport.callbacks;
|
||||
callbacks.onConnect();
|
||||
commands.length = 0;
|
||||
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'secondary-sub-visibility',
|
||||
data: 'yes',
|
||||
});
|
||||
assert.deepEqual(commands, [{ command: ['set_property', 'secondary-sub-visibility', 'no'] }]);
|
||||
});
|
||||
|
||||
test('MpvIpcClient keeps secondary subtitle suppression when restoration send fails', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
|
||||
(client as any).send = (payload: unknown) => {
|
||||
commands.push(payload);
|
||||
return false;
|
||||
};
|
||||
|
||||
await invokeHandleMessage(client, {
|
||||
request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
|
||||
data: 'yes',
|
||||
});
|
||||
client.restorePreviousSecondarySubVisibility();
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'secondary-sid',
|
||||
data: 4,
|
||||
});
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
{ command: ['set_property', 'secondary-sub-visibility', 'no'] },
|
||||
{ command: ['set_property', 'secondary-sub-visibility', 'yes'] },
|
||||
{ command: ['set_property', 'secondary-sub-visibility', 'no'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('MpvIpcClient updates current audio stream index from track list', async () => {
|
||||
|
||||
@@ -184,6 +184,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
osdDimensions: null,
|
||||
};
|
||||
private previousSecondarySubVisibility: boolean | null = null;
|
||||
private enforceSecondarySubVisibilityHidden = true;
|
||||
private playbackPaused: boolean | null = null;
|
||||
private pauseAtTime: number | null = null;
|
||||
private pendingPauseAtSubEnd = false;
|
||||
@@ -199,6 +200,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
socketFactory: deps.socketFactory,
|
||||
connectTimeoutMs: deps.connectTimeoutMs,
|
||||
onConnect: () => {
|
||||
this.enforceSecondarySubVisibilityHidden = true;
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.socket = this.transport.getSocket();
|
||||
@@ -476,6 +478,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
},
|
||||
resolvePendingRequest: (requestId: number, message: MpvMessage) =>
|
||||
this.tryResolvePendingRequest(requestId, message),
|
||||
shouldEnforceSecondarySubVisibilityHidden: () => this.enforceSecondarySubVisibilityHidden,
|
||||
setSecondarySubVisibility: (visible: boolean) => this.setSecondarySubVisibility(visible),
|
||||
syncCurrentAudioStreamIndex: () => {
|
||||
this.syncCurrentAudioStreamIndex();
|
||||
@@ -647,9 +650,11 @@ export class MpvIpcClient implements MpvClient {
|
||||
restorePreviousSecondarySubVisibility(): void {
|
||||
const previous = this.previousSecondarySubVisibility;
|
||||
if (previous === null) return;
|
||||
this.send({
|
||||
const restored = this.send({
|
||||
command: ['set_property', 'secondary-sub-visibility', previous ? 'yes' : 'no'],
|
||||
});
|
||||
if (!restored) return;
|
||||
this.enforceSecondarySubVisibilityHidden = false;
|
||||
this.previousSecondarySubVisibility = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1449,15 +1449,17 @@ test('parseSubtitleCues keeps tall CC-style base dialogue publishable after remo
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(172,437)\\fscx50}({\\fscx100}立希{\\fscx50})',
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(332,443)\\fscx50\\fscy50}ともり',
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?',
|
||||
// A second labeled turn, so the script reads as broadcast captions.
|
||||
'Dialogue: 0,0:00:10.11,0:00:12.00,Default,,0,0,0,,{\\pos(192,497)\\fscx50}({\\fscx100}燈{\\fscx50}){\\fscx100}うん。',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
// The bare speaker label row joins the dialogue row beneath it as one cue.
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
['(立希)', 'お前… 燈をバンドに誘ったの?'],
|
||||
['(立希)\nお前… 燈をバンドに誘ったの?', '(燈)うん。'],
|
||||
);
|
||||
assert.deepEqual(cues[0]?.assFurigana, ['たき']);
|
||||
assert.deepEqual(cues[1]?.assFurigana, ['ともり']);
|
||||
assert.deepEqual(cues[0]?.assFurigana, ['たき', 'ともり']);
|
||||
assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned'));
|
||||
});
|
||||
|
||||
@@ -1485,14 +1487,184 @@ test('parseSubtitleCues removes half-size positioned furigana from broadcast cap
|
||||
[
|
||||
'(山田)ごめん 結局 ぬれたな。',
|
||||
'大丈夫。',
|
||||
'(山田の母)ほんなら',
|
||||
'隠し貯蔵のミルクまんじゅう➡',
|
||||
'(山田の母)ほんなら\n隠し貯蔵のミルクまんじゅう➡',
|
||||
'絶対違う',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(cues[1]?.assFurigana, ['だいじょうぶ']);
|
||||
assert.deepEqual(cues[3]?.assFurigana, ['かく', 'ちょぞう']);
|
||||
assert.deepEqual(cues[4]?.assFurigana, ['ぜったい ちが']);
|
||||
assert.deepEqual(cues[2]?.assFurigana, ['かく', 'ちょぞう']);
|
||||
assert.deepEqual(cues[3]?.assFurigana, ['ぜったい ちが']);
|
||||
});
|
||||
|
||||
// Broadcast-caption rows from You and I Are Polar Opposites S02E09. Every pair shares
|
||||
// timing, style, and the bottom band; only the text tells a wrap from a second speaker.
|
||||
const captionRowsHeader = ['[Script Info]', 'PlayResY: 540', '', ...eventsHeader];
|
||||
|
||||
function captionRow(start: string, end: string, x: number, y: number, text: string): string {
|
||||
return `Dialogue: 0,${start},${end},Default,,0,0,0,,{\\pos(${x},${y})}${text}`;
|
||||
}
|
||||
|
||||
test('parseSubtitleCues joins caption rows that wrap one sentence across two events', () => {
|
||||
const content = [
|
||||
...captionRowsHeader,
|
||||
captionRow('0:00:19.08', '0:00:22.66', 172, 437, '⸨ぶっちゃけ'),
|
||||
captionRow(
|
||||
'0:00:19.08',
|
||||
'0:00:22.66',
|
||||
172,
|
||||
497,
|
||||
'早く{\\fscx50} {\\fscx100}この勉強生活 終えたいし⸩',
|
||||
),
|
||||
// No bracket at all: the upper row simply has not reached sentence punctuation.
|
||||
captionRow('0:02:42.33', '0:02:44.43', 232, 437, '(東)≪好きだと'),
|
||||
captionRow('0:02:42.33', '0:02:44.43', 232, 497, '自覚してしまったものの➡'),
|
||||
// Rows are centred independently, so a wrap can change x between rows.
|
||||
captionRow('0:00:42.21', '0:00:45.21', 252, 407, '≪ちょっとしたことで'),
|
||||
captionRow('0:00:42.21', '0:00:45.21', 292, 497, '勝手に落ち込んだり➡'),
|
||||
// A quote closed with 」 inside a still-open ≪…≫ span is not the end of the line.
|
||||
captionRow('0:19:02.84', '0:19:05.00', 212, 437, '≪「つきあえる自信がない」'),
|
||||
captionRow('0:19:02.84', '0:19:05.00', 452, 497, 'じゃない≫'),
|
||||
// An in-sentence 「 quote on the lower row is not a new turn.
|
||||
captionRow('0:18:35.55', '0:18:38.00', 232, 437, '今 「好きだ」と'),
|
||||
captionRow('0:18:35.55', '0:18:38.00', 192, 497, '「心地いい」と感じてるのも➡'),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'polar-opposites-s02e09.ass');
|
||||
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
[
|
||||
'⸨ぶっちゃけ\n早く この勉強生活 終えたいし⸩',
|
||||
'≪ちょっとしたことで\n勝手に落ち込んだり➡',
|
||||
'(東)≪好きだと\n自覚してしまったものの➡',
|
||||
'今 「好きだ」と\n「心地いい」と感じてるのも➡',
|
||||
'≪「つきあえる自信がない」\nじゃない≫',
|
||||
],
|
||||
);
|
||||
assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned'));
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps simultaneous caption rows from two speakers separate', () => {
|
||||
const content = [
|
||||
...captionRowsHeader,
|
||||
// Both unlabeled: the upper row finished its sentence.
|
||||
captionRow('0:03:56.10', '0:04:00.04', 172, 437, 'なあ 車両 変えね?'),
|
||||
captionRow('0:03:56.10', '0:04:00.04', 632, 497, 'えっ?➡'),
|
||||
// Lower row opens a labeled turn.
|
||||
captionRow('0:03:38.48', '0:03:42.05', 592, 437, 'おはよう!'),
|
||||
captionRow('0:03:38.48', '0:03:42.05', 272, 497, '(平)あっ 声 でかっ。'),
|
||||
// A closed monologue span above a sound effect.
|
||||
captionRow('0:08:16.83', '0:08:19.50', 372, 437, '≪落ち着け 落ち着け≫'),
|
||||
captionRow('0:08:16.83', '0:08:19.50', 272, 497, 'ドクン ドクン ドクン…'),
|
||||
// Two labeled speakers.
|
||||
captionRow('0:09:27.90', '0:09:31.07', 312, 437, '(平)ぐぅ…。'),
|
||||
captionRow('0:09:27.90', '0:09:31.07', 352, 497, '(東)≪ちくしょう~!≫'),
|
||||
// A bare label never swallows a differently labeled row.
|
||||
captionRow('0:11:43.24', '0:11:45.00', 212, 437, '(長谷川)'),
|
||||
captionRow('0:11:43.24', '0:11:45.00', 412, 497, '(早乙女)ん?'),
|
||||
// A short sentence-final 。 closes the upper row like any other.
|
||||
captionRow('0:12:31.55', '0:12:33.55', 172, 437, '⚞(東)平。'),
|
||||
captionRow('0:12:31.55', '0:12:33.55', 532, 497, 'あっ。'),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'polar-opposites-s02e09.ass');
|
||||
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
[
|
||||
'おはよう!',
|
||||
'(平)あっ 声 でかっ。',
|
||||
'なあ 車両 変えね?',
|
||||
'えっ?➡',
|
||||
'≪落ち着け 落ち着け≫',
|
||||
'ドクン ドクン ドクン…',
|
||||
'(平)ぐぅ…。',
|
||||
'(東)≪ちくしょう~!≫',
|
||||
'(長谷川)',
|
||||
'(早乙女)ん?',
|
||||
'⚞(東)平。',
|
||||
'あっ。',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps caption rows apart across styles, bands, and timing', () => {
|
||||
const content = [
|
||||
...captionRowsHeader,
|
||||
// Same wording as a wrap, but the rows sit in different vertical bands.
|
||||
captionRow('0:01:00.00', '0:01:02.00', 172, 77, '≪ちょっとしたことで'),
|
||||
captionRow('0:01:00.00', '0:01:02.00', 172, 497, '勝手に落ち込んだり➡'),
|
||||
// Same band, but a sign style beside dialogue.
|
||||
'Dialogue: 0,0:01:05.00,0:01:07.00,Sign,,0,0,0,,{\\pos(172,437)}ちょっとしたことで',
|
||||
captionRow('0:01:05.00', '0:01:07.00', 172, 497, '勝手に落ち込んだり➡'),
|
||||
// Same rows, but the lower one ends later.
|
||||
captionRow('0:01:10.00', '0:01:12.00', 172, 437, '≪ちょっとしたことで'),
|
||||
captionRow('0:01:10.00', '0:01:13.00', 172, 497, '勝手に落ち込んだり➡'),
|
||||
// Style-aligned rows without \pos are never caption rows.
|
||||
'Dialogue: 0,0:01:15.00,0:01:17.00,Default,,0,0,0,,{\\an8}≪ちょっとしたことで',
|
||||
'Dialogue: 0,0:01:15.00,0:01:17.00,Default,,0,0,0,,{\\an2}勝手に落ち込んだり➡',
|
||||
// Same height: the events sit side by side, not one above the other.
|
||||
captionRow('0:01:20.00', '0:01:22.00', 172, 497, '≪ちょっとしたことで'),
|
||||
captionRow('0:01:20.00', '0:01:22.00', 612, 497, '勝手に落ち込んだり➡'),
|
||||
// Same bottom band, but further apart than two text rows.
|
||||
captionRow('0:01:25.00', '0:01:27.00', 172, 367, '≪ちょっとしたことで'),
|
||||
captionRow('0:01:25.00', '0:01:27.00', 172, 497, '勝手に落ち込んだり➡'),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 12);
|
||||
assert.ok(cues.every((cue) => !cue.text.includes('\n')));
|
||||
});
|
||||
|
||||
test('parseSubtitleCues leaves typeset rows alone in scripts that are not broadcast captions', () => {
|
||||
// Fansub typesetting stacks positioned rows for signs, chat bubbles, and headlines. Such
|
||||
// text carries no caption punctuation, so without the script-level gate every stacked
|
||||
// pair here would read as an unfinished sentence and merge.
|
||||
const content = [
|
||||
...captionRowsHeader,
|
||||
captionRow('0:00:10.00', '0:00:14.00', 640, 200, 'Shocking Statement Leaves'),
|
||||
captionRow('0:00:10.00', '0:00:14.00', 640, 260, 'Listeners Speechless!'),
|
||||
captionRow('0:01:00.00', '0:01:04.00', 400, 300, 'shes here AGAIN'),
|
||||
captionRow('0:01:00.00', '0:01:04.00', 400, 360, 'make sakiko-chan go home'),
|
||||
// Japanese typesetting in the same script is held back by the same gate.
|
||||
captionRow('0:02:00.00', '0:02:04.00', 300, 400, '定休日'),
|
||||
captionRow('0:02:00.00', '0:02:04.00', 300, 460, '毎週水曜日'),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
[
|
||||
'Shocking Statement Leaves',
|
||||
'Listeners Speechless!',
|
||||
'shes here AGAIN',
|
||||
'make sakiko-chan go home',
|
||||
'定休日',
|
||||
'毎週水曜日',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues never joins caption rows that carry no Japanese', () => {
|
||||
// Even inside a caption script, romaji or English rows are not the wrapped Japanese
|
||||
// sentences this pass targets.
|
||||
const content = [
|
||||
...captionRowsHeader,
|
||||
captionRow('0:00:10.00', '0:00:13.00', 172, 437, '(東)≪好きだと'),
|
||||
captionRow('0:00:10.00', '0:00:13.00', 172, 497, '自覚してしまったものの➡'),
|
||||
captionRow('0:00:20.00', '0:00:23.00', 172, 437, '(平)ん?'),
|
||||
captionRow('0:00:30.00', '0:00:34.00', 640, 437, 'NOW LOADING'),
|
||||
captionRow('0:00:30.00', '0:00:34.00', 640, 497, 'please wait'),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
['(東)≪好きだと\n自覚してしまったものの➡', '(平)ん?', 'NOW LOADING', 'please wait'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues scales furigana geometry by PlayResY', () => {
|
||||
|
||||
@@ -2342,6 +2342,185 @@ function removeAssFuriganaEvents(
|
||||
};
|
||||
}
|
||||
|
||||
// Broadcast-caption converters give every visual row of one utterance its own positioned
|
||||
// event, so a sentence that wraps arrives as two simultaneous cues with the same timing,
|
||||
// style, and vertical band. Captions punctuate every finished utterance, and each turn
|
||||
// opens with a speaker label or a ≪…≫ / ⸨…⸩ span, which is what tells a wrapped sentence
|
||||
// apart from two speakers sharing the screen.
|
||||
const CAPTION_SPEAKER_LABEL_ONLY_PATTERN = /^([^()]*)$/u;
|
||||
const CAPTION_SPEAKER_LABEL_PATTERN = /^(/u;
|
||||
const CAPTION_TURN_OPENER_PATTERN = /^[≪⸨(]/u;
|
||||
const CAPTION_TERMINAL_PATTERN = /[。?!?!…‥~〜➡⁉⁈≫⸩)」』]$/u;
|
||||
const CAPTION_SPANS: ReadonlyArray<readonly [open: string, close: string]> = [
|
||||
['≪', '≫'],
|
||||
['⸨', '⸩'],
|
||||
];
|
||||
// Rows of one utterance sit one text row apart (about 60 units in the 540-line space the
|
||||
// furigana geometry is tuned for), or two when a ruby row lies between them. Rows at the
|
||||
// same height sit side by side, and rows further apart are separate placements.
|
||||
const MAX_CAPTION_ROW_GAP = 120;
|
||||
// Only a broadcast-caption script gets rows joined. Typesetters position rows for signs,
|
||||
// chat bubbles, and lyric stacks too, and there the continuation rule below has no
|
||||
// convention to read: sign text rarely carries sentence punctuation, so unrelated rows
|
||||
// would run together. A caption script announces itself by labelling speakers (名) and
|
||||
// bracketing off-screen speech in ≪…≫ / ⸨…⸩; typeset scripts use those in a handful of
|
||||
// lines at most. Measured over local tracks, caption scripts sit near 25% and every typeset
|
||||
// script below 1%, so the threshold has room on both sides. It is deliberately strict: a
|
||||
// caption script wrongly held back just keeps one sentence on two rows, while a typeset
|
||||
// script wrongly let through concatenates unrelated signs.
|
||||
const MIN_CAPTION_EVIDENCE_EVENTS = 2;
|
||||
const MIN_CAPTION_EVIDENCE_RATIO = 0.05;
|
||||
const CAPTION_EVIDENCE_PATTERN = /^([^()]{1,14})|[≪⸨]/u;
|
||||
// Rows that carry no Japanese are not the broadcast captions this pass targets.
|
||||
const JAPANESE_SCRIPT_PATTERN = /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u;
|
||||
|
||||
function hasBroadcastCaptionConventions(cues: readonly AnnotatedSubtitleCue[]): boolean {
|
||||
let evidence = 0;
|
||||
let published = 0;
|
||||
for (const cue of cues) {
|
||||
if (!cue.text.trim()) continue;
|
||||
published += 1;
|
||||
if (CAPTION_EVIDENCE_PATTERN.test(cue.text)) evidence += 1;
|
||||
}
|
||||
return (
|
||||
evidence >= MIN_CAPTION_EVIDENCE_EVENTS && evidence >= published * MIN_CAPTION_EVIDENCE_RATIO
|
||||
);
|
||||
}
|
||||
|
||||
function captionSpanDepth(text: string, [open, close]: readonly [string, string]): number {
|
||||
let depth = 0;
|
||||
for (const char of text) {
|
||||
if (char === open) depth += 1;
|
||||
else if (char === close) depth -= 1;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `lower` continues the utterance `upper` started, both being simultaneous
|
||||
* caption rows. A bare speaker label labels the row beneath it. Otherwise the upper row
|
||||
* must not have finished: it ends without terminal punctuation, or a ≪…≫ / ⸨…⸩ span it
|
||||
* opened is still open (closing 」 inside such a span is not an ending). A lower row that
|
||||
* opens its own turn is always a different line.
|
||||
*/
|
||||
function isCaptionRowContinuation(upper: string, lower: string): boolean {
|
||||
if (CAPTION_SPEAKER_LABEL_ONLY_PATTERN.test(upper)) {
|
||||
return !CAPTION_SPEAKER_LABEL_PATTERN.test(lower);
|
||||
}
|
||||
if (CAPTION_TURN_OPENER_PATTERN.test(lower)) {
|
||||
return false;
|
||||
}
|
||||
const spanContinues = CAPTION_SPANS.some(
|
||||
(span) => captionSpanDepth(upper, span) > 0 || captionSpanDepth(lower, span) < 0,
|
||||
);
|
||||
return spanContinues || !CAPTION_TERMINAL_PATTERN.test(upper);
|
||||
}
|
||||
|
||||
// A half-height row is ruby or a whispered aside, not a row of the utterance.
|
||||
function isCaptionRowCandidate(cue: AnnotatedSubtitleCue): boolean {
|
||||
const scaleY = staticAssScalePercent(cue, 'fscy');
|
||||
return (
|
||||
cue.source === undefined &&
|
||||
cue.assLayout?.kind === 'positioned' &&
|
||||
cue.effect.trim() === '' &&
|
||||
!cue.text.includes('\n') &&
|
||||
!hasAssTemporalOverride(cue.overrides) &&
|
||||
(scaleY === null || scaleY > MAX_ASS_FURIGANA_SCALE_PERCENT) &&
|
||||
JAPANESE_SCRIPT_PATTERN.test(cue.text)
|
||||
);
|
||||
}
|
||||
|
||||
function captionRowGroupKey(cue: AnnotatedSubtitleCue): string {
|
||||
return [
|
||||
cue.startTime,
|
||||
cue.endTime,
|
||||
cue.style,
|
||||
cue.layer,
|
||||
cue.name,
|
||||
cue.assLayout?.verticalBand ?? '',
|
||||
].join('\0');
|
||||
}
|
||||
|
||||
function mergeCaptionRows(rows: readonly AnnotatedSubtitleCue[]): AnnotatedSubtitleCue {
|
||||
const [first] = rows;
|
||||
if (!first) throw new Error('mergeCaptionRows requires at least one row');
|
||||
const overrides = rows.flatMap((row) => row.overrides);
|
||||
const assFurigana = [...new Set(rows.flatMap((row) => row.assFurigana ?? []))];
|
||||
return {
|
||||
...first,
|
||||
text: rows.map((row) => row.text).join('\n'),
|
||||
rawText: rows.map((row) => row.rawText).join('\\N'),
|
||||
overrides,
|
||||
overrideSignature: assOverrideSignature(overrides),
|
||||
...(assFurigana.length === 0 ? {} : { assFurigana }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Join simultaneous caption rows that spell one utterance into a single cue, so the
|
||||
* overlay can wrap or flatten it like an authored `\N` line and the sidebar and mining
|
||||
* paths see the whole sentence. Rows stack top to bottom; each row joins the cue above it
|
||||
* only while `isCaptionRowContinuation` holds, so a second speaker starts a new cue. The
|
||||
* whole pass is skipped unless the script reads as broadcast captions.
|
||||
*/
|
||||
function mergeAssCaptionRows(
|
||||
cues: AnnotatedSubtitleCue[],
|
||||
playResY: number | null,
|
||||
): AnnotatedSubtitleCue[] {
|
||||
if (!hasBroadcastCaptionConventions(cues)) return cues;
|
||||
|
||||
const groups = new Map<string, AnnotatedSubtitleCue[]>();
|
||||
for (const cue of cues) {
|
||||
if (!isCaptionRowCandidate(cue)) continue;
|
||||
const key = captionRowGroupKey(cue);
|
||||
const group = groups.get(key);
|
||||
if (group) group.push(cue);
|
||||
else groups.set(key, [cue]);
|
||||
}
|
||||
|
||||
const maxRowGap = MAX_CAPTION_ROW_GAP * assFuriganaGeometryScale(playResY);
|
||||
const rowY = (cue: AnnotatedSubtitleCue): number =>
|
||||
cue.assLayout?.kind === 'positioned' ? cue.assLayout.y : 0;
|
||||
const replacements = new Map<AnnotatedSubtitleCue, AnnotatedSubtitleCue>();
|
||||
const removed = new Set<AnnotatedSubtitleCue>();
|
||||
for (const group of groups.values()) {
|
||||
if (group.length < 2) continue;
|
||||
const rows = [...group].sort((a, b) => rowY(a) - rowY(b) || a.order - b.order);
|
||||
let run: AnnotatedSubtitleCue[] = [];
|
||||
const flush = (): void => {
|
||||
if (run.length < 2) return;
|
||||
const anchor = run.reduce((lowest, row) => (row.order < lowest.order ? row : lowest));
|
||||
replacements.set(anchor, mergeCaptionRows(run));
|
||||
for (const row of run) {
|
||||
if (row !== anchor) removed.add(row);
|
||||
}
|
||||
};
|
||||
for (const row of rows) {
|
||||
const previous = run.at(-1);
|
||||
const gap = previous ? rowY(row) - rowY(previous) : 0;
|
||||
if (
|
||||
previous &&
|
||||
gap > 0 &&
|
||||
gap <= maxRowGap &&
|
||||
previous.text !== row.text &&
|
||||
isCaptionRowContinuation(previous.text, row.text)
|
||||
) {
|
||||
run.push(row);
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
run = [row];
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
if (replacements.size === 0) return cues;
|
||||
return cues.flatMap((cue) => {
|
||||
if (removed.has(cue)) return [];
|
||||
return [replacements.get(cue) ?? cue];
|
||||
});
|
||||
}
|
||||
|
||||
function parseAnnotatedAssEvents(content: string, placement: AssPlacementContext): ParsedAssEvents {
|
||||
const cues: AnnotatedSubtitleCue[] = [];
|
||||
const comments: AnnotatedSubtitleCue[] = [];
|
||||
@@ -2476,7 +2655,10 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)),
|
||||
placement.playResY,
|
||||
);
|
||||
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
|
||||
return mergeAssCaptionRows(
|
||||
recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events)),
|
||||
placement.playResY,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAssCues(content: string): SubtitleCue[] {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { estimateSubtitleTimingOffset } from './subtitle-timing-offset';
|
||||
|
||||
function cue(startTime: number) {
|
||||
return { startTime, endTime: startTime + 1, text: `cue ${startTime}` };
|
||||
}
|
||||
|
||||
test('estimate subtitle timing offset detects a late Jellyfin subtitle timeline', () => {
|
||||
const primary = [
|
||||
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
|
||||
87.988, 90.991, 94.094, 97.097,
|
||||
].map(cue);
|
||||
const reference = [
|
||||
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56,
|
||||
].map(cue);
|
||||
|
||||
const result = estimateSubtitleTimingOffset(primary, reference);
|
||||
|
||||
assert.ok(result);
|
||||
assert.ok(result.offsetSeconds > -32);
|
||||
assert.ok(result.offsetSeconds < -31);
|
||||
assert.ok(result.matchCount >= 8);
|
||||
assert.ok(result.meanErrorSeconds <= 0.75);
|
||||
});
|
||||
|
||||
test('estimate subtitle timing offset favors the early episode timeline', () => {
|
||||
const primary = [
|
||||
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
|
||||
87.988, 90.991, 94.094, 97.097, 207.974, 212.579, 222.422, 228.095, 232.432, 238.271, 244.778,
|
||||
246.78, 249.282, 251.284, 253.62, 256.289, 259.626, 262.129, 264.965, 267.634, 270.303, 274.407,
|
||||
277.077, 280.08, 284.084, 288.421, 291.925, 295.262, 298.431, 301.101, 306.773, 308.942,
|
||||
312.946, 316.283, 321.621, 326.626, 331.131, 336.069, 340.407, 343.41, 351.418, 355.422,
|
||||
357.924, 362.429, 365.432, 370.604, 373.273, 377.944, 381.114, 384.618, 387.621, 390.957,
|
||||
396.73, 399.232, 401.568, 403.57, 405.572, 407.574, 409.743, 412.746, 418.752, 425.258, 427.26,
|
||||
435.602, 440.44, 442.942, 445.445, 449.783,
|
||||
].map(cue);
|
||||
const reference = [
|
||||
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56, 165.77, 172.81,
|
||||
176.1, 177.27, 186.33, 191.33, 195.78, 201.83, 212.9, 214.09, 216.73, 220.2, 222.91, 225.65,
|
||||
232.8, 237.92, 242.23, 243.28, 247.53, 252.04, 255.9, 258.86, 262.09, 264.43, 276.07, 278.01,
|
||||
280.98, 285.67, 289.89, 294.57, 300, 303.56, 308.58, 316.37, 318.38, 319.86, 325.38, 328.82,
|
||||
333.68, 335.26, 336.82, 340.11, 342.11, 344.36, 346.39, 347.53, 350.92, 370.18, 372.88, 376.43,
|
||||
388.2, 390.57, 403.96, 406.36, 409.72, 413.78, 425.55, 432.76, 435.03, 438.06, 443.73, 448.31,
|
||||
450.57, 457.62, 463.41, 465.85, 473.79, 480.59,
|
||||
].map(cue);
|
||||
|
||||
const result = estimateSubtitleTimingOffset(primary, reference);
|
||||
|
||||
assert.ok(result);
|
||||
assert.ok(result.offsetSeconds > -32);
|
||||
assert.ok(result.offsetSeconds < -31);
|
||||
});
|
||||
|
||||
test('estimate subtitle timing offset ignores subtitle timelines that are already aligned', () => {
|
||||
const starts = [1, 5, 9, 14, 20, 25, 31, 38];
|
||||
|
||||
const result = estimateSubtitleTimingOffset(
|
||||
starts.map(cue),
|
||||
starts.map((start) => cue(start + 0.04)),
|
||||
);
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('estimate subtitle timing offset rejects weak timeline matches', () => {
|
||||
const primary = [10, 20, 30, 40, 50, 60, 70, 80].map(cue);
|
||||
const reference = [1, 2, 3, 4, 5, 6, 7, 8].map(cue);
|
||||
|
||||
const result = estimateSubtitleTimingOffset(primary, reference);
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
||||
|
||||
export type SubtitleTimingOffsetResult = {
|
||||
offsetSeconds: number;
|
||||
matchCount: number;
|
||||
meanErrorSeconds: number;
|
||||
maxErrorSeconds: number;
|
||||
};
|
||||
|
||||
export type SubtitleTimingOffsetOptions = {
|
||||
maxCueCount?: number;
|
||||
maxOffsetSeconds?: number;
|
||||
matchThresholdSeconds?: number;
|
||||
maxMeanErrorSeconds?: number;
|
||||
minMatchCount?: number;
|
||||
minMatchRatio?: number;
|
||||
minUsefulOffsetSeconds?: number;
|
||||
};
|
||||
|
||||
type OffsetScore = SubtitleTimingOffsetResult;
|
||||
|
||||
const DEFAULT_MAX_CUE_COUNT = 60;
|
||||
const DEFAULT_MAX_OFFSET_SECONDS = 180;
|
||||
const DEFAULT_MATCH_THRESHOLD_SECONDS = 1;
|
||||
const DEFAULT_MAX_MEAN_ERROR_SECONDS = 0.75;
|
||||
const DEFAULT_MIN_MATCH_COUNT = 8;
|
||||
const DEFAULT_MIN_MATCH_RATIO = 0.25;
|
||||
const DEFAULT_MIN_USEFUL_OFFSET_SECONDS = 0.25;
|
||||
|
||||
function normalizeCueStarts(cues: SubtitleCue[], maxCueCount: number): number[] {
|
||||
const starts = cues
|
||||
.map((cue) => cue.startTime)
|
||||
.filter((start) => Number.isFinite(start) && start >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
const deduped: number[] = [];
|
||||
for (const start of starts) {
|
||||
const previous = deduped[deduped.length - 1];
|
||||
if (previous === undefined || Math.abs(start - previous) > 0.05) {
|
||||
deduped.push(start);
|
||||
}
|
||||
if (deduped.length >= maxCueCount) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function roundToMillis(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function scoreOffset(
|
||||
primaryStarts: number[],
|
||||
referenceStarts: number[],
|
||||
offsetSeconds: number,
|
||||
matchThresholdSeconds: number,
|
||||
): OffsetScore {
|
||||
let primaryIndex = 0;
|
||||
let referenceIndex = 0;
|
||||
let matchCount = 0;
|
||||
let totalErrorSeconds = 0;
|
||||
let maxErrorSeconds = 0;
|
||||
|
||||
while (primaryIndex < primaryStarts.length && referenceIndex < referenceStarts.length) {
|
||||
const shiftedPrimary = primaryStarts[primaryIndex]! + offsetSeconds;
|
||||
const reference = referenceStarts[referenceIndex]!;
|
||||
const errorSeconds = Math.abs(shiftedPrimary - reference);
|
||||
if (errorSeconds <= matchThresholdSeconds) {
|
||||
matchCount += 1;
|
||||
totalErrorSeconds += errorSeconds;
|
||||
maxErrorSeconds = Math.max(maxErrorSeconds, errorSeconds);
|
||||
primaryIndex += 1;
|
||||
referenceIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shiftedPrimary < reference) {
|
||||
primaryIndex += 1;
|
||||
} else {
|
||||
referenceIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
offsetSeconds,
|
||||
matchCount,
|
||||
meanErrorSeconds: matchCount > 0 ? totalErrorSeconds / matchCount : Number.POSITIVE_INFINITY,
|
||||
maxErrorSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function isBetterScore(next: OffsetScore, current: OffsetScore | null): boolean {
|
||||
if (current === null) return true;
|
||||
if (next.matchCount !== current.matchCount) return next.matchCount > current.matchCount;
|
||||
if (next.meanErrorSeconds !== current.meanErrorSeconds) {
|
||||
return next.meanErrorSeconds < current.meanErrorSeconds;
|
||||
}
|
||||
return Math.abs(next.offsetSeconds) < Math.abs(current.offsetSeconds);
|
||||
}
|
||||
|
||||
export function estimateSubtitleTimingOffset(
|
||||
primaryCues: SubtitleCue[],
|
||||
referenceCues: SubtitleCue[],
|
||||
options: SubtitleTimingOffsetOptions = {},
|
||||
): SubtitleTimingOffsetResult | null {
|
||||
const maxCueCount = options.maxCueCount ?? DEFAULT_MAX_CUE_COUNT;
|
||||
const maxOffsetSeconds = options.maxOffsetSeconds ?? DEFAULT_MAX_OFFSET_SECONDS;
|
||||
const matchThresholdSeconds = options.matchThresholdSeconds ?? DEFAULT_MATCH_THRESHOLD_SECONDS;
|
||||
const maxMeanErrorSeconds = options.maxMeanErrorSeconds ?? DEFAULT_MAX_MEAN_ERROR_SECONDS;
|
||||
const minMatchCount = options.minMatchCount ?? DEFAULT_MIN_MATCH_COUNT;
|
||||
const minMatchRatio = options.minMatchRatio ?? DEFAULT_MIN_MATCH_RATIO;
|
||||
const minUsefulOffsetSeconds =
|
||||
options.minUsefulOffsetSeconds ?? DEFAULT_MIN_USEFUL_OFFSET_SECONDS;
|
||||
|
||||
const primaryStarts = normalizeCueStarts(primaryCues, maxCueCount);
|
||||
const referenceStarts = normalizeCueStarts(referenceCues, maxCueCount);
|
||||
const comparableCueCount = Math.min(primaryStarts.length, referenceStarts.length);
|
||||
if (comparableCueCount < minMatchCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = new Set<number>();
|
||||
for (const primaryStart of primaryStarts) {
|
||||
for (const referenceStart of referenceStarts) {
|
||||
const offsetSeconds = roundToMillis(referenceStart - primaryStart);
|
||||
if (Math.abs(offsetSeconds) <= maxOffsetSeconds) {
|
||||
candidates.add(offsetSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let best: OffsetScore | null = null;
|
||||
for (const offsetSeconds of candidates) {
|
||||
if (Math.abs(offsetSeconds) < minUsefulOffsetSeconds) {
|
||||
continue;
|
||||
}
|
||||
const score = scoreOffset(primaryStarts, referenceStarts, offsetSeconds, matchThresholdSeconds);
|
||||
if (score.matchCount < minMatchCount) {
|
||||
continue;
|
||||
}
|
||||
if (score.matchCount / comparableCueCount < minMatchRatio) {
|
||||
continue;
|
||||
}
|
||||
if (score.meanErrorSeconds > maxMeanErrorSeconds) {
|
||||
continue;
|
||||
}
|
||||
if (isBetterScore(score, best)) {
|
||||
best = score;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
-23
@@ -302,7 +302,6 @@ import {
|
||||
listJellyfinItemsRuntime,
|
||||
listJellyfinLibrariesRuntime,
|
||||
listJellyfinSubtitleTracksRuntime,
|
||||
loadJellyfinSubtitleDelay,
|
||||
loadSubtitlePosition as loadSubtitlePositionCore,
|
||||
loadYomitanExtension as loadYomitanExtensionCore,
|
||||
markLastCardAsAudioCard as markLastCardAsAudioCardCore,
|
||||
@@ -315,7 +314,6 @@ import {
|
||||
resolveSanitizedSubtitleSeekCommand,
|
||||
resolveJellyfinPlaybackPlanRuntime,
|
||||
runStartupBootstrapRuntime,
|
||||
saveJellyfinSubtitleDelay,
|
||||
saveSubtitlePosition as saveSubtitlePositionCore,
|
||||
clearYomitanParserCachesForWindow,
|
||||
getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore,
|
||||
@@ -684,7 +682,6 @@ function spawnManagedMpvProcess(args: string[]): ReturnType<typeof spawn> {
|
||||
}
|
||||
|
||||
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
|
||||
let activeJellyfinSubtitleDelayKey: { itemId: string; streamIndex: number } | null = null;
|
||||
let jellyfinRemoteLastProgressAtMs = 0;
|
||||
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
||||
let backgroundWarmupsStarted = false;
|
||||
@@ -2489,7 +2486,6 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
|
||||
const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback;
|
||||
|
||||
const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions');
|
||||
const JELLYFIN_SUBTITLE_DELAYS_PATH = path.join(CONFIG_DIR, 'jellyfin-subtitle-delays.json');
|
||||
|
||||
const mediaRuntime = createMediaRuntimeService(
|
||||
createBuildMediaRuntimeMainDepsHandler({
|
||||
@@ -3133,23 +3129,6 @@ const {
|
||||
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
||||
cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track),
|
||||
cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs),
|
||||
getSavedSubtitleDelay: (itemId, streamIndex) =>
|
||||
loadJellyfinSubtitleDelay({
|
||||
filePath: JELLYFIN_SUBTITLE_DELAYS_PATH,
|
||||
itemId,
|
||||
streamIndex,
|
||||
}),
|
||||
setActiveSubtitleDelayKey: (key) => {
|
||||
activeJellyfinSubtitleDelayKey = key;
|
||||
},
|
||||
loadSubtitleSourceText,
|
||||
saveSubtitleDelay: (itemId, streamIndex, delaySeconds) =>
|
||||
saveJellyfinSubtitleDelay({
|
||||
filePath: JELLYFIN_SUBTITLE_DELAYS_PATH,
|
||||
itemId,
|
||||
streamIndex,
|
||||
delaySeconds,
|
||||
}),
|
||||
initSubtitlePrefetch: (sourcePath) =>
|
||||
subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath),
|
||||
logDebug: (message, error) => {
|
||||
@@ -3215,7 +3194,6 @@ const {
|
||||
getActivePlayback: () => activeJellyfinRemotePlayback,
|
||||
clearActivePlayback: () => {
|
||||
activeJellyfinRemotePlayback = null;
|
||||
activeJellyfinSubtitleDelayKey = null;
|
||||
},
|
||||
getSession: () => appState.jellyfinRemoteSession,
|
||||
getNow: () => Date.now(),
|
||||
@@ -4572,7 +4550,6 @@ const {
|
||||
appState.activeParsedSubtitleSource = null;
|
||||
appState.activeParsedSubtitleMediaPath = null;
|
||||
}
|
||||
activeJellyfinSubtitleDelayKey = null;
|
||||
overlayManager.broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
|
||||
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
|
||||
@@ -137,6 +137,9 @@ function buildAnkiRuntimeConfigPatch(
|
||||
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
|
||||
patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping };
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.isSenren.fieldGrouping')) {
|
||||
patch.isSenren = { fieldGrouping: config.ankiConnect.isSenren.fieldGrouping };
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.lapisKiku.wordCardKind')) {
|
||||
patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind };
|
||||
}
|
||||
|
||||
@@ -19,19 +19,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
||||
return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' };
|
||||
},
|
||||
cleanupCachedSubtitles: () => calls.push('cleanup'),
|
||||
getSavedSubtitleDelay: (_itemId, streamIndex) => {
|
||||
calls.push(`load-delay:${streamIndex}`);
|
||||
return 1.25;
|
||||
},
|
||||
setActiveSubtitleDelayKey: (key) => calls.push(`active-delay:${key?.streamIndex ?? 'none'}`),
|
||||
loadSubtitleSourceText: async (source) => {
|
||||
calls.push(`load-source:${source}`);
|
||||
return 'subtitle';
|
||||
},
|
||||
saveSubtitleDelay: (_itemId, streamIndex, delaySeconds) => {
|
||||
calls.push(`save-delay:${streamIndex}:${delaySeconds}`);
|
||||
return true;
|
||||
},
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
})();
|
||||
|
||||
@@ -41,21 +28,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
||||
await deps.wait(1);
|
||||
await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' });
|
||||
deps.cleanupCachedSubtitles(['/tmp/subs']);
|
||||
assert.equal(deps.getSavedSubtitleDelay?.('item', 3), 1.25);
|
||||
deps.setActiveSubtitleDelayKey?.({ itemId: 'item', streamIndex: 3 });
|
||||
assert.equal(await deps.loadSubtitleSourceText?.('/tmp/sub.srt'), 'subtitle');
|
||||
assert.equal(deps.saveSubtitleDelay?.('item', 3, -31.5), true);
|
||||
deps.logDebug('oops', null);
|
||||
assert.deepEqual(calls, [
|
||||
'list',
|
||||
'send',
|
||||
'wait',
|
||||
'cache',
|
||||
'cleanup',
|
||||
'load-delay:3',
|
||||
'active-delay:3',
|
||||
'load-source:/tmp/sub.srt',
|
||||
'save-delay:3:-31.5',
|
||||
'debug:oops',
|
||||
]);
|
||||
assert.deepEqual(calls, ['list', 'send', 'wait', 'cache', 'cleanup', 'debug:oops']);
|
||||
});
|
||||
|
||||
@@ -15,19 +15,6 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
|
||||
wait: (ms: number) => deps.wait(ms),
|
||||
cacheSubtitleTrack: (track) => deps.cacheSubtitleTrack(track),
|
||||
cleanupCachedSubtitles: (dirs) => deps.cleanupCachedSubtitles(dirs),
|
||||
getSavedSubtitleDelay: deps.getSavedSubtitleDelay
|
||||
? (itemId, streamIndex) => deps.getSavedSubtitleDelay!(itemId, streamIndex)
|
||||
: undefined,
|
||||
setActiveSubtitleDelayKey: deps.setActiveSubtitleDelayKey
|
||||
? (key) => deps.setActiveSubtitleDelayKey!(key)
|
||||
: undefined,
|
||||
loadSubtitleSourceText: deps.loadSubtitleSourceText
|
||||
? (source) => deps.loadSubtitleSourceText!(source)
|
||||
: undefined,
|
||||
saveSubtitleDelay: deps.saveSubtitleDelay
|
||||
? (itemId, streamIndex, delaySeconds) =>
|
||||
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
|
||||
: undefined,
|
||||
initSubtitlePrefetch: deps.initSubtitlePrefetch
|
||||
? (sourcePath) => deps.initSubtitlePrefetch!(sourcePath)
|
||||
: undefined,
|
||||
|
||||
@@ -32,14 +32,6 @@ function makeDeps(overrides: {
|
||||
cleanupCachedSubtitles?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['cleanupCachedSubtitles'];
|
||||
getSavedSubtitleDelay?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['getSavedSubtitleDelay'];
|
||||
setActiveSubtitleDelayKey?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['setActiveSubtitleDelayKey'];
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
|
||||
initSubtitlePrefetch?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['initSubtitlePrefetch'];
|
||||
@@ -57,10 +49,6 @@ function makeDeps(overrides: {
|
||||
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||
})),
|
||||
cleanupCachedSubtitles: overrides.cleanupCachedSubtitles ?? (() => {}),
|
||||
getSavedSubtitleDelay: overrides.getSavedSubtitleDelay,
|
||||
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
|
||||
loadSubtitleSourceText: overrides.loadSubtitleSourceText,
|
||||
saveSubtitleDelay: overrides.saveSubtitleDelay,
|
||||
initSubtitlePrefetch: overrides.initSubtitlePrefetch,
|
||||
logDebug: overrides.logDebug ?? (() => {}),
|
||||
};
|
||||
@@ -377,20 +365,17 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste
|
||||
|
||||
test('preload jellyfin subtitles clears managed delay when no external tracks are available', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const activeDelayKeys: Array<unknown> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Embedded Japanese' },
|
||||
],
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
setActiveSubtitleDelayKey: (key) => activeDelayKeys.push(key),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.deepEqual(activeDelayKeys, [null]);
|
||||
assert.deepEqual(commands, [['set_property', 'sub-delay', 0]]);
|
||||
});
|
||||
|
||||
@@ -461,42 +446,7 @@ test('preload jellyfin subtitles prefers Jellyfin default and embedded japanese
|
||||
]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles applies saved delay for selected japanese stream', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const activeKeys: Array<{ itemId: string; streamIndex: number } | null> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 3, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 11,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/3.srt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
getSavedSubtitleDelay: (_itemId, streamIndex) => (streamIndex === 3 ? 1.25 : null),
|
||||
setActiveSubtitleDelayKey: (key) => activeKeys.push(key),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-9' });
|
||||
|
||||
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
|
||||
['set_property', 'sub-delay', 1.25],
|
||||
['set_property', 'sid', 11],
|
||||
]);
|
||||
assert.deepEqual(activeKeys, [{ itemId: 'item-9', streamIndex: 3 }]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles applies saved delay before selecting japanese stream', async () => {
|
||||
test('preload jellyfin subtitles resets delay before selecting japanese stream', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
@@ -516,14 +466,13 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
getSavedSubtitleDelay: () => 1.25,
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-9' });
|
||||
|
||||
const delayIndex = commands.findIndex(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 1.25,
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 0,
|
||||
);
|
||||
const selectedSidIndex = commands.findIndex(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11,
|
||||
@@ -533,143 +482,6 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s
|
||||
assert.ok(delayIndex < selectedSidIndex);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles auto-aligns late japanese track from english reference', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const savedDelays: Array<{ itemId: string; streamIndex: number; delaySeconds: number }> = [];
|
||||
const primarySrt = `1
|
||||
00:00:34,935 --> 00:00:36,937
|
||||
Japanese 1
|
||||
|
||||
2
|
||||
00:00:36,937 --> 00:00:41,441
|
||||
Japanese 2
|
||||
|
||||
3
|
||||
00:00:41,441 --> 00:00:45,279
|
||||
Japanese 3
|
||||
|
||||
4
|
||||
00:00:45,279 --> 00:00:48,115
|
||||
Japanese 4
|
||||
|
||||
5
|
||||
00:00:48,115 --> 00:00:52,286
|
||||
Japanese 5
|
||||
|
||||
6
|
||||
00:00:52,286 --> 00:00:54,955
|
||||
Japanese 6
|
||||
|
||||
7
|
||||
00:00:54,955 --> 00:00:59,793
|
||||
Japanese 7
|
||||
|
||||
8
|
||||
00:00:59,793 --> 00:01:03,630
|
||||
Japanese 8
|
||||
|
||||
9
|
||||
00:01:03,630 --> 00:01:07,634
|
||||
Japanese 9
|
||||
|
||||
10
|
||||
00:01:07,634 --> 00:01:13,040
|
||||
Japanese 10
|
||||
|
||||
11
|
||||
00:01:16,643 --> 00:01:20,814
|
||||
Japanese 11
|
||||
|
||||
12
|
||||
00:01:20,814 --> 00:01:23,116
|
||||
Japanese 12
|
||||
|
||||
13
|
||||
00:01:27,988 --> 00:01:30,991
|
||||
Japanese 13
|
||||
|
||||
14
|
||||
00:01:30,991 --> 00:01:34,094
|
||||
Japanese 14
|
||||
|
||||
15
|
||||
00:01:34,094 --> 00:01:37,097
|
||||
Japanese 15
|
||||
|
||||
16
|
||||
00:01:37,097 --> 00:01:39,100
|
||||
Japanese 16
|
||||
`;
|
||||
const referenceAss = `[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:03.46,0:00:08.73,Default,,0,0,0,,English 1
|
||||
Dialogue: 0,0:00:09.48,0:00:13.61,Default,,0,0,0,,English 2
|
||||
Dialogue: 0,0:00:13.61,0:00:19.64,Default,,0,0,0,,English 3
|
||||
Dialogue: 0,0:00:21.40,0:00:27.32,Default,,0,0,0,,English 4
|
||||
Dialogue: 0,0:00:28.16,0:00:31.75,Default,,0,0,0,,English 5
|
||||
Dialogue: 0,0:00:32.06,0:00:34.52,Default,,0,0,0,,English 6
|
||||
Dialogue: 0,0:00:35.93,0:00:40.57,Default,,0,0,0,,English 7
|
||||
Dialogue: 0,0:00:45.10,0:00:51.01,Default,,0,0,0,,English 8
|
||||
Dialogue: 0,0:00:56.57,0:00:59.12,Default,,0,0,0,,English 9
|
||||
Dialogue: 0,0:00:59.68,0:01:02.44,Default,,0,0,0,,English 10
|
||||
Dialogue: 0,0:01:02.44,0:01:05.56,Default,,0,0,0,,English 11
|
||||
Dialogue: 0,0:01:05.56,0:01:06.87,Default,,0,0,0,,English 12
|
||||
`;
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
|
||||
{ index: 4, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.ass' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 10,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 12,
|
||||
lang: 'eng',
|
||||
title: 'English',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/4.ass',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
cacheSubtitleTrack: async (track) => ({
|
||||
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.${track.index === 4 ? 'ass' : 'srt'}`,
|
||||
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||
}),
|
||||
getSavedSubtitleDelay: () => null,
|
||||
loadSubtitleSourceText: async (source) =>
|
||||
source.endsWith('.ass') ? referenceAss : primarySrt,
|
||||
saveSubtitleDelay: (itemId, streamIndex, delaySeconds) => {
|
||||
savedDelays.push({ itemId, streamIndex, delaySeconds });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-9' });
|
||||
|
||||
const delayCommand = commands.find(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sub-delay',
|
||||
);
|
||||
assert.ok(delayCommand);
|
||||
const delaySeconds = delayCommand[2];
|
||||
if (typeof delaySeconds !== 'number') {
|
||||
assert.fail('Expected numeric subtitle delay.');
|
||||
}
|
||||
assert.ok(delaySeconds > -32);
|
||||
assert.ok(delaySeconds < -31);
|
||||
assert.deepEqual(savedDelays, [{ itemId: 'item-9', streamIndex: 0, delaySeconds }]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles accepts numeric string mpv track ids', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||
import { estimateSubtitleTimingOffset } from '../../core/services/subtitle-timing-offset';
|
||||
|
||||
type JellyfinSession = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
@@ -35,11 +32,6 @@ type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
|
||||
source: JellyfinSubtitleTrack;
|
||||
};
|
||||
|
||||
type JellyfinSubtitleDelayKey = {
|
||||
itemId: string;
|
||||
streamIndex: number;
|
||||
};
|
||||
|
||||
type MpvSubtitleTrack = {
|
||||
id: number;
|
||||
lang: string;
|
||||
@@ -257,54 +249,6 @@ async function waitForPreferredSubtitleTracks(
|
||||
return subtitleTracks;
|
||||
}
|
||||
|
||||
async function estimateSubtitleDelayFromReference(
|
||||
deps: {
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
},
|
||||
primaryTrack: CachedExternalSubtitleTrack | null,
|
||||
referenceTrack: CachedExternalSubtitleTrack | null,
|
||||
): Promise<number | null> {
|
||||
if (!deps.loadSubtitleSourceText || !primaryTrack || !referenceTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [primaryContent, referenceContent] = await Promise.all([
|
||||
deps.loadSubtitleSourceText(primaryTrack.path),
|
||||
deps.loadSubtitleSourceText(referenceTrack.path),
|
||||
]);
|
||||
const primaryCues = parseSubtitleCues(primaryContent, primaryTrack.path);
|
||||
const referenceCues = parseSubtitleCues(referenceContent, referenceTrack.path);
|
||||
return estimateSubtitleTimingOffset(primaryCues, referenceCues)?.offsetSeconds ?? null;
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to auto-align Jellyfin subtitle timing', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveEstimatedSubtitleDelay(
|
||||
deps: {
|
||||
saveSubtitleDelay?: (
|
||||
itemId: string,
|
||||
streamIndex: number,
|
||||
delaySeconds: number,
|
||||
) => boolean | void;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
},
|
||||
key: JellyfinSubtitleDelayKey,
|
||||
delaySeconds: number,
|
||||
): void {
|
||||
try {
|
||||
const saved = deps.saveSubtitleDelay?.(key.itemId, key.streamIndex, delaySeconds);
|
||||
if (saved === false) {
|
||||
deps.logDebug('Failed to save Jellyfin auto subtitle delay', key);
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to save Jellyfin auto subtitle delay', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
listJellyfinSubtitleTracks: (
|
||||
session: JellyfinSession,
|
||||
@@ -316,10 +260,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
wait: (ms: number) => Promise<void>;
|
||||
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
|
||||
cleanupCachedSubtitles: (dirs: string[]) => void;
|
||||
getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null;
|
||||
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
|
||||
initSubtitlePrefetch?: (sourcePath: string) => void | Promise<void>;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
}): PreloadJellyfinExternalSubtitlesHandler {
|
||||
@@ -357,6 +297,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
itemId: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
resetManagedSubtitleDelay();
|
||||
try {
|
||||
cleanupActiveCache();
|
||||
} catch (error) {
|
||||
@@ -369,8 +310,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
);
|
||||
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
|
||||
if (externalTracks.length === 0) {
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -427,40 +366,13 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
japanesePrimaryId,
|
||||
);
|
||||
if (selectedCachedTrack) {
|
||||
const delayKey = { itemId: params.itemId, streamIndex: selectedCachedTrack.source.index };
|
||||
deps.setActiveSubtitleDelayKey?.(delayKey);
|
||||
const savedDelay = deps.getSavedSubtitleDelay?.(delayKey.itemId, delayKey.streamIndex);
|
||||
if (typeof savedDelay === 'number' && Number.isFinite(savedDelay)) {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', savedDelay]);
|
||||
} else {
|
||||
const referenceCachedTrack = findCachedTrackForMpvTrackId(
|
||||
resolvedSubtitleTracks,
|
||||
cachedTracks,
|
||||
englishSecondaryId,
|
||||
);
|
||||
const estimatedDelay = await estimateSubtitleDelayFromReference(
|
||||
deps,
|
||||
selectedCachedTrack,
|
||||
referenceCachedTrack,
|
||||
);
|
||||
if (estimatedDelay !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', estimatedDelay]);
|
||||
saveEstimatedSubtitleDelay(deps, delayKey, estimatedDelay);
|
||||
} else {
|
||||
resetManagedSubtitleDelay();
|
||||
}
|
||||
}
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path);
|
||||
} else {
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
}
|
||||
} else {
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
}
|
||||
|
||||
if (englishSecondaryId !== null) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseAssCues } from '../../core/services/subtitle-cue-parser';
|
||||
import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './mpv-main-event-main-deps';
|
||||
|
||||
test('mpv main event main deps map app state updates and delegate callbacks', async () => {
|
||||
@@ -503,14 +504,21 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer
|
||||
assert.deepEqual(timing.slice(3), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
||||
assert.equal(immersion.length, 3);
|
||||
|
||||
// A jump of exactly the seek threshold counts as a seek, matching the time-pos
|
||||
// handler's own `>=` boundary.
|
||||
handlers.onTimePosUpdate?.(4.5);
|
||||
handlers.onTimePosUpdate?.(2);
|
||||
// Jumping back to a brief previous line moves time-pos by less than the general
|
||||
// seek threshold. It is still a backward seek, so the revisited line records
|
||||
// again; otherwise multi-line copy would keep treating the later line as current.
|
||||
handlers.onTimePosUpdate?.(3.9);
|
||||
handlers.onTimePosUpdate?.(2.9);
|
||||
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
||||
|
||||
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
||||
|
||||
// Tiny time-pos jitter is not a seek and must not re-record the line.
|
||||
handlers.onTimePosUpdate?.(3.0);
|
||||
handlers.onTimePosUpdate?.(2.9);
|
||||
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
||||
assert.equal(timing.length, 5);
|
||||
|
||||
handlers.recordImmersionSubtitleLine('Maid\nCafe', 10, 12);
|
||||
handlers.recordSubtitleTiming('Maid\nCafe', 10, 12);
|
||||
assert.equal(immersion.length, 3);
|
||||
@@ -575,3 +583,156 @@ test('subtitle-track changes stop stale canonical cues from substituting immedia
|
||||
assert.equal(appState.activeParsedSubtitleSource, null);
|
||||
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今\n手にある');
|
||||
});
|
||||
|
||||
test('subtitle recorders drop ASS furigana events the same way the display does', () => {
|
||||
// Broadcast-caption ASS (Caption2Ass style): furigana are separate half-scale events
|
||||
// positioned above their base line, and mpv lists them as their own live lines.
|
||||
const cues = parseAssCues(
|
||||
[
|
||||
'[Script Info]',
|
||||
'PlayResX: 960',
|
||||
'PlayResY: 540',
|
||||
'',
|
||||
'[V4+ Styles]',
|
||||
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
|
||||
'Style: Default,Yu Gothic,46,&H00FFFFFF,&H000000FF,&H00000000,&H7F000000,1,0,0,0,100,100,4,0,1,2,2,1,0,0,0,1',
|
||||
'',
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:04:56.26,0:04:59.63,Default,,0000,0000,0000,,{\\pos(472,443)\\fscx50\\fscy50}あくむ',
|
||||
'Dialogue: 0,0:04:56.26,0:04:59.63,Default,,0000,0000,0000,,{\\pos(172,497)}こんな短時間で{\\fscx50} {\\fscx100}悪夢{\\fscx50} {\\fscx100}見んなよ{\\fscx50}。',
|
||||
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(172,407)\\fscx50}({\\fscx100}平{\\fscx50}){\\fscx100}暗記教科は{\\fscx50} {\\fscx100}もう',
|
||||
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(332,443)\\fscx50\\fscy50}かた',
|
||||
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(412,443)\\fscx50\\fscy50}ぱし',
|
||||
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(172,497)}とにかく片っ端から覚えるんだよ{\\fscx50}。',
|
||||
].join('\n'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
[
|
||||
'こんな短時間で 悪夢 見んなよ。',
|
||||
'(平)暗記教科は もう',
|
||||
'とにかく片っ端から覚えるんだよ。',
|
||||
],
|
||||
);
|
||||
|
||||
const immersion: string[] = [];
|
||||
const timing: string[] = [];
|
||||
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState: {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: { currentTimePos: 299.7 },
|
||||
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
|
||||
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
|
||||
activeParsedSubtitleCues: cues,
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: false,
|
||||
},
|
||||
getQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
ensureImmersionTrackerInitialized: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
})();
|
||||
|
||||
const liveText = '(平)暗記教科は もう\nかた\nぱし\nとにかく片っ端から覚えるんだよ。';
|
||||
const expected = '(平)暗記教科は もう\n\nとにかく片っ端から覚えるんだよ。';
|
||||
assert.equal(handlers.resolveSubtitleText?.(liveText), expected);
|
||||
handlers.recordImmersionSubtitleLine(liveText, 299.63, 303.54);
|
||||
handlers.recordSubtitleTiming(liveText, 299.63, 303.54);
|
||||
|
||||
assert.deepEqual(immersion, [expected]);
|
||||
assert.deepEqual(timing, [expected]);
|
||||
});
|
||||
|
||||
test('a resolved line survives recording while a fragment grid is on screen', () => {
|
||||
// Fragment stripping drops every line it can trace back to a cue, and returns nothing
|
||||
// at all when a fragment grid is nearby. Text the parsed cues already resolved is a
|
||||
// complete line, not raw mpv output, so it must not be fed through that path.
|
||||
const immersion: string[] = [];
|
||||
const timing: string[] = [];
|
||||
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState: {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: { currentTimePos: 3.2 },
|
||||
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
|
||||
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
|
||||
activeParsedSubtitleCues: [
|
||||
{ startTime: 3, endTime: 6, text: '飛び越えてみたくて', source: 'canonical-ass' },
|
||||
{
|
||||
startTime: 3,
|
||||
endTime: 6,
|
||||
text: 'MaidCafeMaidCafe',
|
||||
source: 'reconstructed-ass',
|
||||
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
|
||||
},
|
||||
],
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: false,
|
||||
},
|
||||
getQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
ensureImmersionTrackerInitialized: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
})();
|
||||
|
||||
// The grid fragment beside the lyric keeps canonical substitution from applying, so
|
||||
// recording falls to the parsed view -- which is where the whole line is recovered.
|
||||
const liveText = '飛び越え\nMaid';
|
||||
assert.equal(handlers.resolveSubtitleText?.(liveText), '飛び越えてみたくて');
|
||||
handlers.recordImmersionSubtitleLine(liveText, 3, 6);
|
||||
handlers.recordSubtitleTiming(liveText, 3, 6);
|
||||
|
||||
assert.deepEqual(immersion, ['飛び越えてみたくて']);
|
||||
assert.deepEqual(timing, ['飛び越えてみたくて']);
|
||||
|
||||
// A spacer event left as literal control debris is not a subtitle line. The display
|
||||
// drops it, so no recorder may keep it either.
|
||||
assert.equal(handlers.resolveSubtitleText?.('\\'), '');
|
||||
handlers.recordImmersionSubtitleLine('\\', 3, 6);
|
||||
handlers.recordSubtitleTiming('\\', 3, 6);
|
||||
|
||||
assert.equal(immersion.length, 1);
|
||||
assert.equal(timing.length, 1);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
|
||||
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
|
||||
import { SEEK_LIKE_TIME_DELTA_SECONDS } from './mpv-main-event-actions';
|
||||
import {
|
||||
resolveCanonicalPrimarySubtitle,
|
||||
resolvePrimarySubtitleText,
|
||||
stripCanonicalFragmentLines,
|
||||
resolveRecordedPrimarySubtitleText,
|
||||
} from './primary-subtitle-text';
|
||||
|
||||
type AnilistPostWatchRunOptions = {
|
||||
@@ -115,6 +114,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
// after the change is dropped instead of landing in the next session.
|
||||
let subtitleSessionEpoch = 0;
|
||||
let lastTimePosForTimingReset: number | null = null;
|
||||
// Small margin so time-pos jitter is not mistaken for a backward seek.
|
||||
const BACKWARD_SEEK_TIMING_RESET_SECONDS = 0.25;
|
||||
const canonicalCueKey = (cue: SubtitleCue): string =>
|
||||
`${cue.startTime}|${cue.endTime}|${cue.text}`;
|
||||
const resetSubtitleDeduplication = (): void => {
|
||||
@@ -130,10 +131,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
currentTimeSec: startSec,
|
||||
cues: deps.appState.activeParsedSubtitleCues,
|
||||
});
|
||||
// When substitution declined because dialogue shares the screen with a song, record
|
||||
// the dialogue alone rather than the combined dialogue-plus-fragments stack.
|
||||
const stripFragmentsForRecording = (liveText: string, startSec: number) =>
|
||||
stripCanonicalFragmentLines({
|
||||
// Recorders see the same text the overlay displays: mpv's live `sub-text` lists every
|
||||
// simultaneously active ASS event, including furigana events the parser folded into
|
||||
// their base line. Cues the caller already resolved canonically are recorded one by
|
||||
// one above; everything else goes through the shared recording resolution.
|
||||
const resolveTextForRecording = (liveText: string, startSec: number): string =>
|
||||
resolveRecordedPrimarySubtitleText({
|
||||
liveText,
|
||||
currentTimeSec: startSec,
|
||||
cues: deps.appState.activeParsedSubtitleCues,
|
||||
@@ -217,7 +220,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
}
|
||||
return;
|
||||
}
|
||||
text = stripFragmentsForRecording(text, start);
|
||||
text = resolveTextForRecording(text, start);
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
@@ -231,7 +234,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
||||
const canonical = resolveCanonicalSample(text, start);
|
||||
if (!canonical) {
|
||||
const recordableText = stripFragmentsForRecording(text, start);
|
||||
const recordableText = resolveTextForRecording(text, start);
|
||||
if (!recordableText.trim()) {
|
||||
return;
|
||||
}
|
||||
@@ -344,13 +347,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
consumeExplicitSeek: deps.consumeExplicitSeek,
|
||||
onTimePosUpdate: (time: number) => {
|
||||
// Timing history is a viewing log: after a real backward seek, a rewatched
|
||||
// canonical line should enter it again. Immersion stats keep their
|
||||
// once-per-media deduplication and are not reset here.
|
||||
// Timing history is a viewing log: after any backward seek, a rewatched canonical
|
||||
// line should enter it again so multi-line copy treats it as the current line.
|
||||
// Playback never moves time-pos backward on its own, so even a short jump to a
|
||||
// brief previous line counts. Immersion stats keep their once-per-media
|
||||
// deduplication and are not reset here.
|
||||
if (
|
||||
Number.isFinite(time) &&
|
||||
lastTimePosForTimingReset !== null &&
|
||||
time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS
|
||||
time <= lastTimePosForTimingReset - BACKWARD_SEEK_TIMING_RESET_SECONDS
|
||||
) {
|
||||
recordedTimingCanonicalKeys.clear();
|
||||
}
|
||||
|
||||
@@ -674,3 +674,31 @@ test('resolvePrimarySubtitleText keeps source order when no cue declares a place
|
||||
'First line\n\nSecond line',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText publishes a wrapped caption sentence as one cue', () => {
|
||||
// mpv still reports the two source rows (plus the ruby row) as separate live lines, so
|
||||
// the merged cue must explain all of them and come back as a single-break line the
|
||||
// display layer may flatten, not as a two-cue boundary.
|
||||
const ass = [
|
||||
'[Script Info]',
|
||||
'PlayResY: 540',
|
||||
'',
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(232,437)\\fscx50}({\\fscx100}東{\\fscx50}){\\fscx100}≪好きだと',
|
||||
'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(292,443)\\fscx50\\fscy50}じかく',
|
||||
'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(232,497)}自覚してしまったものの➡',
|
||||
// A second labeled turn, so the script reads as broadcast captions.
|
||||
'Dialogue: 0,0:02:44.43,0:02:47.37,Default,,0,0,0,,{\\pos(212,497)\\fscx50}({\\fscx100}平{\\fscx50}){\\fscx100}どうした?',
|
||||
].join('\n');
|
||||
const cues = parseSubtitleCues(ass, 'polar-opposites-s02e09.ass');
|
||||
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '(東)≪好きだと\nじかく\n自覚してしまったものの➡',
|
||||
currentTimeSec: 163,
|
||||
cues,
|
||||
}),
|
||||
'(東)≪好きだと\n自覚してしまったものの➡',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,13 @@ function cuesUseAssSyntax(cues: readonly SubtitleCue[] | null | undefined): bool
|
||||
);
|
||||
}
|
||||
|
||||
function decodedLiveText(
|
||||
liveText: string,
|
||||
cues: readonly SubtitleCue[] | null | undefined,
|
||||
): string {
|
||||
return cuesUseAssSyntax(cues) ? removeAssControlDebrisLines(liveText) : liveText;
|
||||
}
|
||||
|
||||
function animationSpan(cue: SubtitleCue): { start: number; end: number } {
|
||||
return {
|
||||
start: cue.animationStartTime ?? cue.startTime,
|
||||
@@ -290,14 +297,34 @@ export function stripCanonicalFragmentLines(options: {
|
||||
return removeLiveGlyphFragmentLines(options.liveText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording text for a live sample. Callers substitute canonical cues themselves and
|
||||
* record those cue by cue, so what is resolved here is the parsed view -- the one that
|
||||
* folds ASS furigana events back into their base line. Its text is already a complete
|
||||
* line, while fragment stripping takes raw mpv text and would discard a resolved line
|
||||
* whole while a fragment grid is on screen, so only one of the two ever runs.
|
||||
*/
|
||||
export function resolveRecordedPrimarySubtitleText(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): string {
|
||||
const liveText = decodedLiveText(options.liveText, options.cues);
|
||||
if (!liveText.trim()) {
|
||||
return liveText;
|
||||
}
|
||||
return (
|
||||
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
|
||||
stripCanonicalFragmentLines({ ...options, liveText })
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePrimarySubtitleText(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): string {
|
||||
const liveText = cuesUseAssSyntax(options.cues)
|
||||
? removeAssControlDebrisLines(options.liveText)
|
||||
: options.liveText;
|
||||
const liveText = decodedLiveText(options.liveText, options.cues);
|
||||
if (!liveText.trim()) {
|
||||
return liveText;
|
||||
}
|
||||
|
||||
@@ -499,8 +499,8 @@
|
||||
<div id="kikuSelectionStep">
|
||||
<div class="kiku-info-text">
|
||||
A card with the same expression already exists. Select which card to keep. The other
|
||||
card's content will be merged using Kiku field grouping. You can choose whether to
|
||||
delete the duplicate.
|
||||
card's content will be merged using field grouping. You can choose whether to delete
|
||||
the duplicate.
|
||||
</div>
|
||||
<div class="kiku-cards-container">
|
||||
<div id="kikuCard1" class="kiku-card active" tabindex="0">
|
||||
|
||||
@@ -60,6 +60,7 @@ const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
|
||||
'subtitle.annotation.jlpt',
|
||||
'subtitle.annotation.frequency',
|
||||
'anki.kikuFieldGrouping',
|
||||
'anki.senrenFieldGrouping',
|
||||
'anki.nPlusOneMatchMode',
|
||||
];
|
||||
|
||||
|
||||
@@ -67,8 +67,13 @@ export class SubtitleTimingTracker {
|
||||
|
||||
// Check for duplicate of most recent entry (deduplicate adjacent repeats)
|
||||
const lastEntry = this.history[this.history.length - 1];
|
||||
if (lastEntry && lastEntry.timingKey === timingKey) {
|
||||
// Update timing to most recent occurrence
|
||||
if (
|
||||
lastEntry &&
|
||||
lastEntry.timingKey === timingKey &&
|
||||
lastEntry.startTime === startTime &&
|
||||
lastEntry.endTime === endTime
|
||||
) {
|
||||
// Refresh metadata for repeated notifications of the same subtitle event.
|
||||
lastEntry.startTime = startTime;
|
||||
lastEntry.endTime = endTime;
|
||||
lastEntry.secondaryText = displaySecondaryText;
|
||||
@@ -107,28 +112,20 @@ export class SubtitleTimingTracker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent subtitle blocks in chronological order.
|
||||
* Returns the last `count` subtitle events (oldest → newest).
|
||||
* Get recent subtitle blocks in timeline order.
|
||||
* Returns up to `count` known subtitle events ending at the current event.
|
||||
* Blocks preserve internal line breaks and are joined with blank lines.
|
||||
*/
|
||||
getRecentBlocks(count: number): string[] {
|
||||
if (count <= 0) return [];
|
||||
if (count > this.history.length) {
|
||||
count = this.history.length;
|
||||
}
|
||||
return this.history.slice(-count).map((entry) => entry.displayText);
|
||||
return this.getRecentTimelineEntries(count).map((entry) => entry.displayText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent subtitle blocks with their original event timings.
|
||||
* Returns the last `count` subtitle events (oldest → newest).
|
||||
* Get recent subtitle blocks with their original event timings in timeline order.
|
||||
* Returns up to `count` known subtitle events ending at the current event.
|
||||
*/
|
||||
getRecentEntries(count: number): SubtitleTimingBlock[] {
|
||||
if (count <= 0) return [];
|
||||
if (count > this.history.length) {
|
||||
count = this.history.length;
|
||||
}
|
||||
return this.history.slice(-count).map((entry) => ({
|
||||
return this.getRecentTimelineEntries(count).map((entry) => ({
|
||||
displayText: entry.displayText,
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
@@ -144,6 +141,43 @@ export class SubtitleTimingTracker {
|
||||
return lastEntry ? lastEntry.displayText : null;
|
||||
}
|
||||
|
||||
private getRecentTimelineEntries(count: number): HistoryEntry[] {
|
||||
if (count <= 0) return [];
|
||||
|
||||
const currentEntry = this.history[this.history.length - 1];
|
||||
if (!currentEntry) return [];
|
||||
|
||||
const timelineEntries: HistoryEntry[] = [];
|
||||
for (const entry of this.history) {
|
||||
const existingIndex = timelineEntries.findIndex((candidate) =>
|
||||
this.isSameSubtitleEvent(candidate, entry),
|
||||
);
|
||||
if (existingIndex === -1) {
|
||||
timelineEntries.push(entry);
|
||||
} else {
|
||||
timelineEntries[existingIndex] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
timelineEntries.sort(
|
||||
(left, right) => left.startTime - right.startTime || left.endTime - right.endTime,
|
||||
);
|
||||
const currentIndex = timelineEntries.findIndex((entry) =>
|
||||
this.isSameSubtitleEvent(entry, currentEntry),
|
||||
);
|
||||
if (currentIndex === -1) return [];
|
||||
|
||||
return timelineEntries.slice(Math.max(0, currentIndex - count + 1), currentIndex + 1);
|
||||
}
|
||||
|
||||
private isSameSubtitleEvent(left: HistoryEntry, right: HistoryEntry): boolean {
|
||||
return (
|
||||
left.timingKey === right.timingKey &&
|
||||
left.startTime === right.startTime &&
|
||||
left.endTime === right.endTime
|
||||
);
|
||||
}
|
||||
|
||||
private findFuzzyMatch(text: string): { startTime: number; endTime: number } | null {
|
||||
let bestMatch: TimingEntry | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
@@ -195,6 +195,11 @@ export interface AnkiConnectConfig {
|
||||
fieldGrouping?: 'auto' | 'manual' | 'disabled';
|
||||
deleteDuplicateInAuto?: boolean;
|
||||
};
|
||||
isSenren?: {
|
||||
enabled?: boolean;
|
||||
fieldGrouping?: 'auto' | 'manual' | 'disabled';
|
||||
deleteDuplicateInAuto?: boolean;
|
||||
};
|
||||
lapisKiku?: {
|
||||
wordCardKind?: WordCardKind;
|
||||
};
|
||||
|
||||
@@ -286,6 +286,11 @@ export interface ResolvedConfig {
|
||||
fieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
deleteDuplicateInAuto: boolean;
|
||||
};
|
||||
isSenren: {
|
||||
enabled: boolean;
|
||||
fieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
deleteDuplicateInAuto: boolean;
|
||||
};
|
||||
lapisKiku: {
|
||||
wordCardKind: WordCardKind;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export type RuntimeOptionId =
|
||||
| 'subtitle.annotation.jlpt'
|
||||
| 'subtitle.annotation.frequency'
|
||||
| 'anki.kikuFieldGrouping'
|
||||
| 'anki.senrenFieldGrouping'
|
||||
| 'anki.nPlusOneMatchMode';
|
||||
|
||||
export type RuntimeOptionScope = 'ankiConnect' | 'subtitle';
|
||||
|
||||
Reference in New Issue
Block a user