/* * SubMiner - Subtitle mining overlay for mpv * Copyright (C) 2024 sudacode * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import { ExecFileException, execFile } from "child_process"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import { createLogger } from "./logger"; const log = createLogger("media"); export class MediaGenerator { private tempDir: string; private notifyIconDir: string; private av1EncoderPromise: Promise | null = null; constructor(tempDir?: string) { this.tempDir = tempDir || path.join(os.tmpdir(), "subminer-media"); this.notifyIconDir = path.join(os.tmpdir(), "subminer-notify"); if (!fs.existsSync(this.tempDir)) { fs.mkdirSync(this.tempDir, { recursive: true }); } if (!fs.existsSync(this.notifyIconDir)) { fs.mkdirSync(this.notifyIconDir, { recursive: true }); } // Clean up old notification icons on startup (older than 1 hour) this.cleanupOldNotificationIcons(); } /** * Clean up notification icons older than 1 hour. * Called on startup to prevent accumulation of temp files. */ private cleanupOldNotificationIcons(): void { try { if (!fs.existsSync(this.notifyIconDir)) return; const files = fs.readdirSync(this.notifyIconDir); const oneHourAgo = Date.now() - 60 * 60 * 1000; for (const file of files) { if (!file.endsWith(".png")) continue; const filePath = path.join(this.notifyIconDir, file); try { const stat = fs.statSync(filePath); if (stat.mtimeMs < oneHourAgo) { fs.unlinkSync(filePath); } } catch (err) { log.debug( `Failed to clean up ${filePath}:`, (err as Error).message, ); } } } catch (err) { log.error("Failed to cleanup old notification icons:", err); } } /** * Write a notification icon buffer to a temp file and return the file path. * The file path can be passed directly to Electron Notification for better * compatibility with Linux/Wayland notification daemons. */ writeNotificationIconToFile(iconBuffer: Buffer, noteId: number): string { const filename = `icon_${noteId}_${Date.now()}.png`; const filePath = path.join(this.notifyIconDir, filename); fs.writeFileSync(filePath, iconBuffer); return filePath; } scheduleNotificationIconCleanup(filePath: string, delayMs = 10000): void { setTimeout(() => { try { fs.unlinkSync(filePath); } catch {} }, delayMs); } private ffmpegError(label: string, error: ExecFileException): Error { if (error.code === "ENOENT") { return new Error( "FFmpeg not found. Install FFmpeg to enable media generation.", ); } return new Error(`FFmpeg ${label} failed: ${error.message}`); } private detectAv1Encoder(): Promise { if (this.av1EncoderPromise) return this.av1EncoderPromise; this.av1EncoderPromise = new Promise((resolve) => { execFile( "ffmpeg", ["-hide_banner", "-encoders"], { timeout: 10000 }, (error, stdout, stderr) => { if (error) { resolve(null); return; } const output = `${stdout || ""}\n${stderr || ""}`; const candidates = ["libaom-av1", "libsvtav1", "librav1e"]; for (const encoder of candidates) { if (output.includes(encoder)) { resolve(encoder); return; } } resolve(null); }, ); }); return this.av1EncoderPromise; } async generateAudio( videoPath: string, startTime: number, endTime: number, padding: number = 0.5, audioStreamIndex: number | null = null, ): Promise { const start = Math.max(0, startTime - padding); const duration = endTime - startTime + 2 * padding; return new Promise((resolve, reject) => { const outputPath = path.join(this.tempDir, `audio_${Date.now()}.mp3`); const args: string[] = [ "-ss", start.toString(), "-t", duration.toString(), "-i", videoPath, ]; if ( typeof audioStreamIndex === "number" && Number.isInteger(audioStreamIndex) && audioStreamIndex >= 0 ) { args.push("-map", `0:${audioStreamIndex}`); } args.push( "-vn", "-acodec", "libmp3lame", "-q:a", "2", "-ar", "44100", "-y", outputPath, ); execFile("ffmpeg", args, { timeout: 30000 }, (error) => { if (error) { reject(this.ffmpegError("audio generation", error)); return; } try { const data = fs.readFileSync(outputPath); fs.unlinkSync(outputPath); resolve(data); } catch (err) { reject(err); } }); }); } async generateScreenshot( videoPath: string, timestamp: number, options: { format: "jpg" | "png" | "webp"; quality?: number; maxWidth?: number; maxHeight?: number; }, ): Promise { const { format, quality = 92, maxWidth, maxHeight } = options; const ext = format === "webp" ? "webp" : format === "png" ? "png" : "jpg"; const codecMap: Record = { jpg: "mjpeg", png: "png", webp: "webp", }; const args: string[] = [ "-ss", timestamp.toString(), "-i", videoPath, "-vframes", "1", ]; const vfParts: string[] = []; if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) { vfParts.push( `scale=w=${maxWidth}:h=${maxHeight}:force_original_aspect_ratio=decrease`, ); } else if (maxWidth && maxWidth > 0) { vfParts.push(`scale=w=${maxWidth}:h=-2`); } else if (maxHeight && maxHeight > 0) { vfParts.push(`scale=w=-2:h=${maxHeight}`); } if (vfParts.length > 0) { args.push("-vf", vfParts.join(",")); } args.push("-c:v", codecMap[format]); if (format !== "png") { const clampedQuality = Math.max(1, Math.min(100, quality)); if (format === "jpg") { const qv = Math.round(2 + (100 - clampedQuality) * (29 / 99)); args.push("-q:v", qv.toString()); } else { args.push("-q:v", clampedQuality.toString()); } } args.push("-y"); return new Promise((resolve, reject) => { const outputPath = path.join( this.tempDir, `screenshot_${Date.now()}.${ext}`, ); args.push(outputPath); execFile("ffmpeg", args, { timeout: 30000 }, (error) => { if (error) { reject(this.ffmpegError("screenshot generation", error)); return; } try { const data = fs.readFileSync(outputPath); fs.unlinkSync(outputPath); resolve(data); } catch (err) { reject(err); } }); }); } /** * Generate a small PNG icon suitable for desktop notifications. * Always outputs PNG format (known-good for Electron + Linux notification daemons). * Scaled to 256px width for fast encoding and small file size. */ async generateNotificationIcon( videoPath: string, timestamp: number, ): Promise { return new Promise((resolve, reject) => { const outputPath = path.join( this.tempDir, `notify_icon_${Date.now()}.png`, ); execFile( "ffmpeg", [ "-ss", timestamp.toString(), "-i", videoPath, "-vframes", "1", "-vf", "scale=256:256:force_original_aspect_ratio=decrease,pad=256:256:(ow-iw)/2:(oh-ih)/2:black", "-c:v", "png", "-y", outputPath, ], { timeout: 30000 }, (error) => { if (error) { reject(this.ffmpegError("notification icon generation", error)); return; } try { const data = fs.readFileSync(outputPath); fs.unlinkSync(outputPath); resolve(data); } catch (err) { reject(err); } }, ); }); } async generateAnimatedImage( videoPath: string, startTime: number, endTime: number, padding: number = 0.5, options: { fps?: number; maxWidth?: number; maxHeight?: number; crf?: number; } = {}, ): Promise { const start = Math.max(0, startTime - padding); const duration = endTime - startTime + 2 * padding; const { fps = 10, maxWidth = 640, maxHeight, crf = 35 } = options; const clampedFps = Math.max(1, Math.min(60, fps)); const clampedCrf = Math.max(0, Math.min(63, crf)); const vfParts: string[] = []; vfParts.push(`fps=${clampedFps}`); if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) { vfParts.push( `scale=w=${maxWidth}:h=${maxHeight}:force_original_aspect_ratio=decrease`, ); } else if (maxWidth && maxWidth > 0) { vfParts.push(`scale=w=${maxWidth}:h=-2`); } else if (maxHeight && maxHeight > 0) { vfParts.push(`scale=w=-2:h=${maxHeight}`); } const av1Encoder = await this.detectAv1Encoder(); if (!av1Encoder) { throw new Error( "No supported AV1 encoder found for animated AVIF (tried libaom-av1, libsvtav1, librav1e).", ); } return new Promise((resolve, reject) => { const outputPath = path.join( this.tempDir, `animation_${Date.now()}.avif`, ); const encoderArgs: string[] = ["-c:v", av1Encoder]; if (av1Encoder === "libaom-av1") { encoderArgs.push( "-crf", clampedCrf.toString(), "-b:v", "0", "-cpu-used", "8", ); } else if (av1Encoder === "libsvtav1") { encoderArgs.push( "-crf", clampedCrf.toString(), "-preset", "8", ); } else { // librav1e encoderArgs.push("-qp", clampedCrf.toString(), "-speed", "8"); } execFile( "ffmpeg", [ "-ss", start.toString(), "-t", duration.toString(), "-i", videoPath, "-vf", vfParts.join(","), ...encoderArgs, "-y", outputPath, ], { timeout: 60000 }, (error) => { if (error) { reject(this.ffmpegError("animation generation", error)); return; } try { const data = fs.readFileSync(outputPath); fs.unlinkSync(outputPath); resolve(data); } catch (err) { reject(err); } }, ); }); } cleanup(): void { try { if (fs.existsSync(this.tempDir)) { fs.rmSync(this.tempDir, { recursive: true, force: true }); } } catch (err) { log.error("Failed to cleanup media generator temp directory:", err); } } }