/* global React */ const { useState, useEffect, useRef, useMemo, useCallback } = React; /* ============================================================ Icons ============================================================ */ const Icon = { upload: (s = 20) => ( ), film: (s = 20) => ( ), alert: (s = 20) => ( ), heart: (s = 22) => ( ), }; /* ============================================================ Brand mark + ECG line (v2: animated beat-schedule ECG) ============================================================ */ function ECG({ width = 220, height = 24 }) { // Animated heartbeat with natural irregularity: // - per-beat jitter on cadence (RR interval), R-spike amplitude, T amplitude // - tiny baseline wander so the line breathes // - the occasional slightly-early/late beat const [phase, setPhase] = useState(0); // Beats live in a ref so they survive re-renders and accumulate as we scroll. // Each beat = { x: pixel position of its R-spike, rAmp, tAmp, period } const beatsRef = useRef(null); const W = 220, H = 24; const mid = H / 2; const speed = 22; // px/sec // Initialize a couple of beats off the right edge if (beatsRef.current === null) { const seed = []; let x = -10; while (x < W + 80) { const period = 58 + (Math.random() - 0.5) * 14; // 51..65 px between beats x += period; seed.push({ x, period, rAmp: 8 + Math.random() * 2.5, // 8..10.5 tAmp: 2 + Math.random() * 1.2, // 2..3.2 pAmp: 1 + Math.random() * 0.6, }); } beatsRef.current = seed; } useEffect(() => { let raf; let last = performance.now(); const tick = (t) => { const dt = (t - last) / 1000; last = t; // Scroll all beats left const beats = beatsRef.current; for (let i = 0; i < beats.length; i++) beats[i].x -= speed * dt; // Drop beats fully off the left while (beats.length && beats[0].x < -20) beats.shift(); // Add new beats off the right when we're running thin while (beats[beats.length - 1].x < W + 80) { const last = beats[beats.length - 1]; // Skip a beat occasionally (PVC-style pause), or run a slightly fast one const roll = Math.random(); let period; if (roll < 0.06) period = last.period * (1.25 + Math.random() * 0.15); // long pause else if (roll < 0.14) period = last.period * (0.78 + Math.random() * 0.08); // early beat else period = 58 + (Math.random() - 0.5) * 14; beats.push({ x: last.x + period, period, rAmp: 8 + Math.random() * 2.5, tAmp: 2 + Math.random() * 1.2, pAmp: 1 + Math.random() * 0.6, }); } setPhase(p => p + dt); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, []); const path = useMemo(() => { const beats = beatsRef.current || []; // Sample y(x) at every pixel by finding the nearest beat and applying its waveform let d = ""; let started = false; for (let x = 0; x <= W; x += 1) { // tiny baseline wander (slow sin) — purely cosmetic const wander = Math.sin((x * 0.05) + phase * 0.6) * 0.25; // Find the beat whose R-spike is nearest to x let nearest = null; let nd = Infinity; for (let i = 0; i < beats.length; i++) { const b = beats[i]; const d2 = x - b.x; if (Math.abs(d2) < nd) { nd = Math.abs(d2); nearest = b; } } let y = mid + wander; if (nearest) { const u = x - nearest.x; // signed offset from R-spike (px) // Waveform shape relative to R-spike at u=0: // P wave: u in [-14, -10] small bump up // PR: u in [-10, -3] flat // Q: u in [-3, -1] small dip down (positive y) // R: u in [-1, 1] tall spike up (negative y) // S: u in [1, 3] dip down // ST: u in [3, 7] flat // T wave: u in [7, 16] medium bump up if (u >= -14 && u < -10) y -= nearest.pAmp * Math.sin(((u + 14) / 4) * Math.PI); else if (u >= -3 && u < -1) y += 1.2 * ((u + 3) / 2); else if (u >= -1 && u < 1) y -= nearest.rAmp * Math.cos((u) * Math.PI / 2); else if (u >= 1 && u < 3) y += 4.2 * (1 - (u - 1) / 2); else if (u >= 7 && u < 16) y -= nearest.tAmp * Math.sin(((u - 7) / 9) * Math.PI); } d += (started ? " L" : "M") + x + "," + y.toFixed(2); started = true; } return d; }, [phase]); return ( ); } function BrandMark({ size = 28 }) { return ( ); } /* ============================================================ Header ============================================================ */ function Header() { return (
Pulse Lab
Heart rate from face video, in near real time.
); } /* ============================================================ v2 — Sample-first hero player ============================================================ */ // Bundled sample face video (VitalLens, MIT license — see CREDITS.md). // Same file the backend analyzes when "Analyze sample →" is clicked, so the // preview the user plays is exactly what gets processed. Same-origin → no // CORS / hotlink issues, and ~1 MB so it loads instantly. const SAMPLE_VIDEO_URL = "/static/samples/sample-face-60s.mp4"; function SampleHero({ onAnalyze, onPickFile, onRecord }) { const videoRef = useRef(null); const [playing, setPlaying] = useState(false); const [time, setTime] = useState(0); const [duration, setDuration] = useState(60); const inputRef = useRef(null); const fmt = (s) => { if (!isFinite(s)) s = 0; const m = Math.floor(s / 60); const ss = Math.floor(s % 60); return `${String(m).padStart(2,"0")}:${String(ss).padStart(2,"0")}`; }; const togglePlay = () => { const v = videoRef.current; if (!v) return; if (v.paused) v.play(); else v.pause(); }; return (
Sample · 60s face video

