Mark Duppenthaler
Add video slider
9bf569f
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