fix(anki): honor unlimited duration in stats mining (#258)

This commit is contained in:
2026-09-20 22:49:12 -07:00
committed by GitHub
parent 917d52dd94
commit 7959e1a4e5
11 changed files with 130 additions and 13 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Treat `ankiConnect.media.maxMediaDuration: 0` as unlimited for stats dashboard mining, matching overlay mining and configuration.
+1 -1
View File
@@ -591,7 +591,7 @@
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false "reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips. "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. 0 disables the cap.
}, // Media setting. }, // Media setting.
"knownWords": { "knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
+2
View File
@@ -188,6 +188,8 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`. The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
Overlay and stats-dashboard mining use the same `media.maxMediaDuration` limit. See the [configuration example](/config.example.jsonc) for its default and how to disable the cap.
Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards. Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards.
The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone. The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone.
+1 -1
View File
@@ -997,7 +997,7 @@ This example is intentionally compact. The option table below documents availabl
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | | `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. | | `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | | `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | | `media.maxMediaDuration` | number (seconds) | Maximum generated clip duration for overlay and stats-dashboard mining. See the [configuration example](/config.example.jsonc) for the default and disabling the cap. |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) | | `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) | | `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | | `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
+1 -1
View File
@@ -591,7 +591,7 @@
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false "reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips. "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. 0 disables the cap.
}, // Media setting. }, // Media setting.
"knownWords": { "knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
@@ -160,6 +160,52 @@ test('manual clipboard subtitle update replaces audio in the configured field',
); );
}); });
test('manual clipboard mining treats a zero media duration cap as unlimited', async () => {
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
const scenarios = [
{ maxMediaDuration: 0, expectedEnd: 14 },
{ maxMediaDuration: 1, expectedEnd: 13 },
];
for (const scenario of scenarios) {
const { service } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Sentence',
audio: 'ExpressionAudio',
},
media: {
generateAudio: true,
generateImage: false,
audioPadding: 0.25,
maxMediaDuration: scenario.maxMediaDuration,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
mediaGenerator: {
generateAudio: async (_path, start, end, padding) => {
audioRanges.push({ start, end, padding });
return Buffer.from('audio');
},
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
});
await service.updateLastAddedFromClipboard('字幕');
assert.deepEqual(audioRanges.at(-1), {
start: 12,
end: scenario.expectedEnd,
padding: 0.25,
});
}
});
test('manual clipboard word-card update uses configured fields with Lapis and Kiku enabled', async () => { test('manual clipboard word-card update uses configured fields with Lapis and Kiku enabled', async () => {
const { service, updatedFields } = createManualUpdateService({ const { service, updatedFields } = createManualUpdateService({
getConfig: () => getConfig: () =>
+8 -7
View File
@@ -21,6 +21,7 @@ import {
resolveAudioStreamIndexForMediaGeneration, resolveAudioStreamIndexForMediaGeneration,
type MediaGenerationInputResolverOptions, type MediaGenerationInputResolverOptions,
} from './media-source'; } from './media-source';
import { clampMediaEndTime } from './media-duration';
import { resolveWordCardKind } from './note-field-utils'; import { resolveWordCardKind } from './note-field-utils';
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media'; import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
import { resolveMpvVolumeScale } from './mpv-volume'; import { resolveMpvVolumeScale } from './mpv-volume';
@@ -233,11 +234,12 @@ export class CardCreationService {
let rangeEnd = Math.max(...timings.map((entry) => entry.endTime)); let rangeEnd = Math.max(...timings.map((entry) => entry.endTime));
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && rangeEnd - rangeStart > maxMediaDuration) { const cappedRangeEnd = clampMediaEndTime(rangeStart, rangeEnd, maxMediaDuration);
if (cappedRangeEnd !== rangeEnd) {
log.warn( log.warn(
`Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`, `Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
); );
rangeEnd = rangeStart + maxMediaDuration; rangeEnd = cappedRangeEnd;
} }
this.deps.showOsdNotification('Updating card from clipboard...'); this.deps.showOsdNotification('Updating card from clipboard...');
@@ -437,9 +439,7 @@ export class CardCreationService {
} }
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { endTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
endTime = startTime + maxMediaDuration;
}
this.deps.showOsdNotification('Marking card as audio card...'); this.deps.showOsdNotification('Marking card as audio card...');
await this.deps.withUpdateProgress('Marking audio card', async () => { await this.deps.withUpdateProgress('Marking audio card', async () => {
@@ -600,11 +600,12 @@ export class CardCreationService {
} }
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { const cappedEndTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
if (cappedEndTime !== endTime) {
log.warn( log.warn(
`Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`, `Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
); );
endTime = startTime + maxMediaDuration; endTime = cappedEndTime;
} }
try { try {
+10
View File
@@ -0,0 +1,10 @@
/** Zero or a negative cap leaves the requested end time unchanged. */
export function clampMediaEndTime(
startTime: number,
endTime: number,
maxMediaDuration: number,
): number {
return maxMediaDuration > 0 && endTime - startTime > maxMediaDuration
? startTime + maxMediaDuration
: endTime;
}
@@ -295,7 +295,7 @@ export function buildIntegrationConfigOptionRegistry(
path: 'ankiConnect.media.maxMediaDuration', path: 'ankiConnect.media.maxMediaDuration',
kind: 'number', kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.maxMediaDuration, defaultValue: defaultConfig.ankiConnect.media.maxMediaDuration,
description: 'Maximum allowed media clip duration in seconds.', description: 'Maximum allowed media clip duration in seconds. 0 disables the cap.',
}, },
{ {
path: 'ankiConnect.knownWords.matchMode', path: 'ankiConnect.knownWords.matchMode',
@@ -1762,6 +1762,60 @@ describe('stats server API routes', () => {
}); });
}); });
it('POST /api/stats/mine-card treats a zero media duration cap as unlimited', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
const scenarios = [
{ maxMediaDuration: 0, expectedEnd: 12 },
{ maxMediaDuration: 1, expectedEnd: 11 },
];
for (const scenario of scenarios) {
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => null,
createMediaGenerator: () => ({
generateAudio: async (_path, start, end, padding) => {
audioRanges.push({ start, end, padding });
return Buffer.from('audio');
},
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: {
deck: 'Mining',
media: {
generateAudio: true,
generateImage: false,
audioPadding: 0.25,
maxMediaDuration: scenario.maxMediaDuration,
},
},
});
const res = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 10_000,
endMs: 12_000,
sentence: '猫を見た',
word: '猫',
}),
});
assert.equal(res.status, 502);
assert.deepEqual(audioRanges.at(-1), {
start: 10,
end: scenario.expectedEnd,
padding: 0.25,
});
}
});
});
it('POST /api/stats/mine-card requires a non-empty word in word mode', async () => { it('POST /api/stats/mine-card requires a non-empty word in word mode', async () => {
await withTempDir(async (dir) => { await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv'); const sourcePath = path.join(dir, 'episode.mkv');
@@ -4,6 +4,7 @@ import { basename } from 'node:path';
import { AnkiConnectClient } from '../../../anki-connect.js'; import { AnkiConnectClient } from '../../../anki-connect.js';
import { getConfiguredWordFieldName } from '../../../anki-field-config.js'; import { getConfiguredWordFieldName } from '../../../anki-field-config.js';
import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/animated-image-sync.js'; import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/animated-image-sync.js';
import { clampMediaEndTime } from '../../../anki-integration/media-duration.js';
import { MediaGenerator } from '../../../media-generator.js'; import { MediaGenerator } from '../../../media-generator.js';
import { statsJson } from '../../../types/stats-http-contract.js'; import { statsJson } from '../../../types/stats-http-contract.js';
import { import {
@@ -113,8 +114,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
const startSec = startMs / 1000; const startSec = startMs / 1000;
const endSec = endMs / 1000; const endSec = endMs / 1000;
const rawDuration = endSec - startSec; const clampedEndSec = clampMediaEndTime(startSec, endSec, maxMediaDuration);
const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec;
const highlightedSentence = word const highlightedSentence = word
? sentence.replace( ? sentence.replace(