mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 05:16:20 -07:00
fix(anki): separate word audio mapping for animation sync (#256)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: anki
|
||||||
|
|
||||||
|
- Added `ankiConnect.fields.wordAudio` to read word audio separately from the generated sentence-audio destination, fixing animated images that start moving immediately when `fields.audio` points to `SentenceAudio`.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: docs
|
||||||
|
area: anki
|
||||||
|
|
||||||
|
- Documented the separate word-audio mapping for animated-image synchronization and that existing images need regeneration to pick up the corrected freeze.
|
||||||
@@ -541,7 +541,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// 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.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/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.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -562,6 +562,7 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"word": "Expression", // Card field for the mined word or expression text.
|
"word": "Expression", // Card field for the mined word or expression text.
|
||||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||||
|
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
|
||||||
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||||
"sentence": "Sentence", // Card field that receives the source sentence text.
|
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||||
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
|
|||||||
"fields": {
|
"fields": {
|
||||||
"word": "Expression", // mined word / expression text
|
"word": "Expression", // mined word / expression text
|
||||||
"audio": "SentenceAudio", // sentence audio clip cut from the video
|
"audio": "SentenceAudio", // sentence audio clip cut from the video
|
||||||
|
"wordAudio": "ExpressionAudio", // existing Yomitan word audio, read for animation sync
|
||||||
"image": "Picture", // screenshot or animated clip
|
"image": "Picture", // screenshot or animated clip
|
||||||
"sentence": "Sentence", // subtitle text
|
"sentence": "Sentence", // subtitle text
|
||||||
"miscInfo": "MiscInfo" // metadata (filename, timestamp)
|
"miscInfo": "MiscInfo" // metadata (filename, timestamp)
|
||||||
@@ -136,6 +137,8 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
|
|||||||
|
|
||||||
Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error.
|
Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error.
|
||||||
|
|
||||||
|
`fields.wordAudio` selects the existing dictionary-audio field used to calculate the animated image's opening freeze. This mapping only reads audio; `fields.audio` still controls where generated sentence audio is written. See [config.example.jsonc](/config.example.jsonc) for defaults.
|
||||||
|
|
||||||
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
|
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
|
||||||
|
|
||||||
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
|
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
|
||||||
@@ -241,7 +244,7 @@ SubMiner can produce an animated AVIF spanning the subtitle duration instead of
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing word-audio duration, so the motion starts together with the sentence audio.
|
Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing audio duration in `fields.wordAudio`, so the motion starts together with the sentence audio. The freeze is baked into the image when mined; changing the mapping does not repair previously generated images.
|
||||||
|
|
||||||
### Behavior options
|
### Behavior options
|
||||||
|
|
||||||
|
|||||||
@@ -976,6 +976,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
||||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||||
| `fields.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. |
|
| `fields.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. |
|
||||||
|
| `fields.wordAudio` | string | Existing word-audio field read for the animated image's opening freeze. Independent of the sentence-audio destination in `fields.audio`; this mapping does not write audio. See [config.example.jsonc](/config.example.jsonc) for defaults. |
|
||||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||||
|
|||||||
@@ -541,7 +541,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// 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.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/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.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -562,6 +562,7 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"word": "Expression", // Card field for the mined word or expression text.
|
"word": "Expression", // Card field for the mined word or expression text.
|
||||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||||
|
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
|
||||||
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||||
"sentence": "Sentence", // Card field that receives the source sentence text.
|
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||||
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
|
|||||||
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
|
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
|
||||||
config: {
|
config: {
|
||||||
fields: {
|
fields: {
|
||||||
audio: 'ExpressionAudio',
|
audio: 'SentenceAudio',
|
||||||
|
wordAudio: 'Pronunciation',
|
||||||
},
|
},
|
||||||
media: {
|
media: {
|
||||||
imageType: 'avif',
|
imageType: 'avif',
|
||||||
@@ -25,7 +26,8 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
|
|||||||
noteInfo: {
|
noteInfo: {
|
||||||
noteId: 42,
|
noteId: 42,
|
||||||
fields: {
|
fields: {
|
||||||
ExpressionAudio: {
|
SentenceAudio: { value: '[sound:sentence.mp3]' },
|
||||||
|
Pronunciation: {
|
||||||
value: '[sound:word.mp3][sound:alt.ogg]',
|
value: '[sound:word.mp3][sound:alt.ogg]',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -121,3 +123,32 @@ test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled
|
|||||||
|
|
||||||
assert.equal(leadInSeconds, 0);
|
assert.equal(leadInSeconds, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const sentenceAudio of ['', '[sound:sentence.mp3]']) {
|
||||||
|
test(`word audio defaults independently of sentence audio (${sentenceAudio ? 'existing' : 'new'} note)`, async () => {
|
||||||
|
const retrieved: string[] = [];
|
||||||
|
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
|
||||||
|
config: {
|
||||||
|
fields: { audio: 'SentenceAudio' },
|
||||||
|
media: { imageType: 'avif' },
|
||||||
|
},
|
||||||
|
noteInfo: {
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
ExpressionAudio: { value: '[sound:word.mp3]' },
|
||||||
|
SentenceAudio: { value: sentenceAudio },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
|
||||||
|
preferredNames.find((name) => name !== undefined && name in noteInfo.fields) ?? null,
|
||||||
|
retrieveMediaFileBase64: async (filename) => {
|
||||||
|
retrieved.push(filename);
|
||||||
|
return 'd29yZA==';
|
||||||
|
},
|
||||||
|
probeAudioDurationSeconds: async (_buffer, filename) => (filename === 'word.mp3' ? 0.6 : 4),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(leadInSeconds, 0.6);
|
||||||
|
assert.deepEqual(retrieved, ['word.mp3']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
|
|||||||
|
|
||||||
const wordAudioFieldName = resolveConfiguredFieldName(
|
const wordAudioFieldName = resolveConfiguredFieldName(
|
||||||
noteInfo,
|
noteInfo,
|
||||||
config.fields?.audio,
|
config.fields?.wordAudio,
|
||||||
DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
|
DEFAULT_ANKI_CONNECT_CONFIG.fields.wordAudio,
|
||||||
);
|
);
|
||||||
if (!wordAudioFieldName) {
|
if (!wordAudioFieldName) {
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
fields: {
|
fields: {
|
||||||
word: 'Expression',
|
word: 'Expression',
|
||||||
audio: 'ExpressionAudio',
|
audio: 'ExpressionAudio',
|
||||||
|
wordAudio: 'ExpressionAudio',
|
||||||
image: 'Picture',
|
image: 'Picture',
|
||||||
sentence: 'Sentence',
|
sentence: 'Sentence',
|
||||||
miscInfo: 'MiscInfo',
|
miscInfo: 'MiscInfo',
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.ankiConnect.fields.audio,
|
defaultValue: defaultConfig.ankiConnect.fields.audio,
|
||||||
description: 'Card field that receives generated sentence audio.',
|
description: 'Card field that receives generated sentence audio.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ankiConnect.fields.wordAudio',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.ankiConnect.fields.wordAudio,
|
||||||
|
description:
|
||||||
|
'Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.fields.image',
|
path: 'ankiConnect.fields.image',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
title: 'AnkiConnect Integration',
|
title: 'AnkiConnect Integration',
|
||||||
description: ['Automatic Anki updates and media generation options.'],
|
description: ['Automatic Anki updates and media generation options.'],
|
||||||
notes: [
|
notes: [
|
||||||
'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.',
|
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/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.',
|
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||||
'Most other AnkiConnect settings still require restart.',
|
'Most other AnkiConnect settings still require restart.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
|||||||
'ankiConnect.nPlusOne.minSentenceWords',
|
'ankiConnect.nPlusOne.minSentenceWords',
|
||||||
'ankiConnect.fields.word',
|
'ankiConnect.fields.word',
|
||||||
'ankiConnect.fields.audio',
|
'ankiConnect.fields.audio',
|
||||||
|
'ankiConnect.fields.wordAudio',
|
||||||
'ankiConnect.fields.image',
|
'ankiConnect.fields.image',
|
||||||
'ankiConnect.fields.sentence',
|
'ankiConnect.fields.sentence',
|
||||||
'ankiConnect.fields.miscInfo',
|
'ankiConnect.fields.miscInfo',
|
||||||
|
|||||||
@@ -290,6 +290,28 @@ test('accepts ankiConnect.media.syncAnimatedImageToWordAudio override', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('word audio mapping defaults and validates independently of sentence audio', () => {
|
||||||
|
for (const wordAudio of [undefined, 'Pronunciation', 7]) {
|
||||||
|
const { context, warnings } = makeContext({
|
||||||
|
fields: {
|
||||||
|
audio: 'SentenceAudio',
|
||||||
|
...(wordAudio !== undefined ? { wordAudio } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applyAnkiConnectResolution(context);
|
||||||
|
|
||||||
|
assert.equal(context.resolved.ankiConnect.fields.audio, 'SentenceAudio');
|
||||||
|
assert.equal(
|
||||||
|
context.resolved.ankiConnect.fields.wordAudio,
|
||||||
|
typeof wordAudio === 'string' ? wordAudio : DEFAULT_CONFIG.ankiConnect.fields.wordAudio,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
warnings.map((warning) => warning.path),
|
||||||
|
typeof wordAudio === 'number' ? ['ankiConnect.fields.wordAudio'] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('invalid modern Anki subtrees warn and keep resolved defaults', () => {
|
test('invalid modern Anki subtrees warn and keep resolved defaults', () => {
|
||||||
const { context, warnings } = makeContext({
|
const { context, warnings } = makeContext({
|
||||||
fields: { word: 7 },
|
fields: { word: 7 },
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ export function applyModernFieldsResolution(
|
|||||||
context: ResolveContext,
|
context: ResolveContext,
|
||||||
fields: Record<string, unknown>,
|
fields: Record<string, unknown>,
|
||||||
): void {
|
): void {
|
||||||
for (const key of ['word', 'audio', 'image', 'sentence', 'miscInfo', 'translation'] as const) {
|
for (const key of [
|
||||||
|
'word',
|
||||||
|
'audio',
|
||||||
|
'wordAudio',
|
||||||
|
'image',
|
||||||
|
'sentence',
|
||||||
|
'miscInfo',
|
||||||
|
'translation',
|
||||||
|
] as const) {
|
||||||
applyModernValue(
|
applyModernValue(
|
||||||
context,
|
context,
|
||||||
fields,
|
fields,
|
||||||
|
|||||||
@@ -364,6 +364,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
|
|||||||
'ankiConnect.nPlusOne.minSentenceWords',
|
'ankiConnect.nPlusOne.minSentenceWords',
|
||||||
'ankiConnect.fields.word',
|
'ankiConnect.fields.word',
|
||||||
'ankiConnect.fields.audio',
|
'ankiConnect.fields.audio',
|
||||||
|
'ankiConnect.fields.wordAudio',
|
||||||
'ankiConnect.fields.image',
|
'ankiConnect.fields.image',
|
||||||
'ankiConnect.fields.sentence',
|
'ankiConnect.fields.sentence',
|
||||||
'ankiConnect.fields.miscInfo',
|
'ankiConnect.fields.miscInfo',
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export interface AnkiConnectConfig {
|
|||||||
fields?: {
|
fields?: {
|
||||||
word?: string;
|
word?: string;
|
||||||
audio?: string;
|
audio?: string;
|
||||||
|
wordAudio?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
sentence?: string;
|
sentence?: string;
|
||||||
miscInfo?: string;
|
miscInfo?: string;
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ export interface ResolvedConfig {
|
|||||||
fields: {
|
fields: {
|
||||||
word: string;
|
word: string;
|
||||||
audio: string;
|
audio: string;
|
||||||
|
wordAudio: string;
|
||||||
image: string;
|
image: string;
|
||||||
sentence: string;
|
sentence: string;
|
||||||
miscInfo: string;
|
miscInfo: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user