diff --git a/changes/fix-unlimited-mining-duration.md b/changes/fix-unlimited-mining-duration.md new file mode 100644 index 00000000..76a58a3c --- /dev/null +++ b/changes/fix-unlimited-mining-duration.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Treat `ankiConnect.media.maxMediaDuration: 0` as unlimited for stats dashboard mining, matching overlay mining and configuration. diff --git a/config.example.jsonc b/config.example.jsonc index 6eed9b71..56b01757 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -591,7 +591,7 @@ "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. "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. "knownWords": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index d870695c..ee88db45 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -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_.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. 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. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 12321710..a594ec30 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -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.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.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.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"`) | diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 6eed9b71..56b01757 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -591,7 +591,7 @@ "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. "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. "knownWords": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false diff --git a/src/anki-integration/card-creation-manual-update.test.ts b/src/anki-integration/card-creation-manual-update.test.ts index 2a8dcb2e..4ff20aa6 100644 --- a/src/anki-integration/card-creation-manual-update.test.ts +++ b/src/anki-integration/card-creation-manual-update.test.ts @@ -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 () => { const { service, updatedFields } = createManualUpdateService({ getConfig: () => diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 89913576..5917b292 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -21,6 +21,7 @@ import { resolveAudioStreamIndexForMediaGeneration, type MediaGenerationInputResolverOptions, } from './media-source'; +import { clampMediaEndTime } from './media-duration'; import { resolveWordCardKind } from './note-field-utils'; import type { PendingYoutubeMediaUpdate } from './pending-youtube-media'; import { resolveMpvVolumeScale } from './mpv-volume'; @@ -233,11 +234,12 @@ export class CardCreationService { let rangeEnd = Math.max(...timings.map((entry) => entry.endTime)); 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( `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...'); @@ -437,9 +439,7 @@ export class CardCreationService { } const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; - if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { - endTime = startTime + maxMediaDuration; - } + endTime = clampMediaEndTime(startTime, endTime, maxMediaDuration); this.deps.showOsdNotification('Marking card as audio card...'); await this.deps.withUpdateProgress('Marking audio card', async () => { @@ -600,11 +600,12 @@ export class CardCreationService { } 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( `Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`, ); - endTime = startTime + maxMediaDuration; + endTime = cappedEndTime; } try { diff --git a/src/anki-integration/media-duration.ts b/src/anki-integration/media-duration.ts new file mode 100644 index 00000000..04f937d4 --- /dev/null +++ b/src/anki-integration/media-duration.ts @@ -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; +} diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 16e208a6..433f3fcd 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -295,7 +295,7 @@ export function buildIntegrationConfigOptionRegistry( path: 'ankiConnect.media.maxMediaDuration', kind: 'number', 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', diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index bc9f3674..4f5d2873 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -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 () => { await withTempDir(async (dir) => { const sourcePath = path.join(dir, 'episode.mkv'); diff --git a/src/core/services/stats-server/mining-routes.ts b/src/core/services/stats-server/mining-routes.ts index d93cc90e..a2e2d7ba 100644 --- a/src/core/services/stats-server/mining-routes.ts +++ b/src/core/services/stats-server/mining-routes.ts @@ -4,6 +4,7 @@ import { basename } from 'node:path'; import { AnkiConnectClient } from '../../../anki-connect.js'; import { getConfiguredWordFieldName } from '../../../anki-field-config.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 { statsJson } from '../../../types/stats-http-contract.js'; import { @@ -113,8 +114,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO const startSec = startMs / 1000; const endSec = endMs / 1000; - const rawDuration = endSec - startSec; - const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec; + const clampedEndSec = clampMediaEndTime(startSec, endSec, maxMediaDuration); const highlightedSentence = word ? sentence.replace(