Add ability to capture custom thumbnails from video frames
Adds a thumbnail generator component allowing users to capture frames from a video or upload their own custom images to serve as thumbnails. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 11420304-80a9-4ef2-adff-cbdaa418ffa8 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/11420304-80a9-4ef2-adff-cbdaa418ffa8/yWSdtYr
This commit is contained in:
parent
84c41fe289
commit
cce4d2456f
3
.replit
3
.replit
@ -37,3 +37,6 @@ author = "agent"
|
||||
task = "shell.exec"
|
||||
args = "npm run dev"
|
||||
waitForPort = 5000
|
||||
|
||||
[agent]
|
||||
integrations = ["javascript_object_storage==1.0.0"]
|
||||
|
||||
336
client/src/components/thumbnail-generator.tsx
Normal file
336
client/src/components/thumbnail-generator.tsx
Normal file
@ -0,0 +1,336 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { X, Download, Upload, Check, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import Hls from "hls.js";
|
||||
|
||||
interface ThumbnailGeneratorProps {
|
||||
videoUrl: string;
|
||||
videoTitle: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onThumbnailSelect: (thumbnailDataUrl: string) => void;
|
||||
}
|
||||
|
||||
export default function ThumbnailGenerator({
|
||||
videoUrl,
|
||||
videoTitle,
|
||||
isOpen,
|
||||
onClose,
|
||||
onThumbnailSelect
|
||||
}: ThumbnailGeneratorProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const [selectedThumbnail, setSelectedThumbnail] = useState<string | null>(null);
|
||||
const [customThumbnail, setCustomThumbnail] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
// Initialize video when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && videoRef.current && videoUrl) {
|
||||
initializeVideo();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen, videoUrl]);
|
||||
|
||||
const initializeVideo = () => {
|
||||
const videoElement = videoRef.current;
|
||||
if (!videoElement) return;
|
||||
|
||||
// Clean up previous HLS instance
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
|
||||
if (videoUrl.includes('.m3u8')) {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
debug: false,
|
||||
enableWorker: false,
|
||||
lowLatencyMode: false
|
||||
});
|
||||
|
||||
hls.loadSource(videoUrl);
|
||||
hls.attachMedia(videoElement);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('Video loaded for thumbnail generation');
|
||||
setIsVideoLoaded(true);
|
||||
});
|
||||
|
||||
hlsRef.current = hls;
|
||||
} else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
videoElement.src = videoUrl;
|
||||
setIsVideoLoaded(true);
|
||||
}
|
||||
} else {
|
||||
videoElement.src = videoUrl;
|
||||
setIsVideoLoaded(true);
|
||||
}
|
||||
|
||||
// Video event listeners
|
||||
videoElement.addEventListener('loadedmetadata', () => {
|
||||
setDuration(videoElement.duration);
|
||||
setCurrentTime(0);
|
||||
});
|
||||
|
||||
videoElement.addEventListener('timeupdate', () => {
|
||||
setCurrentTime(videoElement.currentTime);
|
||||
});
|
||||
};
|
||||
|
||||
const generateThumbnail = async () => {
|
||||
const videoElement = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
if (!videoElement || !canvas) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas dimensions to match video
|
||||
canvas.width = videoElement.videoWidth || 1280;
|
||||
canvas.height = videoElement.videoHeight || 720;
|
||||
|
||||
// Draw current video frame to canvas
|
||||
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Convert to data URL
|
||||
const thumbnailDataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||
setSelectedThumbnail(thumbnailDataUrl);
|
||||
setCustomThumbnail(null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating thumbnail:', error);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeSliderChange = (value: number[]) => {
|
||||
const newTime = value[0];
|
||||
setCurrentTime(newTime);
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = newTime;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string;
|
||||
setCustomThumbnail(result);
|
||||
setSelectedThumbnail(null);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleSaveThumbnail = () => {
|
||||
const thumbnail = selectedThumbnail || customThumbnail;
|
||||
if (thumbnail) {
|
||||
onThumbnailSelect(thumbnail);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Generiraj thumbnail sliko
|
||||
</h2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
data-testid="button-close-thumbnail-generator"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Video Player Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Video predogled
|
||||
</h3>
|
||||
|
||||
<div className="relative bg-black rounded-lg overflow-hidden">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-auto"
|
||||
muted
|
||||
preload="metadata"
|
||||
data-testid="thumbnail-video-player"
|
||||
>
|
||||
Vaš brskalnik ne podpira video oznake.
|
||||
</video>
|
||||
|
||||
{!isVideoLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-800">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time Slider */}
|
||||
{isVideoLoaded && duration > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
onValueChange={handleTimeSliderChange}
|
||||
max={duration}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
data-testid="time-slider"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate Button */}
|
||||
<Button
|
||||
onClick={generateThumbnail}
|
||||
disabled={!isVideoLoaded || isGenerating}
|
||||
className="w-full"
|
||||
data-testid="button-generate-thumbnail"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Generiram...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Generiraj thumbnail iz trenutnega okvirja
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Preview Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Predogled thumbnail
|
||||
</h3>
|
||||
|
||||
{/* Thumbnail Preview */}
|
||||
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 min-h-[200px] flex items-center justify-center">
|
||||
{selectedThumbnail || customThumbnail ? (
|
||||
<div className="space-y-2">
|
||||
<img
|
||||
src={selectedThumbnail || customThumbnail || ""}
|
||||
alt="Thumbnail predogled"
|
||||
className="max-w-full max-h-48 rounded"
|
||||
data-testid="thumbnail-preview"
|
||||
/>
|
||||
<p className="text-xs text-center text-gray-600 dark:text-gray-400">
|
||||
{selectedThumbnail ? 'Generirano iz videoposnetka' : 'Naložena slika'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-2 bg-gray-300 dark:bg-gray-600 rounded-lg flex items-center justify-center">
|
||||
📷
|
||||
</div>
|
||||
<p>Generirajte thumbnail iz videoposnetka ali naložite svojo sliko</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom Upload */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||
Ali naložite svojo sliko
|
||||
</h4>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleCustomImageUpload}
|
||||
className="hidden"
|
||||
data-testid="custom-thumbnail-upload"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
data-testid="button-upload-custom"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Naloži svojo sliko
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedThumbnail(null);
|
||||
setCustomThumbnail(null);
|
||||
}}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={!selectedThumbnail && !customThumbnail}
|
||||
data-testid="button-reset-thumbnail"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Ponastavi
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveThumbnail}
|
||||
disabled={!selectedThumbnail && !customThumbnail}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700"
|
||||
data-testid="button-save-thumbnail"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Izberi thumbnail
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden canvas for thumbnail generation */}
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -23,4 +23,4 @@ const Slider = React.forwardRef<
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
export { Slider }
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { X, Upload, Save } from "lucide-react";
|
||||
import { X, Upload, Save, Camera } from "lucide-react";
|
||||
import { type Video, type UpdateVideo, updateVideoSchema } from "@shared/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import ThumbnailGenerator from "./thumbnail-generator";
|
||||
|
||||
interface VideoEditModalProps {
|
||||
video: Video;
|
||||
@ -23,6 +24,7 @@ export default function VideoEditModal({ video, isOpen, onClose }: VideoEditModa
|
||||
const [isPublic, setIsPublic] = useState(video.isPublic);
|
||||
const [customThumbnail, setCustomThumbnail] = useState<File | null>(null);
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(video.customThumbnailUrl);
|
||||
const [showThumbnailGenerator, setShowThumbnailGenerator] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -57,6 +59,13 @@ export default function VideoEditModal({ video, isOpen, onClose }: VideoEditModa
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailFromVideo = (thumbnailDataUrl: string) => {
|
||||
setThumbnailPreview(thumbnailDataUrl);
|
||||
setCustomThumbnail(null); // Clear file input since we're using generated thumbnail
|
||||
setShowThumbnailGenerator(false);
|
||||
toast({ title: "Thumbnail ustvarjen iz videoposnetka!" });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
try {
|
||||
const tagsArray = tags.split(",").map(tag => tag.trim()).filter(tag => tag.length > 0);
|
||||
@ -175,29 +184,43 @@ export default function VideoEditModal({ video, isOpen, onClose }: VideoEditModa
|
||||
<Label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Slika predogleda
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="space-y-3">
|
||||
{thumbnailPreview && (
|
||||
<div className="w-32 h-18 rounded overflow-hidden">
|
||||
<div className="w-48 h-27 rounded overflow-hidden border">
|
||||
<img
|
||||
src={thumbnailPreview}
|
||||
alt="Predogled"
|
||||
alt="Predogled thumbnail"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Label className="cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleThumbnailChange}
|
||||
className="hidden"
|
||||
data-testid="input-thumbnail-upload"
|
||||
/>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||
<Upload className="w-4 h-4" />
|
||||
<span className="text-sm">Naloži sliko</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setShowThumbnailGenerator(true)}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
data-testid="button-generate-from-video"
|
||||
>
|
||||
<Camera className="w-4 h-4 mr-2" />
|
||||
Generiraj iz videoposnetka
|
||||
</Button>
|
||||
|
||||
<Label className="cursor-pointer flex-1">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleThumbnailChange}
|
||||
className="hidden"
|
||||
data-testid="input-thumbnail-upload"
|
||||
/>
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors border text-sm font-medium h-10">
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>Naloži sliko</span>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -247,6 +270,15 @@ export default function VideoEditModal({ video, isOpen, onClose }: VideoEditModa
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Generator Modal */}
|
||||
<ThumbnailGenerator
|
||||
videoUrl={video.videoUrl}
|
||||
videoTitle={video.title}
|
||||
isOpen={showThumbnailGenerator}
|
||||
onClose={() => setShowThumbnailGenerator(false)}
|
||||
onThumbnailSelect={handleThumbnailFromVideo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1185
package-lock.json
generated
1185
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -11,6 +11,7 @@
|
||||
"db:push": "drizzle-kit push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
"@google-cloud/video-intelligence": "^6.2.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
@ -34,7 +35,7 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||
"@radix-ui/react-select": "^2.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.3",
|
||||
"@radix-ui/react-slider": "^1.2.4",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.1.4",
|
||||
"@radix-ui/react-tabs": "^1.1.4",
|
||||
@ -44,6 +45,13 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"@types/video.js": "^7.3.58",
|
||||
"@uppy/aws-s3": "^4.3.2",
|
||||
"@uppy/core": "^4.5.2",
|
||||
"@uppy/dashboard": "^4.4.3",
|
||||
"@uppy/drag-drop": "^4.2.2",
|
||||
"@uppy/file-input": "^4.2.2",
|
||||
"@uppy/progress-bar": "^4.3.2",
|
||||
"@uppy/react": "^4.5.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@ -55,6 +63,7 @@
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"framer-motion": "^11.13.1",
|
||||
"google-auth-library": "^10.2.1",
|
||||
"hls.js": "^1.6.7",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
|
||||
@ -14,7 +14,8 @@ VideoStream is a fully functional video streaming platform that integrates direc
|
||||
- ✅ **Error Handling**: Robust error handling with Video.js fallback mechanisms
|
||||
- ✅ **Performance**: Optimized video loading with adaptive bitrate streaming and proper buffering
|
||||
- ✅ **Social Media Sharing**: Implemented direct social sharing for Facebook, Twitter, WhatsApp with custom popup windows and automatic video thumbnail capture
|
||||
- ✅ **Monetization Ready**: Professional advertising framework ready for revenue generation
|
||||
- ✅ **YouTube-Style Editing**: Complete video editing interface with title, description, category, tags, and privacy controls
|
||||
- ✅ **Interactive Thumbnail Generator**: Advanced thumbnail creation from any video frame with timeline scrubbing and custom image upload
|
||||
- ✅ **Copy Link Feature**: Easy link copying with visual feedback notifications
|
||||
|
||||
## User Preferences
|
||||
|
||||
Loading…
Reference in New Issue
Block a user