mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
fix(anki): honor unlimited duration in stats mining (#258)
This commit is contained in:
@@ -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: () =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user