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 = ({ 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('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> = {} variantKeys.forEach((v) => { refs[v] = React.createRef() }) 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) => { 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(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 (
No video examples available. Please select another model and attack.
) } // 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 (
Video
{selectedVideo && variants[originalVariant] && variants[comparisonVariant] && ( <> [v, variants[v]?.metadata || {}]) )} />
setVideoScale(Number(e.target.value))} className="range range-xs" style={{ verticalAlign: 'middle', width: '120px', // Make slider smaller accentColor: '#3b82f6', // Match playback slider color }} /> {(videoScale * 100).toFixed(0)}%
{/* Labels for comparison */}
Left: {VARIANT_NAME_MAP[originalVariant]} Right: {VARIANT_NAME_MAP[comparisonVariant]}
{/* Video comparison container */}
{/* Reference video for dimensions (invisible) */}
{/* Playback position slider */}
{ // 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 */}
{formattedCurrentTime} {formattedDuration}
)}
) } export default VideoGallery