Spaces:
Running
Running
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 | |