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
This commit is contained in:
sebastjanartic
2025-08-04 19:51:11 +00:00
parent 1321038609
commit 60cf545f79
5 changed files with 492 additions and 58 deletions
+113 -51
View File
@@ -3,8 +3,10 @@ import { createServer, type Server } from "http";
import { storage } from "./storage";
import { z } from "zod";
import { BunnyService } from "./bunny";
import { ThumbnailGenerator } from "./thumbnail-generator";
export async function registerRoutes(app: Express): Promise<Server> {
const thumbnailGenerator = new ThumbnailGenerator();
// Get videos with pagination and filtering
app.get("/api/videos", async (req, res) => {
try {
@@ -118,70 +120,130 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Create SVG thumbnails with video title and play button
// Generate real thumbnail from video frame at specific time
app.get("/thumbnail/:videoId", async (req, res) => {
const timeStamp = req.query.t as string || "5"; // Default to 5 seconds
try {
const { videoId } = req.params;
// Get video info
const video = await storage.getVideo(videoId);
if (!video) {
const fallbackSvg = `
<svg width="400" height="225" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="225" fill="#1a1a1a"/>
<text x="200" y="112" text-anchor="middle" fill="white" font-family="Arial" font-size="16">Video not found</text>
</svg>
`;
res.setHeader('Content-Type', 'image/svg+xml');
return res.send(fallbackSvg);
return res.status(404).json({ message: "Video not found" });
}
// Clean up title for display
const displayTitle = video.title.replace('.mp4', '').substring(0, 40);
const duration = video.duration ? Math.floor(video.duration / 60) + ':' + String(video.duration % 60).padStart(2, '0') : '';
// Try to generate real thumbnail from video
const thumbnailPath = await thumbnailGenerator.generateThumbnail({
videoId,
timeStamp,
width: 400,
height: 225,
quality: 85
});
// Create SVG thumbnail with play button
const svg = `
<svg width="400" height="225" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1e40af;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e3a8a;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="225" fill="url(#grad)" />
<!-- Play button circle -->
<circle cx="200" cy="112" r="35" fill="rgba(255,255,255,0.9)" />
<!-- Play button triangle -->
<polygon points="188,97 188,127 218,112" fill="#1e40af" />
<!-- Title background -->
<rect x="0" y="175" width="400" height="50" fill="rgba(0,0,0,0.7)" />
<!-- Title text -->
<text x="20" y="200" fill="white" font-family="Arial, sans-serif" font-size="14" font-weight="bold">${displayTitle}</text>
<!-- Duration -->
${duration ? `<text x="370" y="200" fill="white" font-family="Arial, sans-serif" font-size="12" text-anchor="end">${duration}</text>` : ''}
</svg>
`;
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('Access-Control-Allow-Origin', '*');
res.send(svg);
if (thumbnailPath && require('fs').existsSync(thumbnailPath)) {
// Serve the generated thumbnail
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('Access-Control-Allow-Origin', '*');
return res.sendFile(require('path').resolve(thumbnailPath));
} else {
// Fallback to SVG if thumbnail generation fails
const displayTitle = video.title.replace('.mp4', '').substring(0, 40);
const duration = video.duration ? Math.floor(video.duration / 60) + ':' + String(video.duration % 60).padStart(2, '0') : '';
const svg = `
<svg width="400" height="225" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1e40af;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e3a8a;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="225" fill="url(#grad)" />
<!-- Play button circle -->
<circle cx="200" cy="112" r="35" fill="rgba(255,255,255,0.9)" />
<!-- Play button triangle -->
<polygon points="188,97 188,127 218,112" fill="#1e40af" />
<!-- Title background -->
<rect x="0" y="175" width="400" height="50" fill="rgba(0,0,0,0.7)" />
<!-- Title text -->
<text x="20" y="200" fill="white" font-family="Arial, sans-serif" font-size="14" font-weight="bold">${displayTitle}</text>
<!-- Duration -->
${duration ? `<text x="370" y="200" fill="white" font-family="Arial, sans-serif" font-size="12" text-anchor="end">${duration}</text>` : ''}
<!-- Time indicator -->
<text x="20" y="25" fill="rgba(255,255,255,0.8)" font-family="Arial, sans-serif" font-size="12">t=${timeStamp}s</text>
</svg>
`;
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('Access-Control-Allow-Origin', '*');
return res.send(svg);
}
} catch (error) {
console.error("Error creating thumbnail:", error);
const errorSvg = `
<svg width="400" height="225" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="225" fill="#dc2626"/>
<text x="200" y="112" text-anchor="middle" fill="white" font-family="Arial" font-size="16">Error loading thumbnail</text>
</svg>
`;
res.setHeader('Content-Type', 'image/svg+xml');
res.send(errorSvg);
res.status(500).json({ message: "Error generating thumbnail" });
}
});
// API endpoint to generate multiple thumbnails for video preview
app.post("/api/thumbnails/:videoId/generate", async (req, res) => {
try {
const { videoId } = req.params;
const { timestamps = ["5", "15", "30", "60"] } = req.body;
const video = await storage.getVideo(videoId);
if (!video) {
return res.status(404).json({ message: "Video not found" });
}
const thumbnailPaths = await thumbnailGenerator.generateMultipleThumbnails(videoId, timestamps);
const thumbnails = thumbnailPaths.map((path, index) => ({
timestamp: timestamps[index],
url: `/thumbnail/${videoId}?t=${timestamps[index]}`,
path: path
}));
res.json({ thumbnails });
} catch (error) {
console.error("Error generating thumbnails:", error);
res.status(500).json({ message: "Error generating thumbnails" });
}
});
// API endpoint to list existing thumbnails for a video
app.get("/api/thumbnails/:videoId", async (req, res) => {
try {
const { videoId } = req.params;
const thumbnailPaths = thumbnailGenerator.listThumbnails(videoId);
const thumbnails = thumbnailPaths.map(path => {
const filename = require('path').basename(path);
const match = filename.match(new RegExp(`${videoId}_(.+)_\\d+x\\d+\\.jpg`));
const timestamp = match ? match[1].replace(/-/g, ':') : 'unknown';
return {
timestamp,
url: `/thumbnail/${videoId}?t=${timestamp}`,
path: path
};
});
res.json({ thumbnails });
} catch (error) {
console.error("Error listing thumbnails:", error);
res.status(500).json({ message: "Error listing thumbnails" });
}
});
+158
View File
@@ -0,0 +1,158 @@
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;
}
}
}