From 60cf545f796fbe52c92571548ab74eb9728d4c7a Mon Sep 17 00:00:00 2001 From: sebastjanartic <45803536-sebastjanartic@users.noreply.replit.com> Date: Mon, 4 Aug 2025 19:51:11 +0000 Subject: [PATCH] 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 --- .replit | 2 +- client/src/components/thumbnail-generator.tsx | 212 ++++++++++++++++++ replit.md | 14 +- server/routes.ts | 164 +++++++++----- server/thumbnail-generator.ts | 158 +++++++++++++ 5 files changed, 492 insertions(+), 58 deletions(-) create mode 100644 client/src/components/thumbnail-generator.tsx create mode 100644 server/thumbnail-generator.ts diff --git a/.replit b/.replit index d2607c4..990e492 100644 --- a/.replit +++ b/.replit @@ -4,7 +4,7 @@ hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"] [nix] channel = "stable-24_05" -packages = ["ffmpeg"] +packages = ["ffmpeg", "imagemagick"] [deployment] deploymentTarget = "autoscale" diff --git a/client/src/components/thumbnail-generator.tsx b/client/src/components/thumbnail-generator.tsx new file mode 100644 index 0000000..0a16d5c --- /dev/null +++ b/client/src/components/thumbnail-generator.tsx @@ -0,0 +1,212 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Download, RefreshCw, Trash2 } from "lucide-react"; +import { type Video } from "@shared/schema"; + +interface ThumbnailGeneratorProps { + video: Video; + onThumbnailGenerated?: (thumbnailUrl: string) => void; +} + +export default function ThumbnailGenerator({ video, onThumbnailGenerated }: ThumbnailGeneratorProps) { + const [isGenerating, setIsGenerating] = useState(false); + const [customTime, setCustomTime] = useState("5"); + const [generatedThumbnails, setGeneratedThumbnails] = useState>([]); + + const generateThumbnail = async (timestamp: string) => { + setIsGenerating(true); + try { + const response = await fetch(`/api/thumbnails/${video.id}/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + timestamps: [timestamp] + }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.thumbnails && data.thumbnails.length > 0) { + const newThumbnail = data.thumbnails[0]; + setGeneratedThumbnails(prev => [...prev, newThumbnail]); + + if (onThumbnailGenerated) { + onThumbnailGenerated(newThumbnail.url); + } + } + } + } catch (error) { + console.error('Error generating thumbnail:', error); + } finally { + setIsGenerating(false); + } + }; + + const generateMultipleThumbnails = async () => { + setIsGenerating(true); + try { + const timestamps = ["5", "15", "30", "60", "90"]; + const response = await fetch(`/api/thumbnails/${video.id}/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + timestamps + }), + }); + + if (response.ok) { + const data = await response.json(); + setGeneratedThumbnails(data.thumbnails || []); + } + } catch (error) { + console.error('Error generating thumbnails:', error); + } finally { + setIsGenerating(false); + } + }; + + const loadExistingThumbnails = async () => { + try { + const response = await fetch(`/api/thumbnails/${video.id}`); + if (response.ok) { + const data = await response.json(); + setGeneratedThumbnails(data.thumbnails || []); + } + } catch (error) { + console.error('Error loading thumbnails:', error); + } + }; + + useState(() => { + loadExistingThumbnails(); + }, [video.id]); + + return ( + + + + + Ustvarjanje Thumbnail Slik + +

+ Generirajte thumbnail slike iz različnih trenutkov videa "{video.title}" +

+
+ + {/* Quick generate buttons */} +
+ +
+ +
+
+ + {/* Custom time input */} +
+ +
+ setCustomTime(e.target.value)} + placeholder="Sekunde (npr. 30)" + min="0" + max={video.duration || 300} + /> + +
+

+ Vnesite čas v sekundah (0 - {video.duration || 300}s) +

+
+ + {/* Generated thumbnails grid */} + {generatedThumbnails.length > 0 && ( +
+ +
+ {generatedThumbnails.map((thumbnail, index) => ( +
+
+ {`Thumbnail +
+
+ {thumbnail.timestamp}s +
+
+
+ + +
+
+
+ ))} +
+
+ )} + + {/* Instructions */} +
+

Navodila:

+
    +
  • Kliknite "Generiraj 5 slik" za hitro ustvarjanje slik iz različnih trenutkov
  • +
  • Uporabite "Določi čas" za ustvarjanje slike iz določene sekunde
  • +
  • Kliknite ✓ da nastavite sliko kot glavno thumbnail
  • +
  • Kliknite ⬇ da prenesete sliko na svoj računalnik
  • +
+
+
+
+ ); +} \ No newline at end of file diff --git a/replit.md b/replit.md index cba9314..ce29080 100644 --- a/replit.md +++ b/replit.md @@ -16,12 +16,14 @@ Preferred communication style: Simple, everyday language. - Added fullscreen capabilities and proper video controls - Resolved authentication issues with private video library (ID: 476412) -### Thumbnail System ✅ -- Implemented dynamic SVG thumbnail generation with video titles and duration -- Created attractive blue gradient backgrounds with play button overlay -- Thumbnails display properly with video metadata (title, duration) -- Optimized for social media sharing with proper dimensions (400x225px) -- Eliminated dependency on external thumbnail services +### Advanced Thumbnail Generation System ✅ +- Implemented FFmpeg and ImageMagick for extracting real frames from videos +- Added ability to specify exact timestamp for thumbnail generation (e.g., ?t=30) +- Created comprehensive ThumbnailGenerator class with multiple extraction options +- Added API endpoints for generating multiple thumbnails at once +- Built React component for easy thumbnail selection and generation +- Fallback to attractive SVG thumbnails when video extraction fails +- Cache system for optimized performance and reduced server load ### Video Sharing Functionality ✅ - Added comprehensive share functionality for social media platforms diff --git a/server/routes.ts b/server/routes.ts index 60cd1d6..ad31da2 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -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 { + 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 { } }); - // 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 = ` - - - Video not found - - `; - 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 = ` - - - - - - - - - - - - - - - - - - - ${displayTitle} - - - ${duration ? `${duration}` : ''} - - `; - - 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 = ` + + + + + + + + + + + + + + + + + + + ${displayTitle} + + + ${duration ? `${duration}` : ''} + + + t=${timeStamp}s + + `; + + 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 = ` - - - Error loading thumbnail - - `; - 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" }); } }); diff --git a/server/thumbnail-generator.ts b/server/thumbnail-generator.ts new file mode 100644 index 0000000..87ff504 --- /dev/null +++ b/server/thumbnail-generator.ts @@ -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 { + 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 { + 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 { + 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; + } + } +} \ No newline at end of file