Bundled sample · clear face, even lighting

Press play to preview, then run the live rPPG analysis on it.

or use your own video
{ const f = e.target.files?.[0]; if (f) onPickFile(f); }} />
1 60-second video 2 twelve 5-second chunks 3 per-chunk BPM, streamed 4 final estimate
); } /* ============================================================ v2 — Video preview modal (UI shell; real playback is a future TODO) ============================================================ */ function VideoPreviewModal({ file, onClose }) { return (
e.stopPropagation()}>

{file?.name || "preview"}

preview · 60s · 16:9
00:00
01:00
); } /* ============================================================ v2 — Segmented mode toggle inside upload card ============================================================ */ function ModeToggle({ value, onChange }) { return (
); } /* ============================================================ v2 — Webcam capture (real getUserMedia + MediaRecorder) ============================================================ */ function WebcamCapture({ onComplete }) { const videoRef = useRef(null); const streamRef = useRef(null); const recorderRef = useRef(null); const chunksRef = useRef([]); const tickRef = useRef(null); // camState: "pending" | "denied" | "ready" const [camState, setCamState] = useState("pending"); const [camErrorMsg, setCamErrorMsg] = useState(""); const [recording, setRecording] = useState(false); const [elapsed, setElapsed] = useState(0); // 0..60s // Chips are cosmetic for now — hardcoded to "good" const chips = { lighting: "good", face: "centered", motion: "low" }; const requestCamera = useCallback(() => { setCamState("pending"); setCamErrorMsg(""); navigator.mediaDevices .getUserMedia({ video: { width: 1280, height: 720, facingMode: "user" } }) .then((stream) => { streamRef.current = stream; if (videoRef.current) { videoRef.current.srcObject = stream; } setCamState("ready"); }) .catch((err) => { const isDenied = err.name === "NotAllowedError" || err.name === "PermissionDeniedError"; setCamErrorMsg( isDenied ? "Camera access was denied. Allow camera access in your browser and try again." : `Camera unavailable: ${err.message}` ); setCamState("denied"); }); }, []); // Start camera on mount useEffect(() => { requestCamera(); return () => { if (tickRef.current) clearInterval(tickRef.current); if (recorderRef.current && recorderRef.current.state !== "inactive") { recorderRef.current.stop(); } if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); } }; }, []); const startRecording = useCallback(() => { if (!streamRef.current) return; const mimeType = MediaRecorder.isTypeSupported("video/webm;codecs=vp9") ? "video/webm;codecs=vp9" : "video/webm"; chunksRef.current = []; const recorder = new MediaRecorder(streamRef.current, { mimeType }); recorderRef.current = recorder; recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunksRef.current.push(e.data); }; recorder.onstop = () => { const blob = new Blob(chunksRef.current, { type: mimeType }); onComplete?.(blob); }; recorder.start(200); // collect data every 200ms setElapsed(0); setRecording(true); const startTime = performance.now(); tickRef.current = setInterval(() => { const e = (performance.now() - startTime) / 1000; const clamped = Math.min(60, e); setElapsed(clamped); if (e >= 60) { clearInterval(tickRef.current); tickRef.current = null; if (recorderRef.current && recorderRef.current.state !== "inactive") { recorderRef.current.stop(); } setRecording(false); } }, 100); }, [onComplete]); // No manual stop — recording auto-stops at 60s for guaranteed duration. // The button is disabled until 60s elapses (or auto-completes). const ringR = 36; const ringC = 2 * Math.PI * ringR; const dash = ringC * (elapsed / 60); const fmt = (s) => { const m = Math.floor(s / 60); const ss = Math.floor(s % 60); return `${String(m).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; }; // --- Permission pending state --- if (camState === "pending") { return (

Allow camera access in your browser to start recording.

waiting for permission…
); } // --- Permission denied state --- if (camState === "denied") { return (

