import { useState, useRef, useMemo, useEffect } from "react";
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
ReferenceLine,
ReferenceArea,
Tooltip,
} from "recharts";
const API_URL = import.meta.env.VITE_API_URL || "/api/predict";
/* Extract contiguous anomaly regions [start, end] from the frame list.
These become the glowing alarm bands behind the trace. */
function anomalyBands(frames) {
const bands = [];
let start = null;
for (const f of frames) {
if (f.is_anomaly) {
if (start === null) start = f.frame_idx;
} else if (start !== null) {
bands.push([start, f.frame_idx - 1]);
start = null;
}
}
if (start !== null) bands.push([start, frames[frames.length - 1].frame_idx]);
return bands;
}
/* Tooltip shows the threshold-relative value (intuitive) AND the raw MSE
score (technical transparency). */
function makeTip(threshold) {
return function TipBox({ active, payload }) {
if (!active || !payload || !payload.length) return null;
const p = payload[0].payload;
if (p.rawScore === null || p.rawScore === undefined) return null;
const rel = p.rawScore / threshold;
return (
FRAME{p.frame}
SURPRISE
{rel.toFixed(2)}×
RAW
{p.rawScore.toExponential(2)}
);
};
}
export default function App() {
const [file, setFile] = useState(null);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [drag, setDrag] = useState(false);
const [currentFrame, setCurrentFrame] = useState(0);
const inputRef = useRef(null);
const videoRef = useRef(null);
// Playable URL for the uploaded file (revoke on change to avoid leaks)
const videoUrl = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]);
useEffect(() => {
return () => { if (videoUrl) URL.revokeObjectURL(videoUrl); };
}, [videoUrl]);
const status = loading ? "reading" : result ? "flagged" : "standby";
const statusLabel = loading ? "READING FEED" : result ? "ANALYSIS COMPLETE" : "FEED STANDBY";
function pick(f) {
if (!f) return;
setFile(f);
setResult(null);
setError(null);
setCurrentFrame(0);
}
async function analyze() {
if (!file) return;
setLoading(true);
setError(null);
setResult(null);
setCurrentFrame(0);
try {
const body = new FormData();
body.append("file", file);
const res = await fetch(API_URL, { method: "POST", body });
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.detail || `Server returned ${res.status}`);
}
setResult(await res.json());
} catch (e) {
setError(
e.message?.includes("fetch")
? "Cannot reach the detector. Start the backend, then run again."
: e.message
);
} finally {
setLoading(false);
}
}
const fps = result?.fps || 10;
const threshold = result?.threshold || 1;
// Trace data: store BOTH the threshold-relative value (for the axis) and the
// raw score (for the tooltip). Tripwire sits at 1.0x.
const chartData = useMemo(
() =>
result?.frames.map((f) => ({
frame: f.frame_idx,
rel: f.score === null ? null : f.score / threshold,
rawScore: f.score,
is_anomaly: f.is_anomaly,
})) ?? [],
[result, threshold]
);
// Progressive reveal: hide values beyond the playhead so the trace draws in sync
const revealedData = useMemo(
() => chartData.map((d) => ({ ...d, rel: d.frame <= currentFrame ? d.rel : null })),
[chartData, currentFrame]
);
const bands = useMemo(() => (result ? anomalyBands(result.frames) : []), [result]);
const peakRel = useMemo(() => {
const s = result?.frames.map((f) => f.score).filter((v) => v !== null) ?? [];
return s.length ? Math.max(...s) / threshold : 0;
}, [result, threshold]);
const liveFrame = result?.frames[currentFrame];
const liveRel = liveFrame && liveFrame.score !== null ? liveFrame.score / threshold : null;
const TipBox = useMemo(() => makeTip(threshold), [threshold]);
return (
inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={(e) => { e.preventDefault(); setDrag(false); pick(e.dataTransfer.files?.[0]); }}
>
VIDEO FEED INPUT
Drop a video, or click to select
The detector learns normal motion, then flags what it cannot predict.
{file &&
{file.name}
}
pick(e.target.files?.[0])}
/>
{error && {error}
}
{result && (
FRAMES
{result.total_frames}
FLAGGED
{result.frames.filter((f) => f.is_anomaly).length}
PEAK SURPRISE
{peakRel.toFixed(2)}×
)}
{/* Synced playback: video + live readout */}
{result && videoUrl && (
)}
THE SURPRISE TRACE
signal
tripwire
anomaly
Surprise shown relative to the detection threshold — 1.0× is the alarm line.
{result ? (
{/* glowing alarm bands behind the trace (full range, not revealed) */}
{bands.map(([a, b], i) => (
))}
`${v.toFixed(1)}×`} width={48} tickLine={false} />
{/* the amber tripwire — now fixed at 1.0x */}
{/* the playhead — follows the video */}
} cursor={{ stroke: "#66768A", strokeDasharray: "3 3" }} />
{/* connectNulls=false leaves a gap over warm-up AND beyond the playhead */}
) : (
{loading ? "READING FEED…" : "AWAITING FEED"}
SURPRISE / FRAME
)}
{/* Most anomalous moments — heatmap overlays */}
{result && result.top_anomalies?.length > 0 && (
MOST ANOMALOUS MOMENTS
Where the model was most surprised — heatmap over frame
{result.top_anomalies.map((a) => (
FRAME {a.frame_idx}
{(a.score / threshold).toFixed(2)}×
))}
)}
);
}