mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-21 00:11:27 -07:00
feat: sync animated anki images to sentence audio
This commit is contained in:
133
src/anki-integration/animated-image-sync.ts
Normal file
133
src/anki-integration/animated-image-sync.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { execFile as nodeExecFile } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import type { AnkiConnectConfig } from '../types';
|
||||
|
||||
type NoteInfoLike = {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
};
|
||||
|
||||
interface ResolveAnimatedImageLeadInSecondsArgs<TNoteInfo extends NoteInfoLike> {
|
||||
config: Pick<AnkiConnectConfig, 'fields' | 'media'>;
|
||||
noteInfo: TNoteInfo;
|
||||
resolveConfiguredFieldName: (
|
||||
noteInfo: TNoteInfo,
|
||||
...preferredNames: (string | undefined)[]
|
||||
) => string | null;
|
||||
retrieveMediaFileBase64: (filename: string) => Promise<string>;
|
||||
probeAudioDurationSeconds?: (buffer: Buffer, filename: string) => Promise<number | null>;
|
||||
logWarn?: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
interface ProbeAudioDurationDeps {
|
||||
execFile?: typeof nodeExecFile;
|
||||
mkdtempSync?: typeof fs.mkdtempSync;
|
||||
writeFileSync?: typeof fs.writeFileSync;
|
||||
rmSync?: typeof fs.rmSync;
|
||||
}
|
||||
|
||||
export function extractSoundFilenames(value: string): string[] {
|
||||
const matches = value.matchAll(/\[sound:([^\]]+)\]/gi);
|
||||
return Array.from(matches, (match) => match[1]?.trim() || '').filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'media'>): boolean {
|
||||
return (
|
||||
config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false
|
||||
);
|
||||
}
|
||||
|
||||
export async function probeAudioDurationSeconds(
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
deps: ProbeAudioDurationDeps = {},
|
||||
): Promise<number | null> {
|
||||
const execFile = deps.execFile ?? nodeExecFile;
|
||||
const mkdtempSync = deps.mkdtempSync ?? fs.mkdtempSync;
|
||||
const writeFileSync = deps.writeFileSync ?? fs.writeFileSync;
|
||||
const rmSync = deps.rmSync ?? fs.rmSync;
|
||||
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'subminer-audio-probe-'));
|
||||
const ext = path.extname(filename) || '.bin';
|
||||
const tempPath = path.join(tempDir, `probe${ext}`);
|
||||
writeFileSync(tempPath, buffer);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
'ffprobe',
|
||||
[
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'format=duration',
|
||||
'-of',
|
||||
'default=noprint_wrappers=1:nokey=1',
|
||||
tempPath,
|
||||
],
|
||||
(error, stdout) => {
|
||||
try {
|
||||
if (error) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const durationSeconds = Number.parseFloat((stdout || '').trim());
|
||||
resolve(Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : null);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteInfoLike>({
|
||||
config,
|
||||
noteInfo,
|
||||
resolveConfiguredFieldName,
|
||||
retrieveMediaFileBase64,
|
||||
probeAudioDurationSeconds: probeDuration = probeAudioDurationSeconds,
|
||||
logWarn,
|
||||
}: ResolveAnimatedImageLeadInSecondsArgs<TNoteInfo>): Promise<number> {
|
||||
if (!shouldSyncAnimatedImageToWordAudio(config)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const wordAudioFieldName = resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
config.fields?.audio,
|
||||
DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
|
||||
);
|
||||
if (!wordAudioFieldName) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const wordAudioValue = noteInfo.fields[wordAudioFieldName]?.value || '';
|
||||
const filenames = extractSoundFilenames(wordAudioValue);
|
||||
if (filenames.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let totalLeadInSeconds = 0;
|
||||
for (const filename of filenames) {
|
||||
const encoded = await retrieveMediaFileBase64(filename);
|
||||
if (!encoded) {
|
||||
logWarn?.('Animated image sync skipped: failed to retrieve word audio', filename);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const durationSeconds = await probeDuration(Buffer.from(encoded, 'base64'), filename);
|
||||
if (!(typeof durationSeconds === 'number' && Number.isFinite(durationSeconds))) {
|
||||
logWarn?.('Animated image sync skipped: failed to probe word audio duration', filename);
|
||||
return 0;
|
||||
}
|
||||
|
||||
totalLeadInSeconds += durationSeconds;
|
||||
}
|
||||
|
||||
return totalLeadInSeconds;
|
||||
}
|
||||
Reference in New Issue
Block a user