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:
parent
1321038609
commit
60cf545f79
2
.replit
2
.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"
|
||||
|
||||
212
client/src/components/thumbnail-generator.tsx
Normal file
212
client/src/components/thumbnail-generator.tsx
Normal file
@ -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<Array<{
|
||||
timestamp: string;
|
||||
url: string;
|
||||
path: string;
|
||||
}>>([]);
|
||||
|
||||
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 (
|
||||
<Card className="w-full max-w-4xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Ustvarjanje Thumbnail Slik
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generirajte thumbnail slike iz različnih trenutkov videa "{video.title}"
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Quick generate buttons */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Hitro generiranje</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={generateMultipleThumbnails}
|
||||
disabled={isGenerating}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isGenerating ? "Generiranje..." : "Generiraj 5 slik (5s, 15s, 30s, 60s, 90s)"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom time input */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Določi čas</Label>
|
||||
<div className="flex gap-2 max-w-sm">
|
||||
<Input
|
||||
type="number"
|
||||
value={customTime}
|
||||
onChange={(e) => setCustomTime(e.target.value)}
|
||||
placeholder="Sekunde (npr. 30)"
|
||||
min="0"
|
||||
max={video.duration || 300}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => generateThumbnail(customTime)}
|
||||
disabled={isGenerating}
|
||||
variant="outline"
|
||||
>
|
||||
Generiraj
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Vnesite čas v sekundah (0 - {video.duration || 300}s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Generated thumbnails grid */}
|
||||
{generatedThumbnails.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Generirane slike</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{generatedThumbnails.map((thumbnail, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<div className="aspect-video bg-gray-100 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={thumbnail.url}
|
||||
alt={`Thumbnail at ${thumbnail.timestamp}s`}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 left-2 bg-black/80 px-2 py-1 rounded text-xs text-white">
|
||||
{thumbnail.timestamp}s
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
const link = document.createElement('a');
|
||||
link.href = thumbnail.url;
|
||||
link.download = `thumbnail-${video.id}-${thumbnail.timestamp}s.jpg`;
|
||||
link.click();
|
||||
}}
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
if (onThumbnailGenerated) {
|
||||
onThumbnailGenerated(thumbnail.url);
|
||||
}
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="text-sm text-muted-foreground space-y-2 border-l-4 border-blue-500 pl-4">
|
||||
<p><strong>Navodila:</strong></p>
|
||||
<ul className="space-y-1 list-disc list-inside ml-2">
|
||||
<li>Kliknite "Generiraj 5 slik" za hitro ustvarjanje slik iz različnih trenutkov</li>
|
||||
<li>Uporabite "Določi čas" za ustvarjanje slike iz določene sekunde</li>
|
||||
<li>Kliknite ✓ da nastavite sliko kot glavno thumbnail</li>
|
||||
<li>Kliknite ⬇ da prenesete sliko na svoj računalnik</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
14
replit.md
14
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
|
||||
|
||||
164
server/routes.ts
164
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<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
server/thumbnail-generator.ts
Normal file
158
server/thumbnail-generator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user