mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
134 lines
4.0 KiB
TypeScript
134 lines
4.0 KiB
TypeScript
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;
|
|
}
|