| import { useState, useRef } from 'react'; |
| import { motion, AnimatePresence } from 'framer-motion'; |
| import { Eye, EyeOff, ZoomIn, ZoomOut, RotateCcw, Layers, Info } from 'lucide-react'; |
| import styles from './MediaPanel.module.css'; |
|
|
| function HeatmapLayer({ regions, opacity }) { |
| return ( |
| <div |
| className={styles.heatmapOverlay} |
| style={{ |
| opacity: 0.24 + opacity * 0.76, |
| '--heatmap-strength': opacity, |
| }} |
| > |
| {regions.map((region, i) => { |
| const isHigh = region.intensity > 0.8; |
| const isMedium = region.intensity > 0.5; |
| const color = isHigh |
| ? 'var(--ai-generated)' |
| : isMedium |
| ? 'var(--suspect)' |
| : 'var(--authentic)'; |
| |
| const baseAlpha = 0.22 + region.intensity * 0.58; |
| const scaledAlpha = Math.min(0.98, baseAlpha * (0.55 + opacity * 1.2)); |
| const blur = Math.max(10, 28 - opacity * 14 - region.intensity * 8); |
| const scale = 1 + region.intensity * 0.2 + opacity * 0.08; |
| |
| return ( |
| <motion.div |
| key={i} |
| className={styles.heatmapRegion} |
| style={{ |
| left: `${region.x}%`, |
| top: `${region.y}%`, |
| width: `${region.w}%`, |
| height: `${region.h}%`, |
| '--region-color': color, |
| '--region-alpha': scaledAlpha, |
| '--region-blur': `${blur}px`, |
| '--region-scale': scale, |
| '--region-ring': `${8 + opacity * 20}px`, |
| '--region-border-alpha': Math.min(1, 0.35 + opacity * 0.55), |
| '--region-glow-alpha': Math.min(1, 0.3 + region.intensity * 0.7), |
| }} |
| initial={{ opacity: 0, scale: 0.82 }} |
| animate={{ opacity: scaledAlpha, scale: scale }} |
| transition={{ duration: 0.4, delay: i * 0.08 }} |
| title={region.label} |
| /> |
| ); |
| })} |
| </div> |
| ); |
| } |
|
|
| export default function MediaPanel({ result, previewUrl }) { |
| const [heatmapOn, setHeatmapOn] = useState(false); |
| const [heatmapOpacity, setHeatmapOpacity] = useState(0.78); |
| const [zoom, setZoom] = useState(1); |
| const imgRef = useRef(null); |
|
|
| const isVideo = result.type === 'video'; |
| const regions = result.gradcam?.regions || []; |
|
|
| const handleZoomIn = () => setZoom((value) => Math.min(value + 0.25, 2.5)); |
| const handleZoomOut = () => setZoom((value) => Math.max(value - 0.25, 0.5)); |
| const handleReset = () => { setZoom(1); }; |
|
|
| return ( |
| <div className={styles.panel}> |
| <div className={styles.toolbar}> |
| <div className={styles.toolbarLeft}> |
| <button |
| className={`${styles.toolBtn} ${heatmapOn ? styles.toolBtnActive : ''}`} |
| onClick={() => setHeatmapOn(!heatmapOn)} |
| title={heatmapOn ? 'Hide Grad-CAM heatmap' : 'Show Grad-CAM heatmap'} |
| > |
| {heatmapOn ? <Eye size={14} /> : <EyeOff size={14} />} |
| <span>Grad-CAM</span> |
| </button> |
| <div className={styles.toolbarDivider} /> |
| <button className={styles.iconBtn} onClick={handleZoomIn} title="Zoom in" disabled={zoom >= 2.5}> |
| <ZoomIn size={14} /> |
| </button> |
| <span className={`${styles.zoomLabel} font-mono`}>{Math.round(zoom * 100)}%</span> |
| <button className={styles.iconBtn} onClick={handleZoomOut} title="Zoom out" disabled={zoom <= 0.5}> |
| <ZoomOut size={14} /> |
| </button> |
| <button className={styles.iconBtn} onClick={handleReset} title="Reset zoom"> |
| <RotateCcw size={14} /> |
| </button> |
| </div> |
| |
| <AnimatePresence> |
| {heatmapOn && ( |
| <motion.div |
| className={styles.opacityControl} |
| initial={{ opacity: 0, x: 8 }} |
| animate={{ opacity: 1, x: 0 }} |
| exit={{ opacity: 0, x: 8 }} |
| > |
| <Layers size={12} /> |
| <span>Intensity</span> |
| <input |
| type="range" |
| min={0} |
| max={1} |
| step={0.01} |
| value={heatmapOpacity} |
| onChange={(event) => setHeatmapOpacity(parseFloat(event.target.value))} |
| className={styles.opacitySlider} |
| /> |
| <span className="font-mono">{Math.round(heatmapOpacity * 100)}%</span> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
| |
| <div className={styles.mediaFrame}> |
| <div |
| className={`${styles.mediaContainer} ${heatmapOn ? styles.mediaContainerHeatmapOn : ''}`} |
| style={{ |
| transform: `scale(${zoom})`, |
| transformOrigin: 'center center', |
| '--media-heatmap-strength': heatmapOpacity, |
| }} |
| > |
| {isVideo ? ( |
| <video |
| src={previewUrl} |
| controls |
| muted |
| className={styles.mediaEl} |
| ref={imgRef} |
| /> |
| ) : ( |
| <img |
| src={previewUrl} |
| alt="Analysed media" |
| className={styles.mediaEl} |
| ref={imgRef} |
| /> |
| )} |
| |
| <AnimatePresence> |
| {heatmapOn && ( |
| <motion.div |
| className={styles.heatmapWrap} |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| transition={{ duration: 0.25 }} |
| > |
| <HeatmapLayer regions={regions} opacity={heatmapOpacity} /> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
| |
| <AnimatePresence> |
| {heatmapOn && ( |
| <motion.div |
| className={styles.heatmapLegend} |
| initial={{ opacity: 0, y: 8 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0 }} |
| > |
| <span className={styles.legendTitle}>Artifact Intensity</span> |
| <div className={styles.legendItems}> |
| <div className={styles.legendItem}><span className={`${styles.legendDot} ${styles.dotHigh}`} />High (>80%)</div> |
| <div className={styles.legendItem}><span className={`${styles.legendDot} ${styles.dotMed}`} />Medium (50–80%)</div> |
| <div className={styles.legendItem}><span className={`${styles.legendDot} ${styles.dotLow}`} />Low (<50%)</div> |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
| |
| <AnimatePresence> |
| {heatmapOn && regions.length > 0 && ( |
| <motion.div |
| className={styles.regionAnnotations} |
| initial={{ opacity: 0, y: 8 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0 }} |
| > |
| <div className={styles.regionHeader}> |
| <Info size={13} /> |
| <span>Localised Artifact Regions</span> |
| </div> |
| <div className={styles.regionGrid}> |
| {regions.map((region, index) => ( |
| <div key={index} className={styles.regionItem}> |
| <span |
| className={styles.regionDot} |
| style={{ |
| background: region.intensity > 0.8 |
| ? 'var(--ai-generated)' |
| : region.intensity > 0.5 |
| ? 'var(--suspect)' |
| : 'var(--authentic)', |
| }} |
| /> |
| <span className={styles.regionLabel}>{region.label}</span> |
| <span className={`${styles.regionScore} font-mono`}>{Math.round(region.intensity * 100)}%</span> |
| </div> |
| ))} |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| |
| <div className={styles.fileInfo}> |
| <div className={styles.fileInfoItem}> |
| <span>Format</span> |
| <span className="font-mono">{result.format}</span> |
| </div> |
| <div className={styles.fileInfoItem}> |
| <span>Resolution</span> |
| <span className="font-mono">{result.resolution}</span> |
| </div> |
| <div className={styles.fileInfoItem}> |
| <span>Size</span> |
| <span className="font-mono">{result.filesize}</span> |
| </div> |
| {result.type === 'video' && ( |
| <> |
| <div className={styles.fileInfoItem}> |
| <span>Duration</span> |
| <span className="font-mono">{result.duration}</span> |
| </div> |
| <div className={styles.fileInfoItem}> |
| <span>Frames</span> |
| <span className="font-mono">{result.totalFrames}</span> |
| </div> |
| </> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|