/* 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 (
);
}
/* ============================================================
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"}