Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
| import React from 'react' | |
| import type { ExamplesData } from './Examples' | |
| import { groupByNameAndVariant, VARIANT_NAME_MAP } from './galleryUtils' | |
| import ExampleVariantMetricsTables from './ExampleVariantMetricsTable' | |
| import ExampleDetailsSection from './ExampleDetailsSection' | |
| import ExampleVariantSelector from './ExampleVariantSelector' | |
| import ExampleVariantToggle from './ExampleVariantToggle' | |
| import API from '../API' | |
| interface GalleryProps { | |
| selectedModel: string | |
| selectedAttack: string | |
| examples: { | |
| [model: string]: { | |
| [attack: string]: ExamplesData[] | |
| } | |
| } | |
| } | |
| const VideoGallery: React.FC<GalleryProps> = ({ selectedModel, selectedAttack, examples }) => { | |
| const exampleItems = examples[selectedModel][selectedAttack] | |
| const grouped = groupByNameAndVariant(exampleItems) | |
| const videoNames = Object.keys(grouped) | |
| const [selectedVideo, setSelectedVideo] = React.useState(videoNames[0] || '') | |
| const variants = grouped[selectedVideo] || {} | |
| const variantKeys = Object.keys(variants) | |
| const originalVariant = 'original' | |
| // Comparison variant (watermarked or attacked) | |
| const [comparisonVariant, setComparisonVariant] = React.useState<string>('attacked') | |
| // Slider position (0 = all original, 100 = all comparison) | |
| const [sliderPosition, setSliderPosition] = React.useState(50) | |
| // State for video scale | |
| const [videoScale, setVideoScale] = React.useState(1) | |
| // Playback time ref for syncing position | |
| const playbackTimeRef = React.useRef(0) | |
| // Refs for all video elements | |
| const videoRefs = React.useMemo(() => { | |
| const refs: Record<string, React.RefObject<HTMLVideoElement>> = {} | |
| variantKeys.forEach((v) => { | |
| refs[v] = React.createRef<HTMLVideoElement>() | |
| }) | |
| return refs | |
| }, [variantKeys.join(',')]) | |
| // Track if videos are playing and data loading | |
| const [isPlaying, setIsPlaying] = React.useState(false) | |
| const [isDataLoaded, setIsDataLoaded] = React.useState(false) | |
| // Track if video was playing before seeking | |
| const [wasPlayingBeforeSeeking, setWasPlayingBeforeSeeking] = React.useState(false) | |
| // Toggle playback of all videos | |
| const togglePlayback = () => { | |
| if (isPlaying) { | |
| // Pause all videos | |
| variantKeys.forEach((v) => { | |
| if (videoRefs[v]?.current) { | |
| videoRefs[v]?.current?.pause() | |
| } | |
| }) | |
| setIsPlaying(false) | |
| } else { | |
| // Play all videos | |
| variantKeys.forEach((v) => { | |
| if (videoRefs[v]?.current) { | |
| videoRefs[v]?.current?.play() | |
| } | |
| }) | |
| setIsPlaying(true) | |
| } | |
| } | |
| // When the video plays or pauses, update isPlaying state | |
| const handlePlay = () => { | |
| setIsPlaying(true) | |
| } | |
| const handlePause = () => { | |
| setIsPlaying(false) | |
| } | |
| // When any video time updates, sync all others | |
| const handleTimeUpdate = (sourceVariant: string) => { | |
| const sourceRef = videoRefs[sourceVariant]?.current | |
| if (sourceRef) { | |
| playbackTimeRef.current = sourceRef.currentTime | |
| // Only sync other videos, time display is handled by requestAnimationFrame | |
| // for smoother updates | |
| variantKeys.forEach((v) => { | |
| if (v !== sourceVariant && videoRefs[v]?.current) { | |
| const targetRef = videoRefs[v].current | |
| // Use a smaller threshold for more precise sync (0.05s instead of 0.1s) | |
| if (Math.abs(targetRef.currentTime - playbackTimeRef.current) > 0.05) { | |
| targetRef.currentTime = playbackTimeRef.current | |
| } | |
| } | |
| }) | |
| } | |
| } | |
| // Format time in MM:SS.ms format | |
| const formatTime = (timeInSeconds: number): string => { | |
| if (isNaN(timeInSeconds)) return '00:00.00' | |
| const minutes = Math.floor(timeInSeconds / 60) | |
| const seconds = Math.floor(timeInSeconds % 60) | |
| const milliseconds = Math.floor((timeInSeconds % 1) * 100) | |
| return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}` | |
| } | |
| // Video container dimensions state | |
| const [videoDimensions, setVideoDimensions] = React.useState({ width: 0, height: 0 }) | |
| // Handle video loaded metadata to get dimensions | |
| const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement>) => { | |
| const video = e.currentTarget | |
| setVideoDimensions({ | |
| width: video.videoWidth, | |
| height: video.videoHeight, | |
| }) | |
| setIsDataLoaded(true) | |
| } | |
| // Variable to track current time and duration | |
| const [videoTime, setVideoTime] = React.useState({ current: 0, duration: 0 }) | |
| // Update time display when time updates | |
| const handleTimeDisplayUpdate = () => { | |
| if (videoRefs[originalVariant]?.current) { | |
| const video = videoRefs[originalVariant].current | |
| // Store the time with full precision (up to milliseconds) | |
| setVideoTime({ | |
| current: video.currentTime, | |
| duration: video.duration || 0, | |
| }) | |
| } | |
| } | |
| // Memoized formatted times | |
| const formattedCurrentTime = React.useMemo( | |
| () => formatTime(videoTime.current), | |
| [videoTime.current] | |
| ) | |
| const formattedDuration = React.useMemo( | |
| () => formatTime(videoTime.duration), | |
| [videoTime.duration] | |
| ) | |
| // Add a requestAnimationFrame-based timer for smoother time updates | |
| const animationFrameRef = React.useRef<number | null>(null) | |
| // Effect to continuously update the time for smoother display | |
| React.useEffect(() => { | |
| const updateTimeLoop = () => { | |
| if (videoRefs[originalVariant]?.current) { | |
| const video = videoRefs[originalVariant].current | |
| // Always update the time, regardless of playing state | |
| setVideoTime((prev) => ({ | |
| ...prev, | |
| current: video.currentTime, | |
| duration: video.duration || 0, | |
| })) | |
| } | |
| // Continue the loop | |
| animationFrameRef.current = requestAnimationFrame(updateTimeLoop) | |
| } | |
| // Start the loop - always run to ensure slider stays in sync | |
| animationFrameRef.current = requestAnimationFrame(updateTimeLoop) | |
| // Clean up on unmount | |
| return () => { | |
| if (animationFrameRef.current !== null) { | |
| cancelAnimationFrame(animationFrameRef.current) | |
| animationFrameRef.current = null | |
| } | |
| } | |
| }, [originalVariant]) | |
| if (!videoNames.length) { | |
| return ( | |
| <div className="w-full mt-12 flex items-center justify-center"> | |
| <div className="text-gray-500"> | |
| No video examples available. Please select another model and attack. | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // Calculate clip path for original and comparison videos | |
| const originalClipPath = `inset(0 ${100 - sliderPosition}% 0 0)` // Left side (original) | |
| const comparisonClipPath = `inset(0 0 0 ${sliderPosition}%)` // Right side (comparison/variant) | |
| return ( | |
| <div className="w-full overflow-auto" style={{ minHeight: '100vh' }}> | |
| <div className="example-display"> | |
| <div className="mb-4"> | |
| <fieldset className="fieldset"> | |
| <legend className="fieldset-legend">Video</legend> | |
| <select | |
| className="select select-bordered" | |
| value={selectedVideo || ''} | |
| onChange={(e) => { | |
| setSelectedVideo(e.target.value || '') | |
| }} | |
| > | |
| {videoNames.map((name) => ( | |
| <option key={name} value={name}> | |
| {name} | |
| </option> | |
| ))} | |
| </select> | |
| </fieldset> | |
| </div> | |
| {selectedVideo && variants[originalVariant] && variants[comparisonVariant] && ( | |
| <> | |
| <ExampleVariantMetricsTables | |
| variantMetadatas={Object.fromEntries( | |
| variantKeys.map((v) => [v, variants[v]?.metadata || {}]) | |
| )} | |
| /> | |
| <ExampleDetailsSection> | |
| <div className="flex flex-col gap-4"> | |
| <div className="flex items-center gap-4 mt-2"> | |
| <label htmlFor="comparison-variant" className="font-mono text-xs"> | |
| Right Side: | |
| </label> | |
| <select | |
| id="comparison-variant" | |
| className="select select-bordered select-xs" | |
| value={comparisonVariant} | |
| onChange={(e) => setComparisonVariant(e.target.value)} | |
| > | |
| {variantKeys | |
| .filter((v) => v !== originalVariant) | |
| .map((v) => ( | |
| <option key={v} value={v}> | |
| {VARIANT_NAME_MAP[v]} | |
| </option> | |
| ))} | |
| </select> | |
| <button onClick={togglePlayback} className="btn btn-xs btn-primary ml-4"> | |
| {isPlaying ? 'Pause' : 'Play'} | |
| </button> | |
| <label htmlFor="video-scale" className="font-mono text-xs ml-4"> | |
| Scale: | |
| </label> | |
| <input | |
| id="video-scale" | |
| type="range" | |
| min={0.3} | |
| max={1} | |
| step={0.01} | |
| value={videoScale} | |
| onChange={(e) => setVideoScale(Number(e.target.value))} | |
| className="range range-xs" | |
| style={{ | |
| verticalAlign: 'middle', | |
| width: '120px', // Make slider smaller | |
| accentColor: '#3b82f6', // Match playback slider color | |
| }} | |
| /> | |
| <span className="ml-2 font-mono text-xs">{(videoScale * 100).toFixed(0)}%</span> | |
| </div> | |
| {/* Labels for comparison */} | |
| <div className="mb-2"> | |
| <div className="flex justify-between text-xs font-mono"> | |
| <span className="font-bold">Left: {VARIANT_NAME_MAP[originalVariant]}</span> | |
| <span className="font-bold">Right: {VARIANT_NAME_MAP[comparisonVariant]}</span> | |
| </div> | |
| </div> | |
| {/* Video comparison container */} | |
| <div | |
| className="relative w-full" | |
| style={{ width: `${videoScale * 100}%`, margin: '0 auto', minHeight: '300px' }} | |
| > | |
| {/* Reference video for dimensions (invisible) */} | |
| <video | |
| className="w-full h-auto opacity-0" | |
| src={API.getProxiedUrl(variants[originalVariant].video_url || '')} | |
| /> | |
| {/* Original video */} | |
| {variants[originalVariant]?.video_url && ( | |
| <div | |
| className="absolute top-0 left-0 w-full h-full" | |
| style={{ clipPath: originalClipPath }} | |
| > | |
| <video | |
| ref={videoRefs[originalVariant]} | |
| controls={false} | |
| src={API.getProxiedUrl(variants[originalVariant].video_url)} | |
| className="w-full h-auto" | |
| onTimeUpdate={() => handleTimeUpdate(originalVariant)} | |
| onDurationChange={handleTimeDisplayUpdate} | |
| onPlay={handlePlay} | |
| onPause={handlePause} | |
| onLoadedMetadata={handleLoadedMetadata} | |
| /> | |
| </div> | |
| )} | |
| {/* Comparison video */} | |
| {variants[comparisonVariant]?.video_url && ( | |
| <div | |
| className="absolute top-0 left-0 w-full h-full" | |
| style={{ clipPath: comparisonClipPath }} | |
| > | |
| <video | |
| ref={videoRefs[comparisonVariant]} | |
| controls={false} | |
| src={API.getProxiedUrl(variants[comparisonVariant].video_url)} | |
| className="w-full h-auto" | |
| onTimeUpdate={() => handleTimeUpdate(comparisonVariant)} | |
| onDurationChange={handleTimeDisplayUpdate} | |
| onPlay={handlePlay} | |
| onPause={handlePause} | |
| onLoadedMetadata={handleLoadedMetadata} | |
| /> | |
| </div> | |
| )} | |
| {/* Slider handle indicator */} | |
| <div | |
| className="absolute top-0 bottom-0 z-10 pointer-events-none" | |
| style={{ | |
| left: `${sliderPosition}%`, | |
| height: '100%', | |
| width: '4px', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| justifyContent: 'center', | |
| alignItems: 'center', | |
| }} | |
| > | |
| <div className="bg-white h-full w-1 opacity-80"></div> | |
| <div | |
| className="absolute bg-white rounded-full p-1.5 opacity-90 shadow-md" | |
| style={{ | |
| transform: 'translate(-50%, 0)', | |
| left: '50%', | |
| }} | |
| > | |
| <svg | |
| width="20" | |
| height="20" | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| xmlns="http://www.w3.org/2000/svg" | |
| > | |
| <path | |
| d="M8 7L4 12L8 17M16 7L20 12L16 17" | |
| stroke="black" | |
| strokeWidth="2.5" | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| /> | |
| </svg> | |
| </div> | |
| </div> | |
| {/* Transparent controls overlay */} | |
| <div | |
| className="absolute top-0 left-0 w-full h-full z-20" | |
| style={{ | |
| background: 'transparent', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| justifyContent: 'flex-end', | |
| }} | |
| > | |
| {/* Slider directly on video */} | |
| <input | |
| type="range" | |
| min={0} | |
| max={100} | |
| value={sliderPosition} | |
| onChange={(e) => setSliderPosition(Number(e.target.value))} | |
| className="range range-xs w-full absolute top-1/2 left-0 right-0 z-30" | |
| style={{ | |
| transform: 'translateY(-50%)', | |
| margin: '0 auto', | |
| width: '100%', // Full width | |
| opacity: 0, | |
| cursor: 'ew-resize', | |
| height: '50px', // Taller hit area for easier interaction | |
| }} | |
| /> | |
| {/* Click area for play/pause - covers entire video */} | |
| <div className="w-full h-full" onClick={togglePlayback}></div> | |
| </div> | |
| {/* Play/pause icon overlay */} | |
| {!isPlaying && ( | |
| <div | |
| className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 | |
| bg-black bg-opacity-60 rounded-full p-4 cursor-pointer z-40" | |
| onClick={togglePlayback} | |
| > | |
| <svg | |
| width="30" | |
| height="30" | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| xmlns="http://www.w3.org/2000/svg" | |
| > | |
| <path d="M8 5V19L19 12L8 5Z" fill="white" /> | |
| </svg> | |
| </div> | |
| )} | |
| </div> | |
| {/* Playback position slider */} | |
| <div className="w-full mt-2 flex flex-col gap-1" style={{ width: '100%' }}> | |
| <input | |
| type="range" | |
| min={0} | |
| max={videoTime.duration || 100} | |
| step={0.01} /* More granular step size for smoother scrubbing */ | |
| value={videoTime.current || 0} | |
| onMouseDown={() => { | |
| // Store current playing state | |
| setWasPlayingBeforeSeeking(isPlaying) | |
| if (isPlaying) { | |
| // Pause videos while seeking | |
| variantKeys.forEach((v) => { | |
| if (videoRefs[v]?.current) { | |
| videoRefs[v].current.pause() | |
| } | |
| }) | |
| setIsPlaying(false) | |
| } | |
| }} | |
| onChange={(e) => { | |
| const newTime = parseFloat(e.target.value) | |
| // Only update time display immediately for responsive UI | |
| setVideoTime((prev) => ({ ...prev, current: newTime })) | |
| }} | |
| onInput={(e) => { | |
| // Update videos as user drags (more responsive than just onChange) | |
| const newTime = parseFloat((e.target as HTMLInputElement).value) | |
| variantKeys.forEach((v) => { | |
| if (videoRefs[v]?.current) { | |
| videoRefs[v].current.currentTime = newTime | |
| } | |
| }) | |
| }} | |
| onMouseUp={(e) => { | |
| // Final time setting to ensure perfect sync on release | |
| const newTime = parseFloat((e.target as HTMLInputElement).value) | |
| variantKeys.forEach((v) => { | |
| if (videoRefs[v]?.current) { | |
| videoRefs[v].current.currentTime = newTime | |
| } | |
| }) | |
| // If it was playing before seeking, resume playback | |
| if (document.activeElement === e.target) { | |
| // Remove focus from the slider | |
| ;(e.target as HTMLInputElement).blur() | |
| // Small delay to ensure the time has been updated | |
| setTimeout(() => { | |
| // Resume playback if it was playing before | |
| if (wasPlayingBeforeSeeking) { | |
| variantKeys.forEach((v) => { | |
| if (videoRefs[v]?.current) { | |
| videoRefs[v].current.play() | |
| } | |
| }) | |
| setIsPlaying(true) | |
| } | |
| }, 100) | |
| } | |
| }} | |
| className="range range-xs w-full" | |
| style={{ | |
| width: '100%' /* Ensure full width */, | |
| height: '14px', | |
| borderRadius: '7px' /* More rounded corners - half the height */, | |
| accentColor: '#3b82f6' /* Match scale slider color */, | |
| outline: 'none', | |
| cursor: 'pointer', | |
| appearance: 'none', | |
| WebkitAppearance: 'none', | |
| padding: '0', | |
| margin: '0', | |
| background: '#e5e7eb' /* Light gray background */, | |
| }} | |
| /> | |
| {/* Playback time display */} | |
| <div className="flex justify-between text-xs font-mono"> | |
| <span>{formattedCurrentTime}</span> | |
| <span>{formattedDuration}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </ExampleDetailsSection> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| export default VideoGallery | |