diff --git a/simulator/sample_media/manifest.json b/simulator/sample_media/manifest.json index 3f79f74..2c1adaa 100644 --- a/simulator/sample_media/manifest.json +++ b/simulator/sample_media/manifest.json @@ -41,7 +41,11 @@ "4": {"file": "forest/right4.mp4", "model": "sd-turbo+farneback-flow"} }, "annotations": [ - {"key": "detected.water", "box": [0.30, 0.10, 0.18, 0.70], "min_level": 1}, + {"key": "detected.water", "min_level": 1, "track": [ + {"t": 0.0, "box": [0.30, 0.10, 0.18, 0.70]}, + {"t": 0.5, "box": [0.33, 0.10, 0.18, 0.70]}, + {"t": 1.0, "box": [0.30, 0.10, 0.18, 0.70]} + ]}, {"key": "detected.rock_face", "box": [0.05, 0.30, 0.20, 0.55], "min_level": 2}, {"key": "detected.conifer", "box": [0.70, 0.20, 0.22, 0.45], "min_level": 3}, {"key": "measure.flow_rate", "box": [0.34, 0.55, 0.14, 0.08], "min_level": 4} diff --git a/simulator/static/app.js b/simulator/static/app.js index ac01e1a..bdf6bad 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -35,6 +35,7 @@ let ring = null; // {scales:[{id,clip_id,title}], transitions:[...]} o let ringIndex = 0; // current scale position on the ring let currentClipId = null; // base media currently loaded (reset to force a reload) let busy = false; // true while a ring transition is playing +let lastOverlay = null; // {level, intensity} from the most-recent renderOverlay call; drives per-frame track animation async function loadData() { const clips = (await (await fetch("/api/clips")).json()).clips || []; @@ -182,6 +183,12 @@ function paintLoop() { gl.drawArrays(gl.TRIANGLES, 0, 3); paint.style.filter = busy ? "none" : gradeFilter; } + // Animate keyframed annotation tracks: re-place boxes against playback time. + const c = activeClip(); + if (lastOverlay && lastOverlay.level > 0 && c && + c.annotations.some((a) => a.track && a.track.length)) { + renderOverlay(lastOverlay.level, lastOverlay.intensity); + } requestAnimationFrame(paintLoop); } @@ -232,6 +239,28 @@ function chip(x, y, label, conf, kind, parent) { } } +// Interpolate an annotation's box at loop-normalized time t (0..1). Static labels +// (no track) return their fixed box. Linear between sorted keyframes; clamps to ends. +function boxAt(a, t) { + if (!a.track || !a.track.length) return a.box; + const ks = a.track; + if (t <= ks[0].t) return ks[0].box; + if (t >= ks[ks.length - 1].t) return ks[ks.length - 1].box; + for (let i = 1; i < ks.length; i++) { + if (t <= ks[i].t) { + const a0 = ks[i - 1], a1 = ks[i]; + const f = (t - a0.t) / (a1.t - a0.t); + return a0.box.map((v, j) => v + (a1.box[j] - v) * f); + } + } + return ks[ks.length - 1].box; +} + +// Loop-normalized playback time of the base video (0..1), for track interpolation. +function loopT() { + return vid && vid.duration ? (vid.currentTime % vid.duration) / vid.duration : 0; +} + function renderOverlay(level, intensity) { const clip = activeClip(); overlay.innerHTML = ""; @@ -242,7 +271,7 @@ function renderOverlay(level, intensity) { for (const a of clip.annotations) { if (a.min_level > level) continue; shown++; - const [x, y, w, h] = a.box.map((n) => n * 100); + const [x, y, w, h] = boxAt(a, loopT()).map((n) => n * 100); const measure = a.key.startsWith("measure."); const kind = measure ? "measure" : "detect"; reticle(x, y, w, h, "hud-reticle " + kind, overlay); @@ -266,6 +295,7 @@ function renderOverlay(level, intensity) { height: b.height + pad * 1.2, rx: 0.4, class: "hud-chip-bg detect" }); overlay.insertBefore(bg, st); } + lastOverlay = { level, intensity }; } // Affect channel: soft, glowing emotion-words surfaced only when BOTH knobs are