Add video upload, extraction progress, and reusable analysis sources

This commit is contained in:
Olive Vaughn 2026-09-28 09:38:49 -04:00
parent 690de21fa4
commit 686f897401
24 changed files with 927 additions and 137 deletions

View file

@ -97,6 +97,7 @@
(case type
"int16" (js/Int16Array. (.-buffer u8))
"float32" (js/Float32Array. (.-buffer u8))
"float64" (js/Float64Array. (.-buffer u8))
"uint8" u8
(throw (ex-info "a block's element type is \"int16\", \"float32\" or \"uint8\""
(throw (ex-info "a block's element type is \"int16\", \"float32\", \"float64\" or \"uint8\""
{:type type})))))

View file

@ -9,16 +9,18 @@
[arthur.flow.detect :as detect]
[arthur.flow.ingest :as ingest]
[arthur.flow.measure.interior :as interior]
[arthur.flow.source :as source]
[arthur.flow.take :as take]
[arthur.footage.store :as store]
[arthur.domain.landmarks :as lm]
[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 [])
interiors (atom [])
crops (atom [])
dims (atom nil)
total (:frames manifest)]
(js/Promise.
@ -27,7 +29,7 @@
(if (= i total)
(try
(resolve (assoc (detect/fill-gaps @raw)
:dimensions @dims :interior @interiors))
:dimensions @dims :crops @crops))
(catch :default error (reject error)))
(-> (ingest/image! (ingest/frame-url manifest i))
(.then
@ -43,13 +45,11 @@
(let [face (detect/detect! model canvas)
ring (when face (mapv #(nth face %) lm/LIPS-INNER))
box (when ring (interior/crop ring wh))
measured (if box
(interior/measure take/knobs box
(.getImageData ctx (:x box) (:y box)
(:w box) (:h box)))
{:contour nil :contrast 0 :area 0 :debug nil})]
pixels (when box
(.-data (.getImageData ctx (:x box) (:y box)
(:w box) (:h box))))]
(swap! raw conj face)
(swap! interiors conj measured))
(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
@ -59,43 +59,67 @@
(next-frame 0))))))
(defn- build-clip [manifest detector
{:keys [dense detected dimensions interior missing first-real]}]
{:keys [dense detected dimensions crops missing first-real] :as source-inputs}]
(let [[w h] dimensions
interior (source/measure-crops take/knobs crops)
frozen (take/footage manifest {:dense dense :detected detected
:dimensions dimensions :interior interior
:presence (:presence manifest)
:detector detector})
built (:clip frozen)]
built (:clip frozen)
source-blocks (source/pack (:id (:analysis built)) source-inputs)]
(assoc (select-keys built [:fps :width :height])
:frames (clip/frames built)
:display-fps (:fps built)
:clip built :store (:store frozen)
:source-blocks source-blocks :source-inputs source-inputs
;; No cache-buster. The audio is a blob named by the hash of its own
;; bytes, so re-extracting gives it a different URL rather than
;; overwriting this one — which is what the `?v=` here used to work
;; around.
:audio (ingest/audio-url manifest)
:label (or (:label manifest) (:source manifest) "footage")
:footage-id (:id manifest)
:cid (or (:id manifest) "footage")
:summary (str (:frames manifest) " frames · " w "×" h " · "
(:fps manifest) " fps"
(when (pos? missing)
(str " · " missing " without a face"
(when (pos? first-real)
(str " (first found on " (inc first-real) ")"))))))))
(str " (first found on " (inc first-real) ")"))))))))
(defn- cached-source! [manifest detector]
(let [key (:id (take/analysis-for manifest detector))]
(-> (http/GET (str "/api/analyses/" key))
(.then (fn [^js analysis]
(let [keys (array-seq (.-source_blocks analysis))]
(when (= (count keys) (count source/roles))
(-> (js/Promise.all
(into-array (map #(http/GET (str "/api/blocks/" %)) keys)))
(.then (fn [blocks]
(source/unpack blocks
[(:width manifest) (:height manifest)]))))))))
(.catch (fn [error]
(if (= 404 (:status (ex-data error))) nil (throw error)))))))
(rf/reg-fx
::begin!
(fn [footage-id]
(-> (js/Promise.all #js [(ingest/manifest! footage-id) (ingest/detector!)])
(.then (fn [[manifest detector]]
(rf/dispatch [::progress "loading MediaPipe…"])
(-> (detect/landmarker!)
(.then (fn [model]
(rf/dispatch [::progress "loading frames…"])
(-> (detect-frames! manifest model)
(.then (fn [track]
(build-clip manifest detector track)))))))))
(rf/dispatch [::progress "looking for saved analysis…"])
(-> (cached-source! manifest detector)
(.then (fn [track]
(if track
(do (rf/dispatch [::progress "reusing saved analysis…"])
(build-clip manifest detector track))
(do (rf/dispatch [::progress "loading MediaPipe…"])
(-> (detect/landmarker!)
(.then (fn [model]
(rf/dispatch [::progress "loading frames…"])
(-> (detect-frames! manifest model)
(.then (fn [fresh]
(build-clip manifest detector fresh))))))))))))))
(.then (fn [entry]
(let [id (store/install! entry)]
(rf/dispatch [::loaded id (:summary entry)]))))
@ -111,6 +135,47 @@
(.catch (fn [error]
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
(defn- poll-extraction! [key]
(-> (http/GET (str "/api/extractions/" key))
(.then (fn [^js job]
(case (.-state job)
"done" (rf/dispatch [::uploaded (.-footage job)])
"failed" (rf/dispatch [::failed (.-error job)])
(do (rf/dispatch [::progress
(str "extracting " (.-progress job) "%")])
(js/setTimeout #(poll-extraction! key) 800)))))
(.catch (fn [error]
(rf/dispatch [::failed (or (ex-message error) (str error))])))))
(rf/reg-fx
::upload!
(fn [file]
(let [form (js/FormData.)]
(.append form "file" file)
(-> (http/POST-form "/api/sources" form)
(.then (fn [^js source]
(rf/dispatch [::progress "queued for extraction…"])
(http/POST "/api/extractions" #js {:source (.-id source)
:settings #js {}})))
(.then (fn [^js job] (poll-extraction! (.-key job))))
(.catch (fn [error]
(rf/dispatch [::failed (or (ex-message error) (str error))])))))))
(rf/reg-event-fx
::upload
(fn [{:keys [db]} [_ file]]
(if (or (nil? file) (get-in db [:footage :loading?]))
{}
{:db (update db :footage merge {:loading? true :status "uploading video…"})
::upload! file})))
(rf/reg-event-fx
::uploaded
(fn [{:keys [db]} [_ footage-id]]
{:db (update db :footage merge {:loading? false :chosen footage-id
:status "video extracted — load frames to analyze"})
:dispatch [::refresh]}))
(rf/reg-event-fx
::refresh
(fn [_ _] {::list! nil}))
@ -119,10 +184,9 @@
::listed
(fn [db [_ footage]]
(update db :footage merge
{:available (vec footage)
:chosen (or (:chosen (:footage db)) (:id (first footage)))
:status (when (empty? footage)
"no footage ingested — ./extract.sh, then manage.py ingest_bundle")})))
(cond-> {:available (vec footage)
:chosen (or (:chosen (:footage db)) (:id (first footage)))}
(empty? footage) (assoc :status "upload a video to begin")))))
(rf/reg-event-db
::choose
@ -135,8 +199,7 @@
(cond
(get-in db [:footage :loading?]) {}
(nil? chosen)
{:db (assoc-in db [:footage :status]
"no footage ingested — ./extract.sh, then manage.py ingest_bundle")}
{:db (assoc-in db [:footage :status] "upload a video to begin")}
:else
{:db (update db :footage merge {:loading? true :status "reading the manifest…"})
::pb/pause! nil
@ -150,7 +213,7 @@
::failed
(fn [db [_ message]]
(assoc db :footage (assoc (:footage db)
:loading? false :status (str "load failed: " message)))))
:loading? false :status (str "footage failed: " message)))))
(rf/reg-event-fx
::loaded

View file

@ -26,6 +26,8 @@
[arthur.events.playback :as pb]
[arthur.footage.store :as store]
[arthur.flow.address :as address]
[arthur.flow.source :as source]
[arthur.flow.ingest :as ingest]
[arthur.fx.http :as http]
[re-frame.core :as rf]))
@ -65,16 +67,64 @@
(-> (http/POST "/api/projects" #js {:name name})
(.then (fn [^js created] (.-id created))))))
(defn- opened-entry! [^js clip-json]
(let [footage-id (.-footage clip-json)
analysis-key (.-analysis clip-json)]
(-> (js/Promise.all
#js [(js/Promise.all
(into-array (map #(http/GET (str "/api/blocks/" %))
(array-seq (.-blocks clip-json)))))
(if footage-id (ingest/manifest! footage-id) (js/Promise.resolve nil))
(if analysis-key (http/GET (str "/api/analyses/" analysis-key))
(js/Promise.resolve nil))])
(.then (fn [[blocks manifest analysis]]
(let [source-keys (when analysis (array-seq (.-source_blocks ^js analysis)))]
(-> (js/Promise.all
(into-array (map #(http/GET (str "/api/blocks/" %)) source-keys)))
(.then (fn [source-responses]
(let [cid (.-cid clip-json)
loaded (project/load
cid #js {:leaves (.-leaves clip-json)
:blocks blocks})
built (:clip loaded)
inputs (when (seq source-keys)
(when-not manifest
(throw (ex-info "saved analysis has no footage"
{:analysis analysis-key})))
(source/unpack source-responses
[(:width manifest) (:height manifest)]))]
(merge (select-keys built [:fps :width :height])
{:label (str (or (.-name clip-json) cid) " (saved)")
:cid cid :frames (clip/frames built)
:display-fps (:fps built)
:clip built :store (:store loaded)
:footage-id footage-id
:source-inputs inputs
:source-blocks (when inputs
(source/pack analysis-key inputs))
:audio (if manifest (:audio manifest)
"/static/arthur/audio.wav")})))))))))))
(rf/reg-fx
::save!
(fn [{:keys [id cid label clip]}]
(let [analysis (:analysis (:clip clip))
doc (project/save cid clip)]
doc (project/save cid clip)
source-blocks (:source-blocks clip)]
(-> (ensure-project! id label)
(.then (fn [pid]
(-> (if analysis
(http/POST "/api/analyses" (analysis-payload analysis))
(js/Promise.resolve nil))
(.then (fn [_]
(when (seq source-blocks)
(-> (upload-missing!
#js {:blocks (source/wire-blocks source-blocks)})
(.then (fn [_]
(http/PUT
(str "/api/analyses/" (:id analysis))
#js {:source_blocks
(into-array (map :key (vals source-blocks)))})))))))
(.then (fn [_] (upload-missing! doc)))
(.then (fn [uploaded]
(-> (http/PUT (str "/api/projects/" pid)
@ -82,6 +132,7 @@
:clips #js [#js {:cid cid
:name label
:analysis (:id analysis)
:footage (:footage-id clip)
:leaves (.-leaves doc)
:blocks (block-keys doc)}]})
(.then (fn [^js saved]
@ -109,35 +160,13 @@
(let [^js clip-json (first (array-seq (.-clips loaded)))]
(when-not clip-json
(throw (ex-info "that project has no clips" {})))
(-> (js/Promise.all
(into-array (map #(http/GET (str "/api/blocks/" %))
(array-seq (.-blocks clip-json)))))
(.then (fn [blocks]
(let [doc #js {:leaves (.-leaves clip-json) :blocks blocks}
cid (.-cid clip-json)
loaded' (project/load cid doc)
built (:clip loaded')
entry (merge
(select-keys built [:fps :width :height])
{:label (str (or (.-name clip-json) cid) " (saved)")
:cid cid
:frames (clip/frames built)
:display-fps (:fps built)
:clip built
:store (:store loaded')
;; The audio is the clip's, and a
;; document does not carry it: tier 3
;; is by hash and the clip names the
;; analysis, not the sound. Until the
;; footage id is in the document, the
;; synthetic take's is the one that
;; keeps the clock running.
:audio "/static/arthur/audio.wav"})]
(rf/dispatch [::opened
(store/install! entry "project")
(.-id loaded)
(.-name loaded)
(.-seq loaded)]))))))))
(-> (opened-entry! clip-json)
(.then (fn [entry]
(rf/dispatch [::opened
(store/install! entry "project")
(.-id loaded)
(.-name loaded)
(.-seq loaded)])))))))
(.catch (fn [error]
(js/console.error error)
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))

View file

@ -137,6 +137,9 @@
budget does not reach the teeth at all (the crop is taken from raw landmarks).
It reads like a mistake in both directions and is neither."
{"geom" [:anchor-avg :contour-avg :verts]
"source/dense" []
"source/detected" []
"source/crops" []
"head-pos" [:anchor-avg]
"head-rot" [:anchor-avg]
"head-scale" [:anchor-avg]

View file

@ -1,24 +1,7 @@
(ns arthur.flow.ingest
"Read an ingested take. The manifest owns timing, the exact frame count, and —
since step 9 — the URL of every frame.
WHAT CHANGED, AND WHY IT IS NOT A DETAIL. This used to fetch `/manifest.json` off
the filesystem and then build `frames/0001.png` itself, with shadow-cljs serving
the repo root. So the frame layout was a shared secret between a shell script and
this namespace, and \"where are the frames\" was answered by a directory listing
that nothing could version.
Now the server names every frame and this asks it. The manifest carries a URL per
frame, so the frames can live in a content-addressed blob store — or, when the
in-browser wasm-ffmpeg extraction docs/architecture.md describes arrives, be
uploaded into the same store by the app itself — and nothing in here learns
anything new. That is the whole point of tier 3 being addressed rather than
located.
The cache-busting `?v=` that used to hang off every frame URL went with it. It
was there because re-extracting overwrote `frames/0001.png` under the same name;
a blob's name IS the hash of its bytes, so a stale copy is not a thing that can
happen."
"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]))
(defn feature-presence
@ -67,8 +50,7 @@
:presence (feature-presence frames (:feature-absence m)))))
(defn available!
"Every ingested take the server holds. `manage.py ingest_bundle` is what puts one
there."
"Every extracted take the server holds."
[]
(-> (http/GET "/api/footage")
(.then (fn [json] (:footage (js->clj json :keywordize-keys true))))))

View file

@ -0,0 +1,118 @@
(ns arthur.flow.source
"The pixel-dependent result of analyzing footage, stored as three ordinary
content-addressed blocks. Everything after this boundary can run without PNGs
or MediaPipe. The mouth crops hold raw pixels and an un-eroded lip ring."
(:require [arthur.domain.landmarks :as lm]
[arthur.domain.wire :as wire]
[arthur.flow.address :as address]
[arthur.flow.measure.interior :as interior]))
(def roles ["source/dense" "source/detected" "source/crops"])
(defn measure-crops [params crops]
(mapv (fn [crop]
(if crop
(interior/measure params (:box crop) #js {:data (:data crop)})
{:contour nil :contrast 0 :area 0 :debug nil}))
crops))
(defn- named [role analysis tracks layout data]
(merge (address/block {:role role :analysis analysis :params {}
:tracks tracks :features [] :observation nil
:layout layout})
{:role role :data data}))
(defn pack
"Dense landmarks, detection mask and raw RGBA mouth crops -> source blocks."
[analysis {:keys [dense detected crops]}]
(let [frames (count dense)
points (count (first dense))]
(when-not (and (pos? frames) (pos? points)
(= frames (count detected) (count crops))
(every? #(= points (count %)) dense))
(throw (ex-info "source tracks must have the same frame and landmark counts"
{:frames frames :points points})))
(let [landmarks (js/Float64Array. (* frames points 3))
mask (js/Uint8Array. frames)
lengths (mapv #(if % (.-length ^js (:data %)) 0) crops)
offsets (vec (reductions + 0 lengths))
pixels (js/Uint8Array. (max 1 (peek offsets)))
boxes (mapv (fn [crop]
(when-let [box (:box crop)]
(mapv box [:x :y :w :h])))
crops)]
(doseq [f (range frames)]
(aset mask f (if (nth detected f) 1 0))
(doseq [i (range points)]
(let [{:keys [x y z]} (nth (nth dense f) i)
base (* 3 (+ (* f points) i))]
(aset landmarks base x)
(aset landmarks (inc base) y)
(aset landmarks (+ base 2) z)))
(when-let [crop (nth crops f)]
(.set pixels (:data crop) (nth offsets f))))
{"source/dense" (named "source/dense" analysis ["landmarks"]
{:type "float64" :frames frames :points points :stride (* 3 points)}
landmarks)
"source/detected" (named "source/detected" analysis ["detected"]
{:type "uint8" :frames frames :stride 1} mask)
"source/crops" (named "source/crops" analysis ["rgba"]
{:type "uint8" :frames frames :boxes boxes :offsets offsets}
pixels)})))
(defn wire-blocks [blocks]
(into-array
(map (fn [role]
(let [{:keys [key descriptor data]} (get blocks role)]
#js {:key key :descriptor descriptor :data (wire/base64 data)}))
roles)))
(defn unpack
"The three block-detail responses -> inputs for measurement and freeze."
[^js responses [width height]]
(let [by-role (into {}
(map (fn [^js response]
(let [descriptor (js->clj (js/JSON.parse (.-descriptor response))
:keywordize-keys true)]
[(:role descriptor)
{:layout (:layout descriptor)
:data (wire/typed (get-in descriptor [:layout :type])
(.-data response))}]))
(array-seq responses)))
_ (when-not (= (set roles) (set (keys by-role)))
(throw (ex-info "analysis is missing source blocks" {:roles (keys by-role)})))
{:keys [layout data]} (get by-role "source/dense")
frames (:frames layout)
points (:points layout)
mask (:data (get by-role "source/detected"))
crop-data (:data (get by-role "source/crops"))
crop-layout (:layout (get by-role "source/crops"))]
(when-not (and (= (* frames points 3) (.-length data))
(= frames (.-length mask) (:frames crop-layout))
(= frames (count (:boxes crop-layout)))
(= (inc frames) (count (:offsets crop-layout))))
(throw (ex-info "source block layouts disagree" {:frames frames :points points})))
(let [dense (mapv (fn [f]
(mapv (fn [i]
(let [base (* 3 (+ (* f points) i))]
{:x (aget data base) :y (aget data (inc base))
:z (aget data (+ base 2))}))
(range points)))
(range frames))
detected (mapv #(pos? (aget mask %)) (range frames))
crops (mapv
(fn [f]
(when-let [[x y w h] (nth (:boxes crop-layout) f)]
(let [start (nth (:offsets crop-layout) f)
end (nth (:offsets crop-layout) (inc f))
shape (mapv #(nth (nth dense f) %) lm/LIPS-INNER)]
(when-not (= (* 4 w h) (- end start))
(throw (ex-info "mouth crop byte count disagrees with its box"
{:frame f :expected (* 4 w h) :actual (- end start)})))
{:box {:x x :y y :w w :h h :shape shape
:source-width width :source-height height}
:data (js/Uint8ClampedArray. (.-buffer crop-data) start (- end start))})))
(range frames))]
{:dense dense :detected detected :crops crops :dimensions [width height]
:missing (count (remove true? detected))
:first-real (first (keep-indexed (fn [i present?] (when present? i)) detected))})))

View file

@ -15,6 +15,17 @@
(def knobs params/defaults)
(defn analysis-for [manifest detector]
(let [aspect (/ (:width manifest) (:height manifest))]
(address/analysis
(merge {:detector "mediapipe" :version "unknown"}
detector
{:source (:source manifest)
:footage (:footage manifest)
:frames (:frames manifest)
:fps (:fps manifest)
:aspect aspect}))))
(defn measure
"Condition the anchor before measuring rings through it."
[{:keys [aspect] :as params} {:keys [dense detected interior presence]}]
@ -66,13 +77,7 @@
;; version string somebody has to remember to bump. See
;; `flow/address`: a model upgrade that silently reused
;; these landmarks is the failure this prevents.
:analysis (address/analysis
(merge {:detector "mediapipe" :version "unknown"}
detector
{:source (:source manifest)
:footage (:footage manifest)
:frames (:frames manifest)
:fps (:fps manifest)
:aspect (/ w h)}))})]
:analysis (analysis-for (assoc manifest :width w :height h)
detector)})]
(build params {:dense dense :detected detected :interior interior
:presence presence})))

View file

@ -34,14 +34,16 @@
(defn request!
([method url] (request! method url nil))
([method url body]
(-> (js/fetch url
(clj->js (cond-> {:method method
:cache "no-store"
:headers (cond-> {"Accept" "application/json"}
body (assoc "Content-Type" "application/json")
(not= "GET" method)
(assoc "X-CSRFToken" (or (csrf-token) "")))}
body (assoc :body (js/JSON.stringify body)))))
(let [form? (instance? js/FormData body)
headers #js {"Accept" "application/json"}
options #js {:method method :cache "no-store" :headers headers}]
(when (and body (not form?))
(aset headers "Content-Type" "application/json"))
(when (not= "GET" method)
(aset headers "X-CSRFToken" (or (csrf-token) "")))
(when body
(aset options "body" (if form? body (js/JSON.stringify body))))
(-> (js/fetch url options)
(.then (fn [response]
(-> (.text response)
(.then (fn [text]
@ -49,8 +51,9 @@
(try (js/JSON.parse text) (catch :default _ nil)))]
(if (.-ok response)
parsed
(fail response parsed)))))))))))
(fail response parsed))))))))))))
(defn GET [url] (request! "GET" url))
(defn POST [url body] (request! "POST" url body))
(defn POST-form [url body] (request! "POST" url body))
(defn PUT [url body] (request! "PUT" url body))

View file

@ -108,10 +108,14 @@
[:button {:class (when (= r picture-fps) "on")
:on-click #(rf/dispatch [::pb/set-picture-fps r])}
(if (= r fps) "source" (str r))]))])
;; The takes the SERVER holds, since step 9. There is no path to type any
;; more: `./extract.sh` decodes a clip and `manage.py ingest_bundle` registers
;; it, and from then on the frames are addressed rather than located.
;; Upload a video or choose existing server footage. Both routes produce the
;; same immutable frame and audio manifest.
[:label.source-path "footage "
[:input {:type "file" :accept "video/*" :disabled loading?
:on-change (fn [event]
(when-let [file (aget (.. event -target -files) 0)]
(rf/dispatch [::footage/upload file])
(set! (.. event -target -value) "")))}]
[:select {:value (or chosen "") :disabled loading?
:on-change #(rf/dispatch [::footage/choose (.. % -target -value)])}
(if (seq available)
@ -119,7 +123,7 @@
^{:key id}
[:option {:value id}
(str label " · " frames "f @" fps)]))
[:option {:value ""} "nothing ingested"])]
[:option {:value ""} "no footage yet"])]
[:button {:disabled loading?
:on-click #(rf/dispatch [::footage/refresh])} "refresh"]]
(when status [:div.load-status status])
@ -148,5 +152,5 @@
[audio]
[transport]
[:p.note
"Extract a clip with extract.sh, then load frames. The manifest supplies "
"the frame count, audio and fps."]])
"Upload a video, choose its footage, then load frames. Save the project to "
"share the analyzed take without detecting frames again."]])