mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-12 05:16:19 -07:00
feat(anki): add media timing review before card creation (#203)
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
import { execFile as nodeExecFile, type ExecFileException } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from '../../logger';
|
||||
import { normalizeMediaInput, type MediaInput, type MediaInputOptions } from '../../media-input';
|
||||
|
||||
const log = createLogger('media-window');
|
||||
|
||||
export const REMOTE_MEDIA_WINDOW_TIMEOUT_MS = 120_000;
|
||||
export const REMOTE_MEDIA_WINDOW_MAX_SECONDS = 180;
|
||||
const HEAD_SLACK_SECONDS = 0.25;
|
||||
const TAIL_SLACK_SECONDS = 1;
|
||||
const DEFAULT_IDLE_TTL_MS = 10 * 60_000;
|
||||
const COVERAGE_EPSILON_SECONDS = 0.01;
|
||||
|
||||
export interface RemoteMediaWindowSource {
|
||||
path: string;
|
||||
inputOptions?: MediaInputOptions;
|
||||
/** FFmpeg stream index to keep; `null`/undefined keeps every audio stream. */
|
||||
audioStreamIndex?: number | null;
|
||||
}
|
||||
|
||||
export interface RemoteMediaWindowRange {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface RemoteMediaWindow {
|
||||
path: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
sourcePath: string;
|
||||
audioStreamIndex: number | null;
|
||||
/** Input descriptor for FFmpeg reads; timestamps stay absolute so callers keep source times. */
|
||||
media: MediaInput;
|
||||
}
|
||||
|
||||
type WindowExecFile = (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: { timeout: number },
|
||||
callback: (error: ExecFileException | null) => void,
|
||||
) => void;
|
||||
|
||||
export interface RemoteMediaWindowCacheOptions {
|
||||
tempDir?: string;
|
||||
execFile?: WindowExecFile;
|
||||
idleTtlMs?: number;
|
||||
logDebug?: (message: string) => void;
|
||||
}
|
||||
|
||||
interface PendingFetch extends RemoteMediaWindowRange {
|
||||
sourcePath: string;
|
||||
audioStreamIndex: number | null;
|
||||
promise: Promise<RemoteMediaWindow>;
|
||||
}
|
||||
|
||||
export function isRemoteMediaWindowSourcePath(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value.trim());
|
||||
}
|
||||
|
||||
function describeSourceForDebugLog(sourcePath: string): string {
|
||||
try {
|
||||
return `remote:${new URL(sourcePath).hostname.toLowerCase() || 'unknown'}`;
|
||||
} catch {
|
||||
return 'remote:unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function isUsableRange(range: RemoteMediaWindowRange, allowEmpty: boolean): boolean {
|
||||
return (
|
||||
Number.isFinite(range.startTime) &&
|
||||
Number.isFinite(range.endTime) &&
|
||||
range.startTime >= 0 &&
|
||||
(allowEmpty ? range.endTime >= range.startTime : range.endTime > range.startTime)
|
||||
);
|
||||
}
|
||||
|
||||
function audioStreamMatches(
|
||||
windowIndex: number | null,
|
||||
requested: number | null | undefined,
|
||||
): boolean {
|
||||
return requested == null || windowIndex === requested;
|
||||
}
|
||||
|
||||
function covers(
|
||||
candidate: RemoteMediaWindowRange & { sourcePath: string; audioStreamIndex: number | null },
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
): boolean {
|
||||
return (
|
||||
candidate.sourcePath === source.path &&
|
||||
audioStreamMatches(candidate.audioStreamIndex, source.audioStreamIndex) &&
|
||||
candidate.startTime <= range.startTime + COVERAGE_EPSILON_SECONDS &&
|
||||
candidate.endTime >= range.endTime - COVERAGE_EPSILON_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream-copies `[startTime, endTime]` of a remote source into a local Matroska file.
|
||||
* `-copyts -start_at_zero` keeps the source timestamps, so later reads seek with the
|
||||
* original times via `-seek_timestamp 1` (see `MediaInput.absoluteTimestamps`).
|
||||
*/
|
||||
export function buildRemoteMediaWindowArgs(
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
outputPath: string,
|
||||
): string[] {
|
||||
const input = normalizeMediaInput({ path: source.path, inputOptions: source.inputOptions });
|
||||
const audioMap =
|
||||
typeof source.audioStreamIndex === 'number' && Number.isInteger(source.audioStreamIndex)
|
||||
? `0:${source.audioStreamIndex}`
|
||||
: '0:a';
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-nostdin',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-ss',
|
||||
String(range.startTime),
|
||||
'-t',
|
||||
String(range.endTime - range.startTime),
|
||||
...input.inputArgs,
|
||||
'-i',
|
||||
input.path,
|
||||
'-map',
|
||||
'0:v:0?',
|
||||
'-map',
|
||||
audioMap,
|
||||
'-c',
|
||||
'copy',
|
||||
'-sn',
|
||||
'-dn',
|
||||
'-copyts',
|
||||
'-start_at_zero',
|
||||
'-f',
|
||||
'matroska',
|
||||
'-y',
|
||||
outputPath,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds one downloaded window of the current remote stream so the timing review,
|
||||
* audio extraction, and screenshot all read the same local bytes instead of each
|
||||
* re-fetching the clip over HTTP. A new window replaces the old one; the file is
|
||||
* deleted after `idleTtlMs` without use, on `clear()`, or on `cleanup()`.
|
||||
*/
|
||||
export class RemoteMediaWindowCache {
|
||||
private readonly tempDir: string;
|
||||
private readonly execFile: WindowExecFile;
|
||||
private readonly idleTtlMs: number;
|
||||
private readonly logDebug: (message: string) => void;
|
||||
private current: RemoteMediaWindow | null = null;
|
||||
private pending: PendingFetch | null = null;
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private sequence = 0;
|
||||
|
||||
constructor(options: RemoteMediaWindowCacheOptions = {}) {
|
||||
this.tempDir = options.tempDir ?? path.join(os.tmpdir(), 'subminer-media-windows');
|
||||
this.execFile = options.execFile ?? nodeExecFile;
|
||||
this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
||||
this.logDebug = options.logDebug ?? ((message) => log.debug(message));
|
||||
}
|
||||
|
||||
get currentWindow(): RemoteMediaWindow | null {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** Returns a ready or in-flight window covering the range; never starts a download. */
|
||||
async lookup(
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
): Promise<RemoteMediaWindow | null> {
|
||||
if (!isUsableRange(range, true)) return null;
|
||||
if (this.current && covers(this.current, source, range)) {
|
||||
this.touch();
|
||||
return this.current;
|
||||
}
|
||||
const pending = this.pending;
|
||||
if (pending && covers(pending, source, range)) {
|
||||
try {
|
||||
const window = await pending.promise;
|
||||
this.touch();
|
||||
return window;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns a window covering the range, downloading (and widening) one when needed. */
|
||||
async acquire(
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
): Promise<RemoteMediaWindow> {
|
||||
if (!isUsableRange(range, false)) {
|
||||
throw new Error('Media window range is invalid.');
|
||||
}
|
||||
if (range.endTime - range.startTime > REMOTE_MEDIA_WINDOW_MAX_SECONDS) {
|
||||
throw new Error('Media window range is too long to download.');
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const hit = await this.lookup(source, range);
|
||||
if (hit) return hit;
|
||||
const pending = this.pending;
|
||||
if (!pending) break;
|
||||
// Another caller is already downloading; wait for it, then re-check coverage.
|
||||
await pending.promise.catch(() => null);
|
||||
}
|
||||
|
||||
return this.fetch(source, this.planFetchRange(source, range));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cancelIdleTimer();
|
||||
const current = this.current;
|
||||
this.current = null;
|
||||
if (current) this.removeFile(current.path);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.clear();
|
||||
try {
|
||||
fs.rmSync(this.tempDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
log.error('Failed to cleanup media window directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private planFetchRange(
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
): RemoteMediaWindowRange {
|
||||
let startTime = Math.max(0, range.startTime - HEAD_SLACK_SECONDS);
|
||||
let endTime = range.endTime + TAIL_SLACK_SECONDS;
|
||||
const current = this.current;
|
||||
if (
|
||||
current &&
|
||||
current.sourcePath === source.path &&
|
||||
audioStreamMatches(current.audioStreamIndex, source.audioStreamIndex)
|
||||
) {
|
||||
// Keep what was already downloaded when the review timeline grows in one direction.
|
||||
const unionStart = Math.min(startTime, current.startTime);
|
||||
const unionEnd = Math.max(endTime, current.endTime);
|
||||
if (unionEnd - unionStart <= REMOTE_MEDIA_WINDOW_MAX_SECONDS) {
|
||||
startTime = unionStart;
|
||||
endTime = unionEnd;
|
||||
}
|
||||
}
|
||||
return { startTime, endTime };
|
||||
}
|
||||
|
||||
private fetch(
|
||||
source: RemoteMediaWindowSource,
|
||||
range: RemoteMediaWindowRange,
|
||||
): Promise<RemoteMediaWindow> {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true });
|
||||
this.sequence += 1;
|
||||
const outputPath = path.join(this.tempDir, `window_${Date.now()}_${this.sequence}.mkv`);
|
||||
const audioStreamIndex =
|
||||
typeof source.audioStreamIndex === 'number' ? source.audioStreamIndex : null;
|
||||
const description = describeSourceForDebugLog(source.path);
|
||||
const startedAt = Date.now();
|
||||
this.logDebug(
|
||||
`[media-window] fetch start ${description} start=${range.startTime} end=${range.endTime} audioStream=${audioStreamIndex ?? 'all'}`,
|
||||
);
|
||||
|
||||
const promise = new Promise<RemoteMediaWindow>((resolve, reject) => {
|
||||
this.execFile(
|
||||
'ffmpeg',
|
||||
buildRemoteMediaWindowArgs(source, range, outputPath),
|
||||
{ timeout: REMOTE_MEDIA_WINDOW_TIMEOUT_MS },
|
||||
(error) => {
|
||||
const elapsedMs = Math.max(0, Date.now() - startedAt);
|
||||
const size = error ? 0 : this.fileSize(outputPath);
|
||||
if (error || size === 0) {
|
||||
this.removeFile(outputPath);
|
||||
const reason = error
|
||||
? error.code === 'ENOENT'
|
||||
? 'FFmpeg not found. Install FFmpeg to enable media generation.'
|
||||
: `FFmpeg media window failed: ${error.message}`
|
||||
: 'FFmpeg exited without creating a media window.';
|
||||
this.logDebug(`[media-window] fetch failed ${description} elapsedMs=${elapsedMs}`);
|
||||
reject(new Error(reason));
|
||||
return;
|
||||
}
|
||||
const window: RemoteMediaWindow = {
|
||||
path: outputPath,
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
sourcePath: source.path,
|
||||
audioStreamIndex,
|
||||
media: {
|
||||
path: outputPath,
|
||||
source: 'remote-window',
|
||||
singleResolvedStream: true,
|
||||
absoluteTimestamps: true,
|
||||
},
|
||||
};
|
||||
this.logDebug(
|
||||
`[media-window] fetch complete ${description} elapsedMs=${elapsedMs} bytes=${size}`,
|
||||
);
|
||||
this.replaceCurrent(window);
|
||||
resolve(window);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const pending: PendingFetch = {
|
||||
sourcePath: source.path,
|
||||
audioStreamIndex,
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
promise,
|
||||
};
|
||||
this.pending = pending;
|
||||
promise
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
if (this.pending === pending) this.pending = null;
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
private replaceCurrent(window: RemoteMediaWindow): void {
|
||||
const previous = this.current;
|
||||
this.current = window;
|
||||
if (previous && previous.path !== window.path) this.removeFile(previous.path);
|
||||
this.touch();
|
||||
}
|
||||
|
||||
private touch(): void {
|
||||
this.cancelIdleTimer();
|
||||
if (this.idleTtlMs <= 0 || !this.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (this.idleTimer === timer) this.idleTimer = null;
|
||||
this.clear();
|
||||
}, this.idleTtlMs);
|
||||
timer.unref?.();
|
||||
this.idleTimer = timer;
|
||||
}
|
||||
|
||||
private cancelIdleTimer(): void {
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
|
||||
private fileSize(filePath: string): number {
|
||||
try {
|
||||
return fs.statSync(filePath).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private removeFile(filePath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
log.debug(`Failed to remove media window ${filePath}:`, (error as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sharedCache: RemoteMediaWindowCache | null = null;
|
||||
|
||||
/** Process-wide cache so the review modal and card media generation share one download. */
|
||||
export function getSharedRemoteMediaWindowCache(): RemoteMediaWindowCache {
|
||||
sharedCache ??= new RemoteMediaWindowCache();
|
||||
return sharedCache;
|
||||
}
|
||||
Reference in New Issue
Block a user