Measure the video, not a PNG per frame

Detection now walks a browser-seekable H.264 proxy in MediaPipe's VIDEO
running mode. The PNG sequence it replaces was 112MB for 7.6 seconds at
1440x1920 and 1.1GB at the 900-frame limit; the proxy is 6MB, and landmarks
detected off decoded H.264 rather than off the PNGs moved at most 0.0033 of
frame width.

Three things had to be true for video mode to work, and each was measured
against the same footage decoded to PNGs:

/blob/<digest> answers byte ranges. Django's FileResponse does no Range
handling, and a media element handed 200 with no Accept-Ranges reports an
empty `seekable`, no-ops every currentTime write, and detects frame one
ninety times without raising.

A seek aims at the MIDDLE of its frame. Aiming at i/fps sits on a frame
boundary and landed one frame early 31 times in 91; (i + 0.5)/fps was exact
on all 91.

Timestamps are strictly increasing footage milliseconds. Video mode is a
tracker: a repeat leaves the graph in an error state every later call
re-throws, so the landmarker is discarded on failure, and passing the frame
index instead of i*1000/fps moved landmarks six times further from the
per-frame answer.

Frames are verified rather than trusted. requestVideoFrameCallback states
which frame it handed over, the walker discards any other and fails loudly
if the one it asked for never arrives — a stale presentation from the tail
of a previous seek is what produced "asked for frame 1 and it presented
frame 2" on a video whose seeks were in fact exact.

The proxy is re-encoded even when the upload is already H.264: HEVC is not
decodable everywhere, and footage identity is the proxy's digest. The JPEG
stills beside it are tracing references, outside the footage digest because
re-rendering them at another size is not different footage.

Verified end to end in a real browser against real footage: 228/228 frames
detected, a drawn roto face, 37 backend and 234 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-28 11:32:01 -04:00
parent 686f897401
commit 83d106bbc5
14 changed files with 748 additions and 142 deletions

View file

