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
158 lines
4.3 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
} |