mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
feat(youtube): add mediaCache mode and safer stream media extraction (#130)
This commit is contained in:
+143
-7
@@ -21,9 +21,12 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from './logger';
|
||||
import { normalizeMediaInput, type MediaInput } from './media-input';
|
||||
|
||||
const log = createLogger('media');
|
||||
|
||||
export type { MediaInput, MediaInputOptions } from './media-input';
|
||||
|
||||
function normalizeAnimatedImageFps(fps: number | undefined): number {
|
||||
const fallbackFps = 10;
|
||||
const safeFps = typeof fps === 'number' && Number.isFinite(fps) ? fps : fallbackFps;
|
||||
@@ -69,12 +72,65 @@ export function buildAnimatedImageVideoFilter(options: {
|
||||
return vfParts.join(',');
|
||||
}
|
||||
|
||||
export interface MediaGeneratorOptions {
|
||||
logDebug?: (message: string) => void;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function sanitizeDebugToken(value: string, fallback: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
const sanitized = trimmed.replace(/[^A-Za-z0-9_.:-]+/g, '-').slice(0, 80);
|
||||
return sanitized || fallback;
|
||||
}
|
||||
|
||||
function describeMediaInputPathForDebugLog(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||
return `remote:${url.hostname.toLowerCase() || 'unknown'}`;
|
||||
}
|
||||
return `${url.protocol.replace(/:$/, '')}:`;
|
||||
} catch {
|
||||
// Not a URL; treat as a local file path below.
|
||||
}
|
||||
|
||||
if (value.startsWith('edl://')) {
|
||||
return 'edl:';
|
||||
}
|
||||
|
||||
return `local:${value}`;
|
||||
}
|
||||
|
||||
function describeMediaInputForDebugLog(input: MediaInput): string {
|
||||
const pathValue = typeof input === 'string' ? input : input.path;
|
||||
const sourceValue = typeof input === 'string' ? 'raw' : input.source;
|
||||
const source = sanitizeDebugToken(sourceValue ?? 'raw', 'raw');
|
||||
return `source=${source} input=${describeMediaInputPathForDebugLog(pathValue)}`;
|
||||
}
|
||||
|
||||
function describeFfmpegFailureForDebugLog(error: ExecFileException): string {
|
||||
const code = typeof error.code === 'string' || typeof error.code === 'number' ? error.code : null;
|
||||
const signal = typeof error.signal === 'string' ? error.signal : null;
|
||||
if (code !== null) {
|
||||
return `code=${code}`;
|
||||
}
|
||||
if (signal) {
|
||||
return `signal=${signal}`;
|
||||
}
|
||||
return `name=${sanitizeDebugToken(error.name || 'Error', 'Error')}`;
|
||||
}
|
||||
|
||||
export class MediaGenerator {
|
||||
private tempDir: string;
|
||||
private notifyIconDir: string;
|
||||
private av1EncoderPromise: Promise<string | null> | null = null;
|
||||
private readonly options: MediaGeneratorOptions;
|
||||
|
||||
constructor(tempDir?: string) {
|
||||
constructor(tempDir?: string, options: MediaGeneratorOptions = {}) {
|
||||
this.options = options;
|
||||
this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media');
|
||||
this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify');
|
||||
this.ensureDirectory(this.tempDir);
|
||||
@@ -83,6 +139,28 @@ export class MediaGenerator {
|
||||
this.cleanupOldNotificationIcons();
|
||||
}
|
||||
|
||||
private nowMs(): number {
|
||||
try {
|
||||
const value = this.options.now?.() ?? Date.now();
|
||||
return Number.isFinite(value) ? value : Date.now();
|
||||
} catch {
|
||||
return Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
private elapsedMs(startedAt: number): number {
|
||||
return Math.max(0, Math.round(this.nowMs() - startedAt));
|
||||
}
|
||||
|
||||
private logMediaDebug(message: string): void {
|
||||
const logDebug = this.options.logDebug ?? ((line: string) => log.debug(line));
|
||||
try {
|
||||
logDebug(`[media-generator] ${message}`);
|
||||
} catch {
|
||||
// Debug logging should not affect media generation.
|
||||
}
|
||||
}
|
||||
|
||||
private ensureDirectory(dir: string): void {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -181,7 +259,7 @@ export class MediaGenerator {
|
||||
}
|
||||
|
||||
async generateAudio(
|
||||
videoPath: string,
|
||||
videoPath: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
padding: number = 0,
|
||||
@@ -190,12 +268,24 @@ export class MediaGenerator {
|
||||
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
||||
const start = Math.max(0, startTime - safePadding);
|
||||
const duration = endTime - start + safePadding;
|
||||
const mediaInput = normalizeMediaInput(videoPath);
|
||||
const inputDescription = describeMediaInputForDebugLog(videoPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = this.createTempOutputPath('audio', 'mp3');
|
||||
const args: string[] = ['-ss', start.toString(), '-t', duration.toString(), '-i', videoPath];
|
||||
const startedAt = this.nowMs();
|
||||
const args: string[] = [
|
||||
'-ss',
|
||||
start.toString(),
|
||||
'-t',
|
||||
duration.toString(),
|
||||
...mediaInput.inputArgs,
|
||||
'-i',
|
||||
mediaInput.path,
|
||||
];
|
||||
|
||||
if (
|
||||
!mediaInput.singleResolvedStream &&
|
||||
typeof audioStreamIndex === 'number' &&
|
||||
Number.isInteger(audioStreamIndex) &&
|
||||
audioStreamIndex >= 0
|
||||
@@ -205,8 +295,14 @@ export class MediaGenerator {
|
||||
|
||||
args.push('-vn', '-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
|
||||
|
||||
this.logMediaDebug(
|
||||
`audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`,
|
||||
);
|
||||
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
|
||||
if (error) {
|
||||
this.logMediaDebug(
|
||||
`audio failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
|
||||
);
|
||||
reject(this.ffmpegError('audio generation', error));
|
||||
return;
|
||||
}
|
||||
@@ -214,6 +310,9 @@ export class MediaGenerator {
|
||||
try {
|
||||
const data = fs.readFileSync(outputPath);
|
||||
fs.unlinkSync(outputPath);
|
||||
this.logMediaDebug(
|
||||
`audio complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
|
||||
);
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
@@ -223,7 +322,7 @@ export class MediaGenerator {
|
||||
}
|
||||
|
||||
async generateScreenshot(
|
||||
videoPath: string,
|
||||
videoPath: MediaInput,
|
||||
timestamp: number,
|
||||
options: {
|
||||
format: 'jpg' | 'png' | 'webp';
|
||||
@@ -239,8 +338,18 @@ export class MediaGenerator {
|
||||
png: 'png',
|
||||
webp: 'webp',
|
||||
};
|
||||
const mediaInput = normalizeMediaInput(videoPath);
|
||||
const inputDescription = describeMediaInputForDebugLog(videoPath);
|
||||
|
||||
const args: string[] = ['-ss', timestamp.toString(), '-i', videoPath, '-vframes', '1'];
|
||||
const args: string[] = [
|
||||
'-ss',
|
||||
timestamp.toString(),
|
||||
...mediaInput.inputArgs,
|
||||
'-i',
|
||||
mediaInput.path,
|
||||
'-vframes',
|
||||
'1',
|
||||
];
|
||||
|
||||
const vfParts: string[] = [];
|
||||
if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) {
|
||||
@@ -270,10 +379,17 @@ export class MediaGenerator {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = this.createTempOutputPath('screenshot', ext);
|
||||
const startedAt = this.nowMs();
|
||||
args.push(outputPath);
|
||||
|
||||
this.logMediaDebug(
|
||||
`screenshot start ${inputDescription} timestamp=${timestamp} format=${format} maxWidth=${maxWidth ?? 'none'} maxHeight=${maxHeight ?? 'none'}`,
|
||||
);
|
||||
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
|
||||
if (error) {
|
||||
this.logMediaDebug(
|
||||
`screenshot failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
|
||||
);
|
||||
reject(this.ffmpegError('screenshot generation', error));
|
||||
return;
|
||||
}
|
||||
@@ -281,6 +397,9 @@ export class MediaGenerator {
|
||||
try {
|
||||
const data = fs.readFileSync(outputPath);
|
||||
fs.unlinkSync(outputPath);
|
||||
this.logMediaDebug(
|
||||
`screenshot complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
|
||||
);
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
@@ -334,7 +453,7 @@ export class MediaGenerator {
|
||||
}
|
||||
|
||||
async generateAnimatedImage(
|
||||
videoPath: string,
|
||||
videoPath: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
padding: number = 0,
|
||||
@@ -352,10 +471,15 @@ export class MediaGenerator {
|
||||
const start = Math.max(0, startTime - safePadding);
|
||||
const duration = roundDurationUpToNextFrameBoundary(endTime - start + safePadding, clampedFps);
|
||||
const totalLeadingStillDuration = Math.max(0, leadingStillDuration);
|
||||
const inputDescription = describeMediaInputForDebugLog(videoPath);
|
||||
|
||||
const clampedCrf = Math.max(0, Math.min(63, crf));
|
||||
|
||||
const encoderDetectionStartedAt = this.nowMs();
|
||||
const av1Encoder = await this.detectAv1Encoder();
|
||||
this.logMediaDebug(
|
||||
`animated-image encoder ${inputDescription} elapsedMs=${this.elapsedMs(encoderDetectionStartedAt)} encoder=${av1Encoder ?? 'none'}`,
|
||||
);
|
||||
if (!av1Encoder) {
|
||||
throw new Error(
|
||||
'No supported AV1 encoder found for animated AVIF (tried libaom-av1, libsvtav1, librav1e).',
|
||||
@@ -364,6 +488,8 @@ export class MediaGenerator {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = this.createTempOutputPath('animation', 'avif');
|
||||
const mediaInput = normalizeMediaInput(videoPath);
|
||||
const startedAt = this.nowMs();
|
||||
|
||||
const encoderArgs: string[] = ['-c:v', av1Encoder];
|
||||
if (av1Encoder === 'libaom-av1') {
|
||||
@@ -375,6 +501,9 @@ export class MediaGenerator {
|
||||
encoderArgs.push('-qp', clampedCrf.toString(), '-speed', '8');
|
||||
}
|
||||
|
||||
this.logMediaDebug(
|
||||
`animated-image start ${inputDescription} start=${start} duration=${duration} padding=${safePadding} fps=${clampedFps} maxWidth=${maxWidth ?? 'none'} maxHeight=${maxHeight ?? 'none'} crf=${clampedCrf} encoder=${av1Encoder}`,
|
||||
);
|
||||
execFile(
|
||||
'ffmpeg',
|
||||
[
|
||||
@@ -382,8 +511,9 @@ export class MediaGenerator {
|
||||
start.toString(),
|
||||
'-t',
|
||||
duration.toString(),
|
||||
...mediaInput.inputArgs,
|
||||
'-i',
|
||||
videoPath,
|
||||
mediaInput.path,
|
||||
'-vf',
|
||||
buildAnimatedImageVideoFilter({
|
||||
fps: clampedFps,
|
||||
@@ -398,6 +528,9 @@ export class MediaGenerator {
|
||||
{ timeout: 60000 },
|
||||
(error) => {
|
||||
if (error) {
|
||||
this.logMediaDebug(
|
||||
`animated-image failed ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} ${describeFfmpegFailureForDebugLog(error)}`,
|
||||
);
|
||||
reject(this.ffmpegError('animation generation', error));
|
||||
return;
|
||||
}
|
||||
@@ -405,6 +538,9 @@ export class MediaGenerator {
|
||||
try {
|
||||
const data = fs.readFileSync(outputPath);
|
||||
fs.unlinkSync(outputPath);
|
||||
this.logMediaDebug(
|
||||
`animated-image complete ${inputDescription} elapsedMs=${this.elapsedMs(startedAt)} bytes=${data.byteLength}`,
|
||||
);
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
|
||||
Reference in New Issue
Block a user