@ -16,47 +16,56 @@
[arthur.fx.http :as http]
[re-frame.core :as rf]))
(defn- detect-frames! [manifest model]
(let [canvas (.createElement js/document "canvas")
ctx (.getContext canvas "2d")
raw (atom [])
crops (atom [])
dims (atom nil)
total (:frames manifest)]
(js/Promise.
(fn [resolve reject]
(letfn [(next-frame [i]
(if (= i total)
(try
(resolve (assoc (detect/fill-gaps @raw)
:dimensions @dims :crops @crops))
(catch :default error (reject error)))
(-> (ingest/image! (ingest/frame-url manifest i))
(.then
(fn [image]
(let [wh [(.-naturalWidth image) (.-naturalHeight image)]]
(when (and @dims (not= @dims wh))
(throw (ex-info (str "frame " (inc i) " has different dimensions")
{:expected @dims :actual wh})))
(reset! dims wh)
(set! (.-width canvas) (first wh))
(set! (.-height canvas) (second wh))
(.drawImage ctx image 0 0)
(let [face (detect/detect! model canvas)
ring (when face (mapv #(nth face %) lm/LIPS-INNER))
box (when ring (interior/crop ring wh))
pixels (when box
(.-data (.getImageData ctx (:x box) (:y box)
(:w box) (:h box))))]
(swap! raw conj face)
(swap! crops conj (when box {:box box :data pixels})))
(when (or (zero? i) (zero? (mod (inc i) 4)) (= (inc i) total))
(rf/dispatch [::progress (str "detecting " (inc i) "/" total)]))
;; Let the status and the transport paint between sync
;; MediaPipe calls, and release each decoded PNG.
(js/setTimeout #(next-frame (inc i)) 0))))
(.catch reject))))]
(next-frame 0))))))
(defn- detect-frames!
"Walk the proxy once, forward, and measure every frame as it goes.
ONCE AND FORWARD IS A REQUIREMENT, NOT A STYLE. MediaPipe's video mode is a
tracker whose input stream refuses a timestamp that does not advance, and the
error it raises is terminal for the landmarker — so there is no re-reading a
frame, no retry of frame 40, and no second pass. Each frame is decoded, handed
to the detector at its own time in the take, and its mouth crop read off the
same canvas before the loop moves on."
[manifest model]
(let [[w h] [(:width manifest) (:height manifest)]
canvas (.createElement js/document "canvas")
ctx (.getContext canvas "2d" #js {:willReadFrequently true})
fps (:fps manifest)
raw (atom [])
crops (atom [])
total (:frames manifest)]
(set! (.-width canvas) w)
(set! (.-height canvas) h)
(-> (ingest/video! (ingest/video-url manifest) w h)
(.then
(fn [video]
(js/Promise.
(fn [resolve reject]
(letfn [(next-frame [i]
(if (= i total)
(try
(resolve (assoc (detect/fill-gaps @raw)
:dimensions [w h] :crops @crops))
(catch :default error (reject error)))
(-> (ingest/frame! video fps i)
(.then
(fn [_]
(.drawImage ctx video 0 0)
(let [face (detect/detect! model canvas
(ingest/frame-ms fps i))
ring (when face (mapv #(nth face %) lm/LIPS-INNER))
box (when ring (interior/crop ring [w h]))
pixels (when box
(.-data (.getImageData ctx (:x box) (:y box)
(:w box) (:h box))))]
(swap! raw conj face)
(swap! crops conj (when box {:box box :data pixels})))
(when (or (zero? i) (zero? (mod (inc i) 4)) (= (inc i) total))
(rf/dispatch [::progress (str "detecting " (inc i) "/" total)]))
;; Let the status paint between synchronous
;; MediaPipe calls.
(js/setTimeout #(next-frame (inc i)) 0)))
(.catch reject))))]
(next-frame 0)))))))))
(defn- build-clip [manifest detector
{:keys [dense detected dimensions crops missing first-real] :as source-inputs}]
@ -116,7 +125,7 @@
(do (rf/dispatch [::progress "loading MediaPipe…"])
(-> (detect/landmarker!)
(.then (fn [model]
(rf/dispatch [::progress "loading frames…"])
(rf/dispatch [::progress "opening the video…"])
(-> (detect-frames! manifest model)
(.then (fn [fresh]
(build-clip manifest detector fresh))))))))))))))
@ -125,6 +134,10 @@
(rf/dispatch [::loaded id (:summary entry)]))))
(.catch (fn [error]
(js/console.error error)
;; A run that ended badly may have ended on a MediaPipe graph
;; error, and a landmarker that has hit one throws the same error
;; for the rest of the page's life. Retrying has to get a new one.
(detect/discard!)
(rf/dispatch [::failed (or (ex-message error) (.-message error) (str error))]))))))
(rf/reg-fx

View file

@ -60,8 +60,15 @@
`:source` is in it and `:name` is NOT. A clip's name is a label a human types;
two clips of the same footage under different names are the same analysis and
must share it, which is the whole return on addressing."
[{:keys [detector version source footage frames fps aspect seed]}]
must share it, which is the whole return on addressing.
`:mode` is how the detector was RUN, and it belongs here for the same reason the
version does. MediaPipe's video mode is a tracker and its image mode is not:
over the same frames and the same model they disagree by up to 0.013 of frame
width, which is a visible difference on a mouth. Optional, because the synthetic
take has no running mode to declare and an absent field is how the other
optional inputs already say \"not applicable\"."
[{:keys [detector version source footage frames fps aspect seed mode]}]
(when-not (and (string? detector) (seq detector) (string? version) (seq version))
(throw (ex-info "an analysis names its detector and the detector's VERSION: an upgrade that silently reuses old landmarks is the failure content addressing exists to prevent"
{:detector detector :version version})))
@ -73,7 +80,8 @@
:aspect aspect}
source (assoc :source source)
footage (assoc :footage footage)
seed (assoc :seed seed))))
seed (assoc :seed seed)
mode (assoc :mode mode))))
(defn analysis
"An analysis record with its `:id` filled in. The record is tier 1 — it says

View file

@ -4,6 +4,21 @@
(defonce ^:private instance (atom nil))
(defonce ^:private pending (atom nil))
(defn discard!
"Throw away the cached landmarker so the next run builds a fresh one.
Because a MediaPipe graph error is PERMANENT for the instance that hit it. A
timestamp that did not advance leaves the graph in an error state, and every
later `detectForVideo` on that landmarker re-throws it — so a memoised instance
turns one bad run into a tool that is broken until the tab is reloaded. Called
from the failure path, not from the happy one: a landmarker costs 26MB of wasm
and a model parse, and that is worth keeping for a run that ended cleanly."
[]
(when-let [model @instance]
(try (.call (aget model "close") model) (catch :default _ nil)))
(reset! instance nil)
(reset! pending nil))
(defn landmarker!
"Initialize once, using the vendored wasm and the local model. CPU also works
in browsers where a GPU delegate initializes but fails on its first frame.
@ -27,7 +42,7 @@
#js {:baseOptions
#js {:modelAssetPath "/static/mediapipe/face_landmarker.task"
:delegate "CPU"}
:runningMode "IMAGE"
:runningMode "VIDEO"
:numFaces 1})))
(.then (fn [model]
(reset! instance model)
@ -40,9 +55,27 @@
(js/Promise.reject (js/Error. "local MediaPipe script did not load"))))))
(defn detect!
"Detect one already-drawn canvas frame. nil means no face was detected."
[model canvas]
(when-let [face (aget (aget (.call (aget model "detect") model canvas)
"Detect one already-drawn canvas frame at its own time in the take. nil means no
face was detected.
`at-ms` IS THE FRAME'S REAL PRESENTATION TIME, and all three words are
load-bearing. In VIDEO mode the graph is a tracker: it runs face DETECTION only
when it has lost the face, and otherwise follows the previous frame's region,
using the gap between timestamps as the motion it has to account for. So:
IT MUST INCREASE, STRICTLY. MediaPipe's input streams reject a timestamp that
does not advance — `Packet timestamp mismatch on a calculator receiving from
stream \"norm_rect\"` — and that error is not recoverable: the graph is left in an
error state and every later call on this landmarker throws the same thing. One
repeated frame kills the run, so the caller walks frames forward exactly once.
IT MUST BE MILLISECONDS OF FOOTAGE, not a frame counter. Feeding `i` instead of
`i * 1000 / fps` still runs — and measured over the same 91 frames it moved
landmarks six times further from the per-frame answer (0.079 of frame width
against 0.013), because a tracker told that every frame is 1ms apart expects a
face that has barely moved."
[model canvas at-ms]
(when-let [face (aget (aget (.call (aget model "detectForVideo") model canvas at-ms)
"faceLandmarks") 0)]
(mapv (fn [p] {:x (.-x p) :y (.-y p) :z (.-z p)}) face)))

View file

@ -1,8 +1,14 @@
(ns arthur.flow.ingest
"Read footage from the server. Its response gives source timing and one
content-addressed URL per frame. The upload path derives this response from
stored frame records; no `manifest.json` file is part of that path."
(:require [arthur.fx.http :as http]))
"Read footage from the server: source timing, one video to measure, and one
content-addressed URL per tracing still.
THE MEASURED PIXELS COME OUT OF A VIDEO NOW, not out of a PNG per frame. The old
arrangement stored 112MB for a 7.6-second take and 1.1GB at the 900-frame limit;
the same footage is a 6MB H.264 proxy the page steps through. What that costs is
the property a PNG sequence gave for free — that asking for frame 12 gets frame
12 — so `frame!` below buys it back explicitly, and refuses to guess."
(:require [arthur.fx.http :as http]
[clojure.string :as str]))
(defn feature-presence
"Expand one-based, inclusive absence intervals from a manifest into boolean
@ -40,6 +46,13 @@
(sequential? urls) (every? string? urls))
(throw (ex-info "a footage manifest needs fps, frames (1–900), audio and a url per frame"
{:manifest (dissoc m :urls)})))
;; Named as its own failure rather than folded into the check above, because
;; it has a specific cause and a specific fix: this footage was extracted
;; before the proxy existed, and its frames were stored as PNGs that the
;; measurement path no longer reads.
(when-not (and (string? (:video m)) (seq (:video m)))
(throw (ex-info "this footage has no video to measure — re-extract it from its source"
{:footage (:id m)})))
(when-not (= frames (count urls))
;; The count is the manifest's and the URLs are the manifest's, so a
;; disagreement between them is the server contradicting itself — and it
@ -76,7 +89,13 @@
(defn audio-url [manifest]
(:audio manifest))
(defn frame-url [manifest i]
(defn video-url [manifest]
(:video manifest))
(defn frame-url
"The tracing still for one frame. A reference image for drawing over — the
landmarks and the mouth crops come from the video, not from these."
[manifest i]
(nth (:urls manifest) i))
(defn image! [src]
@ -87,3 +106,126 @@
(set! (.-onerror image) #(reject (ex-info (str "frame did not load: " src)
{:src src})))
(set! (.-src image) src)))))
;; ---------------------------------------------------------------------------
;; walking the proxy, one frame at a time
(def ^:private seek-timeout-ms
"How long one frame may take to arrive before the run gives up.
Long, because the first seek of a take also opens the file and fills a buffer,
and short enough that a video the browser cannot decode fails with a sentence
instead of hanging with a spinner."
10000)
(defn video!
"Load the proxy as a decodable, seekable element.
`preload=auto` and nothing else: the element is never added to the document and
never played. It is a decoder with a seek function, and the only reason it is a
DOM element rather than a `VideoDecoder` is that a `VideoDecoder` needs the
container demuxed before it can be handed a single frame, and this does not."
[src width height]
(js/Promise.
(fn [resolve reject]
(let [video (.createElement js/document "video")]
(set! (.-muted video) true)
(set! (.-playsInline video) true)
(set! (.-preload video) "auto")
(set! (.-crossOrigin video) "anonymous")
(set! (.-onerror video)
(fn [_]
(reject (ex-info (str "the browser could not decode this footage's video"
(when-let [e (.-error video)]
(str " (" (.-message e) ")")))
{:src src}))))
(set! (.-onloadeddata video)
(fn [_]
(cond
(not (fn? (.-requestVideoFrameCallback video)))
(reject (ex-info (str "this browser has no requestVideoFrameCallback, so "
"which frame is on screen cannot be established")
{}))
(not= [(.-videoWidth video) (.-videoHeight video)] [width height])
(reject (ex-info "the video's size disagrees with the footage manifest"
{:manifest [width height]
:video [(.-videoWidth video) (.-videoHeight video)]}))
:else (resolve video))))
(set! (.-src video) src)))))
(defn seek-time
"When to ask the video for source frame `i`: the MIDDLE of the frame, not its
start.
A frame occupies the half-open interval `[i/fps, (i+1)/fps)`, so `i/fps` sits
exactly on a boundary — and a boundary is where a seek lands on whichever side
the container's timebase rounds to. Measured over 91 frames: seeking to `i/fps`
produced the previous frame 31 times, and seeking to the middle was exact on all
91. Half a frame of slack in both directions is the entire fix."
[fps i]
(/ (+ i 0.5) fps))
(defn presented-frame
"Which source frame a `mediaTime` from requestVideoFrameCallback refers to.
`mediaTime` is the presented frame's own START — measured on 91 frames at 12fps
it came back as an exact multiple of the frame duration — so this is the inverse
of `i/fps` and NOT of `seek-time`. The two are asymmetric on purpose: we aim
half a frame late because a seek target is fuzzy, and read back exactly because
a presentation timestamp is not."
[fps media-time]
(js/Math.round (* media-time fps)))
(defn frame-ms
"When source frame `i` happens, in milliseconds of footage.
What `flow/detect` hands MediaPipe as the frame's timestamp. Real elapsed time
rather than the frame number, because the tracker reads the gap between
timestamps as motion — see `detect/detect!`."
[fps i]
(/ (* i 1000) fps))
(defn frame!
"Seek to source frame `i` and resolve once the browser has PRESENTED it.
IT WAITS FOR THE FRAME IT ASKED FOR, rather than trusting the first callback.
`requestVideoFrameCallback` is a queue of presentations, not an answer to our
seek: a frame presented while the file was still opening, or the tail of the
previous seek, arrives on the next callback we happen to have registered. Taking
it at face value is what produced `asked the video for frame 1 and it presented
frame 2` on a video whose seeks were in fact exact. So a callback whose
`mediaTime` is not this frame's is DISCARDED and the wait re-armed — the
browser states which frame it handed over, and that is the only frame we let
through.
It still fails loudly. A silent one-frame slip between the landmarks and the
audio is not something anyone finds by looking at the result, so a frame that
never arrives inside `seek-timeout-ms` ends the run and says which one."
[^js video fps i]
(js/Promise.
(fn [resolve reject]
(let [settled (volatile! false)
seen (volatile! [])
finish (fn [f] (when-not @settled (vreset! settled true) (f)))
timer (js/setTimeout
#(finish
(fn []
(reject (ex-info (str "the video never presented frame " (inc i)
(when (seq @seen)
(str "; it offered "
(str/join ", " (map inc @seen)))))
{:frame i :offered @seen}))))
seek-timeout-ms)]
(letfn [(listen []
(.requestVideoFrameCallback
video
(fn [_now metadata]
(when-not @settled
(let [presented (presented-frame fps (.-mediaTime metadata))]
(if (= presented i)
(do (js/clearTimeout timer) (finish #(resolve video)))
(do (vswap! seen conj presented) (listen))))))))]
(listen))
(set! (.-currentTime video) (seek-time fps i))))))

View file

@ -24,7 +24,10 @@
:footage (:footage manifest)
:frames (:frames manifest)
:fps (:fps manifest)
:aspect aspect}))))
:aspect aspect
;; How the detector was run, not just which one it was. See
;; `flow/address/analysis-descriptor`.
:mode "video"}))))
(defn measure
"Condition the anchor before measuring rings through it."