{camErrorMsg}

camera unavailable
); } // --- Camera ready state --- return (
{/* Live video preview */}
lighting {chips.lighting} face {chips.face} motion {chips.motion} {recording ? "recording… auto-stops at 60s" : "tap to record · 60s"}
); } /* ============================================================ Upload Zone (v2: sample-first landing + video preview modal) ============================================================ */ function UploadZone({ file, onPickFile, onPickSample, onStart, mode, setMode, onRecordComplete }) { const [drag, setDrag] = useState(false); const inputRef = useRef(null); const previewUrlRef = useRef(null); const onDrop = (e) => { e.preventDefault(); setDrag(false); const f = e.dataTransfer.files?.[0]; if (f) onPickFile(f); }; // Default landing: sample-first hero. Only show legacy dropzone / webcam // when user explicitly switches modes. if (mode === "sample" && !file) { return (
{ onPickSample(); }} onPickFile={(f) => { onPickFile(f); setMode("upload"); }} onRecord={() => setMode("record")} />
); } return (
{mode === "record" ? ( <>
1 60-second capture 2 twelve 5-second chunks 3 per-chunk BPM, streamed 4 final estimate
) : ( <>
{ e.preventDefault(); setDrag(true); }} onDragOver={(e) => { e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={onDrop} onClick={() => { if (!file) inputRef.current?.click(); }} > { const f = e.target.files?.[0]; if (f) onPickFile(f); }} /> {file ? ( (() => { // Revoke previous blob URL to avoid leaks, then create a fresh one if (previewUrlRef.current) { URL.revokeObjectURL(previewUrlRef.current); } previewUrlRef.current = URL.createObjectURL(file); return ( <>
60-second face video .mp4 or .mov up to 100 MB
1 60-second video 2 twelve 5-second chunks 3 per-chunk BPM, streamed 4 final estimate
)}
); } /* ============================================================ Processing View ============================================================ */ function ChunkTick({ chunk, isActive }) { if (!chunk) return null; const { idx, bpm, status, quality } = chunk; const cls = status === "done" ? "" : status === "failed" ? "is-failed" : status === "active" ? "is-active" : "is-pending"; const fillPct = bpm ? Math.max(20, Math.min(96, ((bpm - 50) / 60) * 100)) : 0; return (
{status === "done" && (
)} {isActive && status !== "done" && status !== "failed" && (
)}
{status === "done" && bpm} {status === "failed" && "—"}
{String(idx + 1).padStart(2, "0")}
{status === "done" && <>chunk {idx+1} · {bpm} bpm · q {quality?.toFixed(2)}} {status === "failed" && <>chunk {idx+1} · {chunk.failReason || "signal too noisy"}} {status === "active" && <>chunk {idx+1} · processing…} {status === "pending"&& <>chunk {idx+1} · waiting}
); } /* ============================================================ v2 — ROI overlay on processing thumbnail ============================================================ */ function ProcThumbRoi({ lost }) { const [pos, setPos] = useState({ left: 28, top: 14, w: 36, h: 38 }); useEffect(() => { const id = setInterval(() => { setPos({ left: 26 + Math.random() * 4, top: 12 + Math.random() * 4, w: 34 + Math.random() * 4, h: 36 + Math.random() * 4, }); }, 900); return () => clearInterval(id); }, []); return (
); } /* ============================================================ v2 — Live BVP waveform (beat-schedule algorithm) props: bpm — heart rate, drives beat frequency frozen — if true, render a static snapshot instead of animating height — SVG height in px (default 72) samples — real BVP array from the final SSE event (used when frozen=true) ============================================================ */ function LiveWaveform({ bpm = 76, frozen = false, height = 72, samples = [] }) { const W = 800, H = height; const [phase, setPhase] = useState(0); // px-per-second scroll speed: ~10s of waveform shown across W const speed = W / 10; // Mean px-per-beat at the current bpm (10s = W px, beats in 10s = bpm/6) const meanPeriodPx = W / Math.max(1, (bpm / 60) * 10); const beatsRef = useRef(null); if (beatsRef.current === null) { const seed = []; let x = -meanPeriodPx; while (x < W + meanPeriodPx * 2) { x += meanPeriodPx * (0.88 + Math.random() * 0.24); // ±12% seed.push({ x, sysAmp: 0.85 + Math.random() * 0.3, // 0.85..1.15 dicAmp: 0.35 + Math.random() * 0.2, // 0.35..0.55 baseline: (Math.random() - 0.5) * 0.06, // tiny wander }); } beatsRef.current = seed; } useEffect(() => { if (frozen) return; let raf; let last = performance.now(); const tick = (t) => { const dt = (t - last) / 1000; last = t; const beats = beatsRef.current; const dx = speed * dt; for (let i = 0; i < beats.length; i++) beats[i].x -= dx; while (beats.length && beats[0].x < -meanPeriodPx) beats.shift(); while (beats[beats.length - 1].x < W + meanPeriodPx * 2) { const last = beats[beats.length - 1]; // Most beats: ±12% jitter. Occasionally an early beat or longer pause. const roll = Math.random(); let factor; if (roll < 0.05) factor = 1.25 + Math.random() * 0.18; // long pause else if (roll < 0.12) factor = 0.78 + Math.random() * 0.08; // premature else factor = 0.88 + Math.random() * 0.24; beats.push({ x: last.x + meanPeriodPx * factor, sysAmp: 0.85 + Math.random() * 0.3, dicAmp: 0.35 + Math.random() * 0.2, baseline: (Math.random() - 0.5) * 0.06, }); } setPhase(p => p + dt); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [frozen, speed, meanPeriodPx]); // Frozen path from real BVP samples when available const frozenPath = useMemo(() => { if (!frozen || samples.length < 2) return null; return samples.map((v, i) => { const x = (i / (samples.length - 1)) * W; const y = H / 2 - v * (H / 2 - 4); return `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`; }).join(" "); }, [frozen, samples, H]); // Beat-schedule synthetic path (live mode, and frozen fallback when no samples) const syntheticPath = useMemo(() => { const beats = beatsRef.current || []; const peak = (b, c, w) => Math.exp(-((b - c) ** 2) / (2 * w * w)); let d = ""; for (let x = 0; x <= W; x += 2) { // tiny baseline wander — slow sin const wander = Math.sin((x * 0.012) + phase * 0.7) * 0.04; // Sum contributions of nearby beats (BVP shape spans ~one period each side) let val = wander; for (let i = 0; i < beats.length; i++) { const b = beats[i]; const u = (x - b.x) / meanPeriodPx; // normalized offset if (u < -0.4 || u > 1.0) continue; // skip distant beats for perf // Two-peak BVP-ish: systolic (u≈0.18) + dicrotic (u≈0.45) val -= b.sysAmp * peak(u, 0.18, 0.07); val -= b.dicAmp * peak(u, 0.50, 0.09); val += b.baseline * peak(u, 0.3, 0.4); } const py = H / 2 + (val + 0.45) * (H * 0.34); d += (x === 0 ? "M" : " L") + x + "," + py.toFixed(1); } return d; }, [phase, meanPeriodPx, H]); // Prefer real samples in frozen mode; fall back to synthetic const pathToRender = frozen && frozenPath ? frozenPath : syntheticPath; return ( ); } function ProcessingView({ file, chunks, runningAvg, currentIdx, total, onCancel, pulsePeriod, bpm, roiLost, showWaveform }) { return (
REC
Source
{file?.name || "sample.mp4"}
Running average
{runningAvg ?? "—"} bpm
{currentIdx < total ? <>Currently processing chunk {currentIdx + 1} of {total} : <>Finalizing…}
{showWaveform && ( <>
fps · 30 · roi · forehead · signal · {roiLost ? "lost" : "steady"}
)}
00:00 00:15 00:30 00:45 01:00
{chunks.map((c, i) => ( ))}
); } /* ============================================================ Results — Chart ============================================================ */ function PerChunkChart({ chunks }) { const W = 480, H = 220; const padL = 36, padR = 12, padT = 16, padB = 28; const innerW = W - padL - padR; const innerH = H - padT - padB; const valid = chunks.filter(c => c.status === "done"); const ys = valid.map(c => c.bpm); const minY = Math.floor(Math.min(...ys, 60) / 5) * 5; const maxY = Math.ceil (Math.max(...ys, 90) / 5) * 5; const yRange = maxY - minY; const xFor = (i) => padL + (i / 11) * innerW; const yFor = (b) => padT + (1 - (b - minY) / yRange) * innerH; const [hover, setHover] = useState(null); const linePath = useMemo(() => { let d = ""; let started = false; chunks.forEach((c, i) => { if (c.status !== "done") { started = false; return; } const x = xFor(i), y = yFor(c.bpm); d += (started ? " L" : "M") + x.toFixed(1) + "," + y.toFixed(1); started = true; }); return d; }, [chunks, minY, maxY]); return (
{maxY} {Math.round((maxY+minY)/2)} {minY}
{[0, 0.5, 1].map((t, i) => ( ))} {[0, 3, 6, 9, 11].map((i) => ( ))} {linePath && ( )} {linePath && ( )} {chunks.map((c, i) => { if (c.status === "failed") { return ( setHover({ i, c, x: xFor(i), y: padT + innerH/2 })} onMouseLeave={() => setHover(null)}> ); } if (c.status !== "done") return null; return ( setHover({ i, c, x: xFor(i), y: yFor(c.bpm) })} onMouseLeave={() => setHover(null)} style={{ cursor: "pointer" }}> ); })} {hover && ( )}
123456 789101112
{hover && (
chunk{hover.i + 1}
{hover.c.status === "done" ? ( <>
bpm{hover.c.bpm}
quality{hover.c.quality.toFixed(2)}
) : (
status{hover.c.failReason || "no signal"}
)}
)}
); } /* ============================================================ Results View ============================================================ */ function ResultsView({ chunks, perf, overallBpm, ci, biomarkers, pulsePeriod, file, onReset, bvp }) { const ciWarn = ci != null && ci > 5; return (
Overall BPM
{overallBpm} bpm
{ci != null && ( {ciWarn && } ± {ci} bpm )}
95% confidence interval across 12 chunks, weighted by signal quality. {ciWarn && <> — consider re-recording in better conditions.}
Per-chunk BPM
{/* Source snapshot with frozen mini waveform */}
SRC
Source
{file?.name || "sample.mp4"}
Performance
Total processing time
{perf.total}s
Avg per-chunk latency
{perf.avg}s
Frames processed
{perf.frames.toLocaleString()}
{biomarkers && ( <> Biomarkers
Respiratory rate
{(biomarkers.rr != null && biomarkers.rr !== 0) ? biomarkers.rr : "—"} {(biomarkers.rr != null && biomarkers.rr !== 0) && br/min}
HRV — RMSSD
{biomarkers.rmssd != null ? biomarkers.rmssd : "—"} {biomarkers.rmssd != null && ms}
beat-to-beat variability
HRV — SDNN
{biomarkers.sdnn != null ? biomarkers.sdnn : "—"} {biomarkers.sdnn != null && ms}
long-window variability
LF / HF ratio
{biomarkers.lfhf != null ? biomarkers.lfhf : "—"}
autonomic balance
)}
); } /* ============================================================ Whole-video failure ============================================================ */ function WholeFailure({ onRetry, onSample, message }) { const defaultMsg = "The rPPG model needs a clearly-lit, mostly-still face for the full 60 seconds. Try a video with steadier framing, or use our sample to see how it works."; return (
{Icon.alert(20)}

{message ? "Analysis failed" : "We couldn't find a face in this video"}

{message || defaultMsg}

); } /* ============================================================ Export everything to window ============================================================ */ Object.assign(window, { Icon, ECG, BrandMark, Header, SampleHero, VideoPreviewModal, ModeToggle, WebcamCapture, UploadZone, ProcessingView, ResultsView, WholeFailure, LiveWaveform, ProcThumbRoi, });