videofolxtv/server/thumbnail-generator.ts
sebastjanartic 60cf545f79 Provide users with advanced options for video thumbnail generation
Adds FFmpeg/ImageMagick integration for dynamic thumbnail creation with API endpoints.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 50814a1e-92e4-4968-856f-7bc7eedf5e8f
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/50814a1e-92e4-4968-856f-7bc7eedf5e8f/L923Cjb
2025-08-04 19:51:11 +00:00

158 lines
4.3 KiB
TypeScript

import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
const execAsync = promisify(exec);
export interface ThumbnailOptions {
videoId: string;
timeStamp?: string; // Format: "00:01:30" or "90" (seconds)
width?: number;
height?: number;
quality?: number;
}
export class ThumbnailGenerator {
private thumbnailDir: string;
constructor() {
this.thumbnailDir = path.join(process.cwd(), 'thumbnails');
this.ensureThumbnailDir();
}
private ensureThumbnailDir() {
if (!fs.existsSync(this.thumbnailDir)) {
fs.mkdirSync(this.thumbnailDir, { recursive: true });
}
}
async generateThumbnail(options: ThumbnailOptions): Promise<string | null> {
const { videoId, timeStamp = "5", width = 400, height = 225, quality = 85 } = options;
// Create unique filename based on parameters
const filename = `${videoId}_${timeStamp.replace(/:/g, '-')}_${width}x${height}.jpg`;
const outputPath = path.join(this.thumbnailDir, filename);
// Check if thumbnail already exists
if (fs.existsSync(outputPath)) {
return outputPath;
}
try {
// Use Bunny.net direct video URL for FFmpeg
const videoUrl = `https://vz-7982dfc4-cc8.b-cdn.net/${videoId}/playlist.m3u8`;
// FFmpeg command to extract frame at specific time
const ffmpegCommand = [
'ffmpeg',
'-i', `"${videoUrl}"`,
'-ss', timeStamp,
'-vframes', '1',
'-vf', `scale=${width}:${height}`,
'-q:v', quality.toString(),
`"${outputPath}"`,
'-y'
].join(' ');
console.log(`Generating thumbnail: ${ffmpegCommand}`);
const { stdout, stderr } = await execAsync(ffmpegCommand);
if (fs.existsSync(outputPath)) {
console.log(`Thumbnail generated successfully: ${outputPath}`);
return outputPath;
} else {
console.error('Thumbnail file was not created');
return null;
}
} catch (error) {
console.error('Error generating thumbnail:', error);
return null;
}
}
async generateMultipleThumbnails(videoId: string, timestamps: string[]): Promise<string[]> {
const results: string[] = [];
for (const timestamp of timestamps) {
const thumbnailPath = await this.generateThumbnail({
videoId,
timeStamp: timestamp
});
if (thumbnailPath) {
results.push(thumbnailPath);
}
}
return results;
}
async enhanceThumbnail(inputPath: string, outputPath: string): Promise<string | null> {
try {
// Use ImageMagick to enhance the thumbnail
const magickCommand = [
'convert',
`"${inputPath}"`,
'-auto-level',
'-enhance',
'-unsharp', '0x1',
`"${outputPath}"`
].join(' ');
await execAsync(magickCommand);
if (fs.existsSync(outputPath)) {
return outputPath;
}
return null;
} catch (error) {
console.error('Error enhancing thumbnail:', error);
return null;
}
}
listThumbnails(videoId: string): string[] {
try {
const files = fs.readdirSync(this.thumbnailDir);
return files
.filter(file => file.startsWith(videoId) && file.endsWith('.jpg'))
.map(file => path.join(this.thumbnailDir, file));
} catch (error) {
console.error('Error listing thumbnails:', error);
return [];
}
}
deleteThumbnail(videoId: string, timestamp?: string): boolean {
try {
if (timestamp) {
// Delete specific thumbnail
const filename = `${videoId}_${timestamp.replace(/:/g, '-')}_400x225.jpg`;
const filePath = path.join(this.thumbnailDir, filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
return true;
}
} else {
// Delete all thumbnails for video
const files = fs.readdirSync(this.thumbnailDir);
const videoThumbnails = files.filter(file => file.startsWith(videoId));
for (const file of videoThumbnails) {
fs.unlinkSync(path.join(this.thumbnailDir, file));
}
return videoThumbnails.length > 0;
}
return false;
} catch (error) {
console.error('Error deleting thumbnail:', error);
return false;
}
}
}