Add scoped feature regeneration and retained pixel measurements
This commit is contained in:
parent
39ee37db02
commit
3058b9a5f2
15 changed files with 889 additions and 81 deletions
|
|
@ -48,6 +48,19 @@ and PUTs with ordinary CSRF protection — no endpoint in this app is exempt.
|
||||||
color: var(--fg); background: #1c1f2b; border: 1px solid #2b3040;
|
color: var(--fg); background: #1c1f2b; border: 1px solid #2b3040;
|
||||||
font: inherit; max-width: 360px; }
|
font: inherit; max-width: 360px; }
|
||||||
.load-status { margin-top: 6px; font-size: 12px; opacity: .75; }
|
.load-status { margin-top: 6px; font-size: 12px; opacity: .75; }
|
||||||
|
.controls { width: 640px; margin-top: 18px; padding-top: 12px;
|
||||||
|
border-top: 1px solid #2b3040; font-size: 12px; }
|
||||||
|
.controls select { margin-left: 8px; padding: 3px 5px; color: var(--fg);
|
||||||
|
background: #1c1f2b; border: 1px solid #2b3040; font: inherit; }
|
||||||
|
.shared-note { margin-top: 6px; color: #d0ba86; }
|
||||||
|
.control-list { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 16px;
|
||||||
|
margin-top: 10px; }
|
||||||
|
.control-row { display: grid; grid-template-columns: 115px 1fr 42px;
|
||||||
|
align-items: center; gap: 6px; }
|
||||||
|
.control-row input { width: 100%; }
|
||||||
|
.control-row output { text-align: right; }
|
||||||
|
.regeneration-debug { padding: 8px; margin-top: 10px; background: #1c1f2b;
|
||||||
|
white-space: pre-wrap; color: #d0ba86; }
|
||||||
.note { opacity: .35; font-size: 12px; max-width: 640px; }
|
.note { opacity: .35; font-size: 12px; max-width: 640px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,12 @@
|
||||||
:channels (cond-> {[:audio :gain] gain}
|
:channels (cond-> {[:audio :gain] gain}
|
||||||
pan (assoc [:audio :pan] pan))}])
|
pan (assoc [:audio :pan] pan))}])
|
||||||
audio))]
|
audio))]
|
||||||
(-> source
|
(cond-> (assoc source :name name :width width :height height
|
||||||
(assoc :name name :width width :height height
|
:timelines {:main {:id :main :frames frames :nodes nodes}
|
||||||
:timelines {:main {:id :main :frames frames :nodes nodes}
|
symbol (assoc original :id symbol)})
|
||||||
symbol (assoc original :id symbol)})
|
;; These are the same tracked features in the shared symbol. Every
|
||||||
(dissoc :subjects :features :groups))))
|
;; placement reads them; tuning one changes all instances.
|
||||||
|
(seq (:features source))
|
||||||
|
(assoc :features (into {} (map (fn [[id f]]
|
||||||
|
[id (assoc f :timeline symbol)]))
|
||||||
|
(:features source))))))
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,10 @@
|
||||||
:when (not (params/valid-settings? (:area f) (or (:params f) {})))]
|
:when (not (params/valid-settings? (:area f) (or (:params f) {})))]
|
||||||
(str "feature " (pr-str id) " has invalid settings for " (pr-str (:area f))))
|
(str "feature " (pr-str id) " has invalid settings for " (pr-str (:area f))))
|
||||||
(for [[id f] features node-id (:nodes f)
|
(for [[id f] features node-id (:nodes f)
|
||||||
:when (not (contains? nodes node-id))]
|
:let [owned-nodes (if-let [timeline (:timeline f)]
|
||||||
|
(get-in clip [:timelines timeline :nodes])
|
||||||
|
nodes)]
|
||||||
|
:when (not (contains? owned-nodes node-id))]
|
||||||
(str "feature " (pr-str id) " refers to missing node " (pr-str node-id)))
|
(str "feature " (pr-str id) " refers to missing node " (pr-str node-id)))
|
||||||
(for [[id n] (frequencies node-owners) :when (> n 1)]
|
(for [[id n] (frequencies node-owners) :when (> n 1)]
|
||||||
(str "node " (pr-str id) " belongs to more than one feature"))
|
(str "node " (pr-str id) " belongs to more than one feature"))
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,8 @@
|
||||||
:frames (clip/frames built)
|
:frames (clip/frames built)
|
||||||
:display-fps (:fps built)
|
:display-fps (:fps built)
|
||||||
:clip built :store (:store frozen)
|
:clip built :store (:store frozen)
|
||||||
:source-blocks source-blocks :source-inputs source-inputs
|
:source-blocks source-blocks
|
||||||
|
:source-inputs (assoc source-inputs :presence (:presence manifest))
|
||||||
;; No cache-buster. The audio is a blob named by the hash of its own
|
;; 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
|
;; bytes, so re-extracting gives it a different URL rather than
|
||||||
;; overwriting this one — which is what the `?v=` here used to work
|
;; overwriting this one — which is what the `?v=` here used to work
|
||||||
|
|
@ -124,24 +125,40 @@
|
||||||
(when (pos? first-real)
|
(when (pos? first-real)
|
||||||
(str " (first found on " (inc first-real) ")"))))))))
|
(str " (first found on " (inc first-real) ")"))))))))
|
||||||
|
|
||||||
(defn- cached-source! [manifest detector]
|
(defn- with-default-interior! [analysis track]
|
||||||
(let [key (:id (take/analysis-for manifest detector))]
|
(let [frames (count (:crops track))
|
||||||
(-> (http/GET (str "/api/analyses/" key))
|
key (source/interior-key analysis take/knobs frames)]
|
||||||
(.then (fn [^js analysis]
|
(-> (http/GET (str "/api/blocks/" key))
|
||||||
(let [keys (array-seq (.-source_blocks analysis))]
|
(.then (fn [block]
|
||||||
(when (= (count keys) (count source/roles))
|
(assoc track :interior (source/unpack-interior block take/knobs frames)
|
||||||
(-> (js/Promise.all
|
:interior-key key)))
|
||||||
(into-array (map #(http/GET (str "/api/blocks/" %)) keys)))
|
|
||||||
(.then (fn [blocks]
|
|
||||||
(source/unpack blocks
|
|
||||||
[(:width manifest) (:height manifest)]))))))))
|
|
||||||
(.catch (fn [error]
|
(.catch (fn [error]
|
||||||
(if (= 404 (:status (ex-data error))) nil (throw error)))))))
|
(if (= 404 (:status (ex-data error))) track (throw error)))))))
|
||||||
|
|
||||||
|
(defn saved-source!
|
||||||
|
"Restore retained source tracks by analysis id, for regeneration after open."
|
||||||
|
[key dimensions]
|
||||||
|
(-> (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 dimensions)))
|
||||||
|
(.then (fn [track] (with-default-interior! key track))))))))
|
||||||
|
(.catch (fn [error]
|
||||||
|
(if (= 404 (:status (ex-data error))) nil (throw error))))))
|
||||||
|
|
||||||
|
(defn- cached-source! [manifest detector]
|
||||||
|
(saved-source! (:id (take/analysis-for manifest detector))
|
||||||
|
[(:width manifest) (:height manifest)]))
|
||||||
|
|
||||||
(defn- measure-cached! [track]
|
(defn- measure-cached! [track]
|
||||||
;; Cached source blocks hold crop pixels, but no interior measurements. Spread
|
;; Older analyses may lack the optional interior block. Spread their one-time
|
||||||
;; their measurement over event-loop turns just like the fresh decode path.
|
;; measurement over event-loop turns just like the fresh decode path.
|
||||||
(let [crops (:crops track)
|
(if (:interior track)
|
||||||
|
(js/Promise.resolve track)
|
||||||
|
(let [crops (:crops track)
|
||||||
total (count crops)
|
total (count crops)
|
||||||
interior (atom [])]
|
interior (atom [])]
|
||||||
(js/Promise.
|
(js/Promise.
|
||||||
|
|
@ -156,7 +173,7 @@
|
||||||
(str "measuring " (inc i) "/" total)]))
|
(str "measuring " (inc i) "/" total)]))
|
||||||
(js/setTimeout #(step (inc i)) 0)
|
(js/setTimeout #(step (inc i)) 0)
|
||||||
(catch :default error (reject error)))))]
|
(catch :default error (reject error)))))]
|
||||||
(step 0))))))
|
(step 0)))))))
|
||||||
|
|
||||||
(rf/reg-fx
|
(rf/reg-fx
|
||||||
::begin!
|
::begin!
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,19 @@
|
||||||
(:require [arthur.domain.clip :as clip]
|
(:require [arthur.domain.clip :as clip]
|
||||||
[arthur.audio.mix :as mix]
|
[arthur.audio.mix :as mix]
|
||||||
[arthur.demo.stage :as stage]
|
[arthur.demo.stage :as stage]
|
||||||
|
[arthur.domain.feature :as feature]
|
||||||
[arthur.domain.project :as project]
|
[arthur.domain.project :as project]
|
||||||
[arthur.domain.wire :as wire]
|
[arthur.domain.wire :as wire]
|
||||||
|
[arthur.events.footage :as footage]
|
||||||
[arthur.events.playback :as pb]
|
[arthur.events.playback :as pb]
|
||||||
[arthur.footage.store :as store]
|
[arthur.footage.store :as store]
|
||||||
[arthur.flow.address :as address]
|
[arthur.flow.address :as address]
|
||||||
|
[arthur.flow.ingest :as ingest]
|
||||||
|
[arthur.flow.regenerate :as regenerate]
|
||||||
[arthur.flow.source :as source]
|
[arthur.flow.source :as source]
|
||||||
|
[arthur.flow.take :as take]
|
||||||
[arthur.fx.http :as http]
|
[arthur.fx.http :as http]
|
||||||
|
[arthur.synth :as synth]
|
||||||
[re-frame.core :as rf]))
|
[re-frame.core :as rf]))
|
||||||
|
|
||||||
(defn- analysis-payload [analysis]
|
(defn- analysis-payload [analysis]
|
||||||
|
|
@ -130,7 +136,8 @@
|
||||||
(http/PUT
|
(http/PUT
|
||||||
(str "/api/analyses/" (:id analysis))
|
(str "/api/analyses/" (:id analysis))
|
||||||
#js {:source_blocks
|
#js {:source_blocks
|
||||||
(into-array (map :key (vals source-blocks)))})))))))
|
(into-array (map #(get-in source-blocks [% :key])
|
||||||
|
source/roles))})))))))
|
||||||
(.then (fn [_] (upload-missing! doc)))
|
(.then (fn [_] (upload-missing! doc)))
|
||||||
(.then (fn [uploaded]
|
(.then (fn [uploaded]
|
||||||
(-> (http/PUT (str "/api/projects/" pid)
|
(-> (http/PUT (str "/api/projects/" pid)
|
||||||
|
|
@ -200,6 +207,137 @@
|
||||||
(js/console.error error)
|
(js/console.error error)
|
||||||
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
|
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
|
||||||
|
|
||||||
|
(defonce ^:private retained-source (atom nil))
|
||||||
|
(defonce ^:private retained-interior (atom nil))
|
||||||
|
|
||||||
|
(defn- measure-crops! [inputs settings]
|
||||||
|
;; Existing analyses without an interior block pay this once. One crop per
|
||||||
|
;; event-loop turn keeps playback responsive during that backfill.
|
||||||
|
(if (or (:interior inputs) (not (:crops inputs)))
|
||||||
|
(js/Promise.resolve inputs)
|
||||||
|
(js/Promise.
|
||||||
|
(fn [resolve reject]
|
||||||
|
(let [crops (:crops inputs)
|
||||||
|
measured (atom [])]
|
||||||
|
(letfn [(step [i]
|
||||||
|
(if (= i (count crops))
|
||||||
|
(resolve (assoc inputs :interior @measured))
|
||||||
|
(try
|
||||||
|
(swap! measured conj (source/measure-crop settings (nth crops i)))
|
||||||
|
(js/setTimeout #(step (inc i)) 0)
|
||||||
|
(catch :default error (reject error)))))]
|
||||||
|
(step 0)))))))
|
||||||
|
|
||||||
|
(defn- retained-interior! [analysis settings inputs]
|
||||||
|
(let [frames (count (:crops inputs))
|
||||||
|
block-key (source/interior-key analysis settings frames)]
|
||||||
|
(-> (http/GET (str "/api/blocks/" block-key))
|
||||||
|
(.then (fn [block]
|
||||||
|
(assoc inputs
|
||||||
|
:interior (source/unpack-interior block settings frames)
|
||||||
|
:interior-key block-key)))
|
||||||
|
(.catch (fn [error]
|
||||||
|
(if (= 404 (:status (ex-data error)))
|
||||||
|
(-> (measure-crops! (dissoc inputs :interior) settings)
|
||||||
|
(.then (fn [measured]
|
||||||
|
(let [{:keys [key descriptor data]}
|
||||||
|
(source/interior-block analysis settings
|
||||||
|
(:interior measured))]
|
||||||
|
(-> (upload-missing!
|
||||||
|
#js {:blocks #js [#js {:key key
|
||||||
|
:descriptor descriptor
|
||||||
|
:data data}]})
|
||||||
|
(.then (fn [_]
|
||||||
|
(assoc measured :interior-key block-key))))))))
|
||||||
|
(throw error)))))))
|
||||||
|
|
||||||
|
(defn- source-for! [entry]
|
||||||
|
(if-let [inputs (:source-inputs entry)]
|
||||||
|
(js/Promise.resolve inputs)
|
||||||
|
(let [analysis (get-in entry [:clip :analysis])]
|
||||||
|
(if (= (:id analysis) (:id @retained-source))
|
||||||
|
(:promise @retained-source)
|
||||||
|
(let [promise
|
||||||
|
(if (= "synth" (:detector analysis))
|
||||||
|
(js/Promise.resolve
|
||||||
|
{:dense (synth/synth-dense (:frames analysis) {:seed (:seed analysis)})})
|
||||||
|
(-> (ingest/manifest! (:footage-id entry))
|
||||||
|
(.then (fn [manifest]
|
||||||
|
(-> (footage/saved-source! (:id analysis)
|
||||||
|
[(:width manifest)
|
||||||
|
(:height manifest)])
|
||||||
|
(.then (fn [inputs]
|
||||||
|
(when-not inputs
|
||||||
|
(throw (ex-info "saved analysis has no source blocks" {})))
|
||||||
|
(assoc inputs :presence (:presence manifest)))))))))]
|
||||||
|
(do
|
||||||
|
(reset! retained-source {:id (:id analysis) :promise promise})
|
||||||
|
promise))))))
|
||||||
|
|
||||||
|
(defn- inputs-for-edit! [entry edit inputs]
|
||||||
|
(let [{clip :changed fids :features} (regenerate/plan (:clip entry) edit)
|
||||||
|
teeth (first (filter #(= :teeth (get-in clip [:features % :area])) fids))]
|
||||||
|
(if (and teeth (:crops inputs))
|
||||||
|
(let [settings (merge take/knobs (feature/effective-params clip teeth))
|
||||||
|
analysis (get-in clip [:analysis :id])
|
||||||
|
frames (count (:crops inputs))
|
||||||
|
key (source/interior-key analysis settings frames)]
|
||||||
|
(if (and (:interior inputs)
|
||||||
|
(or (= (:interior-key inputs) key)
|
||||||
|
(and (nil? (:interior-key inputs))
|
||||||
|
(= key (source/interior-key analysis take/knobs frames)))))
|
||||||
|
(js/Promise.resolve inputs)
|
||||||
|
(if (= key (:key @retained-interior))
|
||||||
|
(:promise @retained-interior)
|
||||||
|
(let [promise (retained-interior! analysis settings inputs)]
|
||||||
|
(reset! retained-interior {:key key :promise promise})
|
||||||
|
promise))))
|
||||||
|
(js/Promise.resolve inputs))))
|
||||||
|
|
||||||
|
(rf/reg-fx
|
||||||
|
::preview-settings!
|
||||||
|
(fn [{:keys [id entry edit request]}]
|
||||||
|
(-> (source-for! entry)
|
||||||
|
(.then (fn [inputs] (inputs-for-edit! entry edit inputs)))
|
||||||
|
(.then (fn [inputs]
|
||||||
|
(regenerate/change (assoc entry :source-inputs inputs) edit)))
|
||||||
|
(.then (fn [changed]
|
||||||
|
(rf/dispatch [::settings-previewed id request changed])))
|
||||||
|
(.catch (fn [error]
|
||||||
|
(js/console.error error)
|
||||||
|
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
|
||||||
|
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::preview-settings
|
||||||
|
(fn [{:keys [db]} [_ edit]]
|
||||||
|
(let [id (:clip/current db)
|
||||||
|
entry (store/entry id)]
|
||||||
|
(if (or (get-in db [:project :busy?]) (nil? (:analysis (:clip entry))))
|
||||||
|
{}
|
||||||
|
(let [plan (regenerate/plan (:clip entry) edit)
|
||||||
|
report (select-keys plan [:features :roles])
|
||||||
|
request (inc (or (:preview-request db) 0))]
|
||||||
|
(js/console.info "arthur regeneration" (clj->js (assoc report :edit edit)))
|
||||||
|
{:db (-> db
|
||||||
|
(assoc :preview-request request)
|
||||||
|
(update :project merge {:status "previewing…"})
|
||||||
|
(assoc :regeneration (assoc report :edit edit)))
|
||||||
|
::preview-settings! {:id id :entry entry :edit edit
|
||||||
|
:request request}})))))
|
||||||
|
|
||||||
|
(rf/reg-sub ::regeneration (fn [db _] (:regeneration db)))
|
||||||
|
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::settings-previewed
|
||||||
|
(fn [{:keys [db]} [_ previous request entry]]
|
||||||
|
(if (and (= previous (:clip/current db))
|
||||||
|
(= request (:preview-request db)))
|
||||||
|
(let [id (store/install! entry "edited")]
|
||||||
|
{:db (-> db
|
||||||
|
(assoc :clip/current id)
|
||||||
|
(update :project merge {:status "preview · unsaved"}))})
|
||||||
|
{})))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
;; events
|
;; events
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,8 @@
|
||||||
"source/dense" []
|
"source/dense" []
|
||||||
"source/detected" []
|
"source/detected" []
|
||||||
"source/crops" []
|
"source/crops" []
|
||||||
|
"source/interior" [:blob-grow :cavity-erode :min-area :teeth-verts
|
||||||
|
:tongue-reject :top-bias]
|
||||||
"head-pos" [:anchor-avg]
|
"head-pos" [:anchor-avg]
|
||||||
"head-rot" [:anchor-avg]
|
"head-rot" [:anchor-avg]
|
||||||
"head-scale" [:anchor-avg]
|
"head-scale" [:anchor-avg]
|
||||||
|
|
|
||||||
|
|
@ -425,6 +425,34 @@
|
||||||
[:xform :pos] (ch/framed [(- (/ w 2) (:x c))
|
[:xform :pos] (ch/framed [(- (/ w 2) (:x c))
|
||||||
(- (* 0.25 h) (:y c))])})))
|
(- (* 0.25 h) (:y c))])})))
|
||||||
|
|
||||||
|
(defn- mouth-part [absent? obs
|
||||||
|
{:keys [analysis verts anchor-avg contour-avg aperture-cut] :as params}
|
||||||
|
{:keys [outer inner] :as inputs}]
|
||||||
|
(let [rings (block {:role "geom" :analysis (:id analysis) :params params
|
||||||
|
:tracks ["outer" "inner"]}
|
||||||
|
{:type "int16" :scale geom-scale
|
||||||
|
:features [:mouth :mouth] :absent? absent?}
|
||||||
|
obs [(rings->flat outer verts) (rings->flat inner verts)])
|
||||||
|
prov (fn [by]
|
||||||
|
{:by by :analysis (:id analysis)
|
||||||
|
:params {:anchor-avg anchor-avg :contour-avg contour-avg
|
||||||
|
:verts verts}})]
|
||||||
|
{:nodes {:mouth
|
||||||
|
{:id :mouth :name "mouth" :kind :poly :parent :head :z "a1"
|
||||||
|
:channels {[:geom :pts] (dense rings 0 (prov :roto/lips-outer))
|
||||||
|
[:style :color] (ch/framed :skin-dark)}}
|
||||||
|
:mouth-in
|
||||||
|
{:id :mouth-in :name "mouth interior" :kind :poly
|
||||||
|
:parent :mouth :z "a2"
|
||||||
|
:channels {[:geom :pts] (dense rings 1 (prov :roto/lips-inner))
|
||||||
|
[:style :color] (ch/framed :mouth-dark)
|
||||||
|
[:vis] (visibility params inputs
|
||||||
|
{:by :roto/mouth-aperture
|
||||||
|
:analysis (:id analysis)
|
||||||
|
:params {:anchor-avg anchor-avg
|
||||||
|
:aperture-cut aperture-cut}})}}}
|
||||||
|
:store (stored rings)}))
|
||||||
|
|
||||||
(defn- feature-parts
|
(defn- feature-parts
|
||||||
"Freeze eyes and brows into their own dense blocks and scene nodes. This owns
|
"Freeze eyes and brows into their own dense blocks and scene nodes. This owns
|
||||||
only representation: the landmark correspondence, blink and pose choices have
|
only representation: the landmark correspondence, blink and pose choices have
|
||||||
|
|
@ -445,20 +473,23 @@
|
||||||
;; feature each one follows, in the same order. The two vectors are read
|
;; feature each one follows, in the same order. The two vectors are read
|
||||||
;; together on purpose: this is the mapping `pack` cannot check for itself,
|
;; together on purpose: this is the mapping `pack` cannot check for itself,
|
||||||
;; and `each-dense-track-follows-its-own-features-presence` is what pins it.
|
;; and `each-dense-track-follows-its-own-features-presence` is what pins it.
|
||||||
eye-block (named "eyes"
|
eye-block (when eyes (named "eyes"
|
||||||
["lash-r" "lid-r" "lash-l" "lid-l"]
|
["lash-r" "lid-r" "lash-l" "lid-l"]
|
||||||
[:eye-r :eye-r :eye-l :eye-l]
|
[:eye-r :eye-r :eye-l :eye-l]
|
||||||
"int16"
|
"int16"
|
||||||
(mapv #(rings->flat % eye-verts)
|
(mapv #(rings->flat % eye-verts)
|
||||||
[(:lash-r eyes) (:lid-r eyes)
|
[(:lash-r eyes) (:lid-r eyes)
|
||||||
(:lash-l eyes) (:lid-l eyes)]))
|
(:lash-l eyes) (:lid-l eyes)])))
|
||||||
iris-block (named "iris-pos" ["iris-r" "iris-l"] [:eye-r :eye-l] "float32"
|
iris-block (when eyes
|
||||||
[(:iris-r eyes) (:iris-l eyes)])
|
(named "iris-pos" ["iris-r" "iris-l"] [:eye-r :eye-l] "float32"
|
||||||
brow-block (named "brows" ["ring-r" "ring-l"] [:brow-r :brow-l] "int16"
|
[(:iris-r eyes) (:iris-l eyes)]))
|
||||||
|
brow-block (when brows
|
||||||
|
(named "brows" ["ring-r" "ring-l"] [:brow-r :brow-l] "int16"
|
||||||
(mapv #(rings->flat % brow-verts)
|
(mapv #(rings->flat % brow-verts)
|
||||||
[(:ring-r brows) (:ring-l brows)]))
|
[(:ring-r brows) (:ring-l brows)])))
|
||||||
brow-pos-block (named "brow-pos" ["pos-r" "pos-l"] [:brow-r :brow-l] "float32"
|
brow-pos-block (when brows
|
||||||
[(:pos-r brows) (:pos-l brows)])
|
(named "brow-pos" ["pos-r" "pos-l"] [:brow-r :brow-l] "float32"
|
||||||
|
[(:pos-r brows) (:pos-l brows)]))
|
||||||
eye-node (fn [id z track]
|
eye-node (fn [id z track]
|
||||||
{:id id :name (clojure.core/name id) :kind :poly :parent :head :z z
|
{:id id :name (clojure.core/name id) :kind :poly :parent :head :z z
|
||||||
:channels {[:geom :pts] (dense eye-block track
|
:channels {[:geom :pts] (dense eye-block track
|
||||||
|
|
@ -477,12 +508,18 @@
|
||||||
:stencil parent
|
:stencil parent
|
||||||
:channels {[:xform :pos] (dense iris-block track
|
:channels {[:xform :pos] (dense iris-block track
|
||||||
(provenance :roto/gaze nil))
|
(provenance :roto/gaze nil))
|
||||||
[:geom :radius] (ch/framed radius)
|
[:geom :radius] (assoc (ch/framed radius)
|
||||||
|
:generated
|
||||||
|
(provenance :roto/iris-size
|
||||||
|
{:iris-size (:iris-size params)}))
|
||||||
[:style :color] (ch/framed :iris)}})
|
[:style :color] (ch/framed :iris)}})
|
||||||
pupil-node (fn [id parent]
|
pupil-node (fn [id parent]
|
||||||
{:id id :name (clojure.core/name id) :kind :rect :parent parent :z "a1"
|
{:id id :name (clojure.core/name id) :kind :rect :parent parent :z "a1"
|
||||||
:stencil parent
|
:stencil parent
|
||||||
:channels {[:geom :size] (ch/framed (:pupil-size eyes))
|
:channels {[:geom :size] (assoc (ch/framed (:pupil-size eyes))
|
||||||
|
:generated
|
||||||
|
(provenance :roto/pupil-size
|
||||||
|
{:pupil-size (:pupil-size params)}))
|
||||||
[:style :color] (ch/framed :pupil)}})
|
[:style :color] (ch/framed :pupil)}})
|
||||||
brow-node (fn [id z track]
|
brow-node (fn [id z track]
|
||||||
{:id id :name (clojure.core/name id) :kind :poly :parent :head :z z
|
{:id id :name (clojure.core/name id) :kind :poly :parent :head :z z
|
||||||
|
|
@ -491,17 +528,21 @@
|
||||||
[:xform :pos] (dense brow-pos-block track
|
[:xform :pos] (dense brow-pos-block track
|
||||||
(provenance :roto/brow-raise nil))
|
(provenance :roto/brow-raise nil))
|
||||||
[:style :color] (ch/framed :brow)}})]
|
[:style :color] (ch/framed :brow)}})]
|
||||||
{:nodes {:eye-r (eye-node :eye-r "a2" 0)
|
{:nodes (merge
|
||||||
|
(when eyes
|
||||||
|
{:eye-r (eye-node :eye-r "a2" 0)
|
||||||
:eye-r-in (inner-node :eye-r-in :eye-r "a1" 1 (:shut-r eyes))
|
:eye-r-in (inner-node :eye-r-in :eye-r "a1" 1 (:shut-r eyes))
|
||||||
:iris-r (iris-node :iris-r :eye-r-in 0 (:radius-r eyes))
|
:iris-r (iris-node :iris-r :eye-r-in 0 (:radius-r eyes))
|
||||||
:pupil-r (pupil-node :pupil-r :iris-r)
|
:pupil-r (pupil-node :pupil-r :iris-r)
|
||||||
:eye-l (eye-node :eye-l "a3" 2)
|
:eye-l (eye-node :eye-l "a3" 2)
|
||||||
:eye-l-in (inner-node :eye-l-in :eye-l "a1" 3 (:shut-l eyes))
|
:eye-l-in (inner-node :eye-l-in :eye-l "a1" 3 (:shut-l eyes))
|
||||||
:iris-l (iris-node :iris-l :eye-l-in 1 (:radius-l eyes))
|
:iris-l (iris-node :iris-l :eye-l-in 1 (:radius-l eyes))
|
||||||
:pupil-l (pupil-node :pupil-l :iris-l)
|
:pupil-l (pupil-node :pupil-l :iris-l)})
|
||||||
:brow-r (brow-node :brow-r "a4" 0)
|
(when brows
|
||||||
:brow-l (brow-node :brow-l "a5" 1)}
|
{:brow-r (brow-node :brow-r "a4" 0)
|
||||||
:store (stored eye-block iris-block brow-block brow-pos-block)}))
|
:brow-l (brow-node :brow-l "a5" 1)}))
|
||||||
|
:store (apply stored (remove nil?
|
||||||
|
[eye-block iris-block brow-block brow-pos-block]))}))
|
||||||
|
|
||||||
(defn- interior-part
|
(defn- interior-part
|
||||||
"Freeze the pixel-derived radial contour under the mouth cavity. Missing
|
"Freeze the pixel-derived radial contour under the mouth cavity. Missing
|
||||||
|
|
@ -536,6 +577,44 @@
|
||||||
[:vis] (keyed-visibility shown generated)}}}
|
[:vis] (keyed-visibility shown generated)}}}
|
||||||
:store (stored blk)}))
|
:store (stored blk)}))
|
||||||
|
|
||||||
|
(defn part
|
||||||
|
"A feature type's nodes and addressed blocks, with no clip scaffold."
|
||||||
|
[area params {:keys [detected presence] :as measured}]
|
||||||
|
(let [absent? (when (or detected presence)
|
||||||
|
(fn [id f]
|
||||||
|
(or (and detected (not (nth detected f true)))
|
||||||
|
(and (contains? presence id)
|
||||||
|
(not (nth (get presence id) f))))))
|
||||||
|
obs {:detected detected :presence presence}]
|
||||||
|
(case area
|
||||||
|
:mouth (mouth-part absent? obs params measured)
|
||||||
|
:eye (feature-parts absent? obs params (select-keys measured [:eyes]))
|
||||||
|
:brow (feature-parts absent? obs params (select-keys measured [:brows]))
|
||||||
|
:teeth (interior-part params absent? obs (:teeth measured))
|
||||||
|
(throw (ex-info "unknown frozen feature type" {:area area})))))
|
||||||
|
|
||||||
|
(defn head-part
|
||||||
|
"Freeze only the measured head transform from an already conditioned anchor."
|
||||||
|
[{:keys [analysis anchor-avg] :as params} {:keys [transforms detected presence]}]
|
||||||
|
(let [absent? (when (or detected presence)
|
||||||
|
(fn [_ f] (and detected (not (nth detected f true)))))
|
||||||
|
inv (mapv invert transforms)
|
||||||
|
xf (fn [role f]
|
||||||
|
(block {:role role :analysis (:id analysis) :params params
|
||||||
|
:tracks [role]}
|
||||||
|
{:type "float32" :features [nil] :absent? absent?}
|
||||||
|
{:detected detected}
|
||||||
|
[(mapv f inv)]))
|
||||||
|
pos (xf "head-pos" (fn [t] [(:tx t) (:ty t)]))
|
||||||
|
rot (xf "head-rot" (fn [t] [(:theta t)]))
|
||||||
|
scale (xf "head-scale" (fn [t] [(:s t) (:s t)]))
|
||||||
|
prov {:by :anchor/similarity :analysis (:id analysis)
|
||||||
|
:params {:anchor-avg anchor-avg}}]
|
||||||
|
{:measured {[:xform :pos] (dense pos 0 prov)
|
||||||
|
[:xform :rot] (dense rot 0 prov)
|
||||||
|
[:xform :scale] (dense scale 0 prov)}
|
||||||
|
:store (stored pos rot scale)}))
|
||||||
|
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
;; the clip
|
;; the clip
|
||||||
|
|
||||||
|
|
@ -624,12 +703,7 @@
|
||||||
prov (fn [by extra]
|
prov (fn [by extra]
|
||||||
{:by by :analysis (:id analysis)
|
{:by by :analysis (:id analysis)
|
||||||
:params (merge {:anchor-avg anchor-avg} extra)})
|
:params (merge {:anchor-avg anchor-avg} extra)})
|
||||||
rings (block {:role "geom" :analysis (:id analysis) :params params
|
mouth (mouth-part absent? obs params inputs)
|
||||||
:tracks ["outer" "inner"]}
|
|
||||||
{:type "int16" :scale geom-scale
|
|
||||||
:features [:mouth :mouth] :absent? absent?}
|
|
||||||
obs
|
|
||||||
[(rings->flat outer verts) (rings->flat inner verts)])
|
|
||||||
;; The anchor, inverted and split into its three components. Three blocks
|
;; The anchor, inverted and split into its three components. Three blocks
|
||||||
;; and not one: they are three channels, they have three strides, and a
|
;; and not one: they are three channels, they have three strides, and a
|
||||||
;; single block would need a per-component offset table to say so.
|
;; single block would need a per-component offset table to say so.
|
||||||
|
|
@ -653,7 +727,6 @@
|
||||||
;; `anchor avg` and on its own cut and NOT on `contour avg`, because
|
;; `anchor avg` and on its own cut and NOT on `contour avg`, because
|
||||||
;; measure reports the inner ring's own height and nothing smooths it.
|
;; measure reports the inner ring's own height and nothing smooths it.
|
||||||
anchor-prov (prov :anchor/similarity nil)
|
anchor-prov (prov :anchor/similarity nil)
|
||||||
roto (fn [by] (prov by {:verts verts :contour-avg contour-avg}))
|
|
||||||
features (when (and eyes brows) (feature-parts absent? obs params inputs))
|
features (when (and eyes brows) (feature-parts absent? obs params inputs))
|
||||||
interior (when teeth (interior-part params absent? obs teeth))
|
interior (when teeth (interior-part params absent? obs teeth))
|
||||||
built {:name name
|
built {:name name
|
||||||
|
|
@ -710,31 +783,13 @@
|
||||||
{:id :head :name "head" :kind :group :parent :face :z "a1"
|
{:id :head :name "head" :kind :group :parent :face :z "a1"
|
||||||
:measured {[:xform :pos] (dense pos 0 anchor-prov)
|
:measured {[:xform :pos] (dense pos 0 anchor-prov)
|
||||||
[:xform :rot] (dense rot 0 anchor-prov)
|
[:xform :rot] (dense rot 0 anchor-prov)
|
||||||
[:xform :scale] (dense scale 0 anchor-prov)}}
|
[:xform :scale] (dense scale 0 anchor-prov)}}}
|
||||||
|
(:nodes mouth) (:nodes features) (:nodes interior))}}}
|
||||||
;; The outer lip ring is the dark band OUTSIDE the interior, and
|
|
||||||
;; that three-layer structure — dark ring, pale interior, teeth
|
|
||||||
;; — is what makes a flat shape read as an opening rather than
|
|
||||||
;; as a blob. So it keeps every frame and is never hidden.
|
|
||||||
:mouth
|
|
||||||
{:id :mouth :name "mouth" :kind :poly :parent :head :z "a1"
|
|
||||||
:channels {[:geom :pts] (dense rings 0 (roto :roto/lips-outer))
|
|
||||||
[:style :color] (ch/framed :skin-dark)}}
|
|
||||||
|
|
||||||
:mouth-in
|
|
||||||
{:id :mouth-in :name "mouth interior" :kind :poly
|
|
||||||
:parent :mouth :z "a2"
|
|
||||||
:channels {[:geom :pts] (dense rings 1 (roto :roto/lips-inner))
|
|
||||||
[:style :color] (ch/framed :mouth-dark)
|
|
||||||
[:vis] (visibility params inputs
|
|
||||||
(prov :roto/mouth-aperture
|
|
||||||
{:aperture-cut aperture-cut}))}}}
|
|
||||||
(:nodes features) (:nodes interior))}}}
|
|
||||||
;; Tier 2, behind a handle, and now behind a content address: every key is
|
;; Tier 2, behind a handle, and now behind a content address: every key is
|
||||||
;; a sha256 over the analysis, the settings and the absence data that
|
;; a sha256 over the analysis, the settings and the absence data that
|
||||||
;; produced the bytes under it. Nothing above this line changed when they
|
;; produced the bytes under it. Nothing above this line changed when they
|
||||||
;; stopped being "take/geom", which is the point of a handle.
|
;; stopped being "take/geom", which is the point of a handle.
|
||||||
store (merge (stored rings pos rot scale)
|
store (merge (stored pos rot scale) (:store mouth)
|
||||||
(:store features) (:store interior))]
|
(:store features) (:store interior))]
|
||||||
(doseq [id (keys presence)]
|
(doseq [id (keys presence)]
|
||||||
(when-not (contains? (:features built) id)
|
(when-not (contains? (:features built) id)
|
||||||
|
|
|
||||||
125
frontend/src/arthur/flow/regenerate.cljs
Normal file
125
frontend/src/arthur/flow/regenerate.cljs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
(ns arthur.flow.regenerate
|
||||||
|
"Recompute a changed feature from retained source tracks, then replace only
|
||||||
|
channels owned by that feature. Upload remains project/save's ordinary job."
|
||||||
|
(:require [arthur.domain.clip :as clip]
|
||||||
|
[arthur.domain.feature :as feature]
|
||||||
|
[arthur.domain.params :as params]
|
||||||
|
[arthur.flow.address :as address]
|
||||||
|
[arthur.flow.freeze :as freeze]
|
||||||
|
[arthur.flow.take :as take]))
|
||||||
|
|
||||||
|
(defn- settings [clip fid]
|
||||||
|
(let [f (get-in clip [:features fid])
|
||||||
|
mouth (first (for [[id peer] (:features clip)
|
||||||
|
:when (and (= :mouth (:area peer))
|
||||||
|
(= (:subject f) (:subject peer)))] id))]
|
||||||
|
(cond-> (feature/effective-params clip fid)
|
||||||
|
(and (= :teeth (:area f)) mouth)
|
||||||
|
(assoc :aperture-cut (:aperture-cut (feature/effective-params clip mouth))))))
|
||||||
|
|
||||||
|
(defn- replace-feature [entry fragment fid]
|
||||||
|
(let [paths (for [id (get-in entry [:clip :features fid :nodes])
|
||||||
|
[prop channel] (get-in fragment [:nodes id :channels])
|
||||||
|
:when (:generated channel)]
|
||||||
|
[id prop channel])]
|
||||||
|
(-> (reduce (fn [entry [id prop channel]]
|
||||||
|
(let [at [:clip :timelines clip/root-id :nodes id :channels prop]
|
||||||
|
old (get-in entry at)]
|
||||||
|
(assoc-in entry at
|
||||||
|
(cond-> channel
|
||||||
|
(contains? old :over) (assoc :over (:over old))))))
|
||||||
|
entry paths)
|
||||||
|
(update :store merge (:store fragment)))))
|
||||||
|
|
||||||
|
(defn plan
|
||||||
|
"The changed document and feature IDs an edit dirties. Also reports tier-2
|
||||||
|
block roles from the existing address table for the debug UI."
|
||||||
|
[clip {:keys [scope id knob value]}]
|
||||||
|
(let [area (get-in params/definitions [knob :area])
|
||||||
|
collection (case scope
|
||||||
|
:subject :subjects :feature :features :group :groups
|
||||||
|
(throw (ex-info "unknown setting scope" {:scope scope})))
|
||||||
|
owner (get-in clip [collection id])]
|
||||||
|
(when-not (and owner (params/valid-value? knob value)
|
||||||
|
(case scope
|
||||||
|
:subject (= area :subject)
|
||||||
|
:feature (= area (:area owner))
|
||||||
|
:group (and (= area :eye) (= :eye-pair (:kind owner)))))
|
||||||
|
(throw (ex-info "invalid scoped setting" {:scope scope :id id
|
||||||
|
:knob knob :value value})))
|
||||||
|
(let [changed (assoc-in clip [collection id :params knob] value)
|
||||||
|
subject (if (= scope :subject) id (:subject owner))
|
||||||
|
members (case scope
|
||||||
|
:subject (for [[fid f] (:features clip)
|
||||||
|
:when (= subject (:subject f))] fid)
|
||||||
|
:feature [id]
|
||||||
|
:group (:members owner))
|
||||||
|
members (if (= knob :contour-avg)
|
||||||
|
(remove #(= :teeth (get-in clip [:features % :area])) members)
|
||||||
|
members)
|
||||||
|
members (if (= knob :aperture-cut)
|
||||||
|
(concat members (for [[fid f] (:features clip)
|
||||||
|
:when (and (= subject (:subject f))
|
||||||
|
(= :teeth (:area f)))] fid))
|
||||||
|
members)]
|
||||||
|
{:changed changed
|
||||||
|
:features (vec (filter #(not= (settings clip %) (settings changed %))
|
||||||
|
(distinct members)))
|
||||||
|
:roles (address/invalidates knob)})))
|
||||||
|
|
||||||
|
(defn- change-take
|
||||||
|
"One scoped static edit. `source-inputs` contains dense landmarks and, when
|
||||||
|
teeth are dirty, retained pixel measurements. No IO or app-db here."
|
||||||
|
[{:keys [clip source-inputs] :as entry} edit]
|
||||||
|
(let [{changed :changed fids :features} (plan clip edit)]
|
||||||
|
(when-not (:dense source-inputs)
|
||||||
|
(throw (ex-info "regeneration needs retained source landmarks" {})))
|
||||||
|
(if (empty? fids)
|
||||||
|
(assoc entry :clip changed)
|
||||||
|
(let [base-params (merge take/knobs
|
||||||
|
{:fps (:fps clip)
|
||||||
|
:aspect (get-in clip [:analysis :aspect])
|
||||||
|
:analysis (:analysis clip)})
|
||||||
|
base (delay (take/anchor-base
|
||||||
|
(merge base-params (settings changed (first fids)))
|
||||||
|
source-inputs))
|
||||||
|
entry (reduce
|
||||||
|
(fn [entry fid]
|
||||||
|
(let [part-area (get-in changed [:features fid :area])
|
||||||
|
p (merge base-params (settings changed fid))
|
||||||
|
_ (when (and (= part-area :teeth)
|
||||||
|
(nil? (:interior source-inputs)))
|
||||||
|
(throw (ex-info "teeth regeneration needs retained pixel measurements"
|
||||||
|
{:feature fid})))
|
||||||
|
measured (take/measure-part part-area p source-inputs @base)]
|
||||||
|
(replace-feature entry (freeze/part part-area p measured) fid)))
|
||||||
|
(assoc entry :clip changed) fids)]
|
||||||
|
(if (= :anchor-avg (:knob edit))
|
||||||
|
(let [p (merge base-params
|
||||||
|
(settings changed (first fids)))
|
||||||
|
baked (freeze/head-part p @base)
|
||||||
|
old-head (get-in clip [:timelines :main :nodes :head])
|
||||||
|
measured (:measured baked)]
|
||||||
|
(cond-> (-> entry
|
||||||
|
(assoc-in [:clip :timelines :main :nodes :head :measured] measured)
|
||||||
|
(update :store merge (:store baked)))
|
||||||
|
(= (:channels old-head) (:measured old-head))
|
||||||
|
(assoc-in [:clip :timelines :main :nodes :head :channels] measured)))
|
||||||
|
entry)))))
|
||||||
|
|
||||||
|
(defn change
|
||||||
|
"A take edits its root timeline. A stage edits the shared symbol timeline;
|
||||||
|
every instance then reads the same new channels on its next paint."
|
||||||
|
[{:keys [clip] :as entry} edit]
|
||||||
|
(if-let [symbol (some :timeline (vals (:features clip)))]
|
||||||
|
(let [original (:timelines clip)
|
||||||
|
take (assoc clip :timelines
|
||||||
|
{:main (assoc (get original symbol) :id :main)})
|
||||||
|
changed (change-take (assoc entry :clip take) edit)]
|
||||||
|
(assoc changed :clip
|
||||||
|
(-> (:clip changed)
|
||||||
|
(assoc :timelines
|
||||||
|
(assoc original symbol
|
||||||
|
(assoc (get-in changed [:clip :timelines :main])
|
||||||
|
:id symbol))))))
|
||||||
|
(change-take entry edit)))
|
||||||
|
|
@ -1,14 +1,76 @@
|
||||||
(ns arthur.flow.source
|
(ns arthur.flow.source
|
||||||
"The pixel-dependent result of analyzing footage, stored as three ordinary
|
"Retained analysis: three raw source blocks and an optional addressed interior
|
||||||
content-addressed blocks. Everything after this boundary can run without PNGs
|
measurement block. Everything after this boundary can run without PNGs or
|
||||||
or MediaPipe. The mouth crops hold raw pixels and an un-eroded lip ring."
|
MediaPipe. Mouth crops retain pixels for settings that change the measurement."
|
||||||
(:require [arthur.domain.landmarks :as lm]
|
(:require [arthur.domain.landmarks :as lm]
|
||||||
|
[arthur.domain.params :as params]
|
||||||
[arthur.domain.wire :as wire]
|
[arthur.domain.wire :as wire]
|
||||||
[arthur.flow.address :as address]
|
[arthur.flow.address :as address]
|
||||||
[arthur.flow.measure.interior :as interior]))
|
[arthur.flow.measure.interior :as interior]))
|
||||||
|
|
||||||
(def roles ["source/dense" "source/detected" "source/crops"])
|
(def roles ["source/dense" "source/detected" "source/crops"])
|
||||||
|
|
||||||
|
(defn- interior-address [analysis settings frames]
|
||||||
|
(let [verts (:teeth-verts settings)
|
||||||
|
stride (+ 2 (* 2 verts))]
|
||||||
|
(address/block {:role "source/interior" :analysis analysis
|
||||||
|
:params settings
|
||||||
|
:tracks ["contrast" "area" "contour"]
|
||||||
|
:features [] :observation nil
|
||||||
|
:layout {:type "float64" :frames frames :verts verts
|
||||||
|
:stride stride}})))
|
||||||
|
|
||||||
|
(defn interior-key [analysis settings frames]
|
||||||
|
(:key (interior-address analysis settings frames)))
|
||||||
|
|
||||||
|
(defn interior-block
|
||||||
|
"Retain pixel measurements before any head-local transform or smoothing."
|
||||||
|
[analysis settings measures]
|
||||||
|
(let [frames (count measures)
|
||||||
|
{:keys [key descriptor]}
|
||||||
|
(interior-address analysis settings frames)
|
||||||
|
verts (:teeth-verts settings)
|
||||||
|
stride (+ 2 (* 2 verts))
|
||||||
|
data (js/Float64Array. (* frames stride))]
|
||||||
|
(doseq [f (range frames)]
|
||||||
|
(let [{:keys [contrast area contour]} (nth measures f)
|
||||||
|
at (* f stride)]
|
||||||
|
(aset data at (or contrast 0))
|
||||||
|
(aset data (inc at) (or area 0))
|
||||||
|
(if contour
|
||||||
|
(do
|
||||||
|
(when-not (= verts (count contour))
|
||||||
|
(throw (ex-info "interior contour vertex count disagrees with settings"
|
||||||
|
{:frame f :expected verts :actual (count contour)})))
|
||||||
|
(doseq [i (range verts)]
|
||||||
|
(aset data (+ at 2 (* 2 i)) (:x (nth contour i)))
|
||||||
|
(aset data (+ at 3 (* 2 i)) (:y (nth contour i)))))
|
||||||
|
(aset data (+ at 2) js/NaN))))
|
||||||
|
{:role "source/interior" :key key :descriptor descriptor :data data}))
|
||||||
|
|
||||||
|
(defn unpack-interior [^js response settings frames]
|
||||||
|
(let [descriptor (js->clj (js/JSON.parse (.-descriptor response))
|
||||||
|
:keywordize-keys true)
|
||||||
|
layout (:layout descriptor)
|
||||||
|
data (wire/typed (:type layout) (.-data response))
|
||||||
|
verts (:teeth-verts settings)
|
||||||
|
stride (+ 2 (* 2 verts))]
|
||||||
|
(when-not (and (= "source/interior" (:role descriptor))
|
||||||
|
(= frames (:frames layout)) (= verts (:verts layout))
|
||||||
|
(= stride (:stride layout))
|
||||||
|
(= (* frames stride) (.-length data)))
|
||||||
|
(throw (ex-info "saved interior block has the wrong layout"
|
||||||
|
{:layout layout :frames frames :verts verts})))
|
||||||
|
(mapv (fn [f]
|
||||||
|
(let [at (* f stride)]
|
||||||
|
{:contrast (aget data at) :area (aget data (inc at))
|
||||||
|
:contour (when-not (js/Number.isNaN (aget data (+ at 2)))
|
||||||
|
(mapv (fn [i]
|
||||||
|
{:x (aget data (+ at 2 (* 2 i)))
|
||||||
|
:y (aget data (+ at 3 (* 2 i)))})
|
||||||
|
(range verts)))}))
|
||||||
|
(range frames))))
|
||||||
|
|
||||||
(defn measure-crop
|
(defn measure-crop
|
||||||
"One mouth crop's interior, or the empty measurement for a frame with no face.
|
"One mouth crop's interior, or the empty measurement for a frame with no face.
|
||||||
|
|
||||||
|
|
@ -21,9 +83,6 @@
|
||||||
(interior/measure params (:box crop) #js {:data (:data crop)})
|
(interior/measure params (:box crop) #js {:data (:data crop)})
|
||||||
{:contour nil :contrast 0 :area 0 :debug nil}))
|
{:contour nil :contrast 0 :area 0 :debug nil}))
|
||||||
|
|
||||||
(defn measure-crops [params crops]
|
|
||||||
(mapv #(measure-crop params %) crops))
|
|
||||||
|
|
||||||
(defn- named [role analysis tracks layout data]
|
(defn- named [role analysis tracks layout data]
|
||||||
(merge (address/block {:role role :analysis analysis :params {}
|
(merge (address/block {:role role :analysis analysis :params {}
|
||||||
:tracks tracks :features [] :observation nil
|
:tracks tracks :features [] :observation nil
|
||||||
|
|
@ -31,12 +90,13 @@
|
||||||
{:role role :data data}))
|
{:role role :data data}))
|
||||||
|
|
||||||
(defn pack
|
(defn pack
|
||||||
"Dense landmarks, detection mask and raw RGBA mouth crops -> source blocks."
|
"Dense landmarks, mask, crops and optional pixel measurements -> source blocks."
|
||||||
[analysis {:keys [dense detected crops]}]
|
[analysis {:keys [dense detected crops interior]}]
|
||||||
(let [frames (count dense)
|
(let [frames (count dense)
|
||||||
points (count (first dense))]
|
points (count (first dense))]
|
||||||
(when-not (and (pos? frames) (pos? points)
|
(when-not (and (pos? frames) (pos? points)
|
||||||
(= frames (count detected) (count crops))
|
(= frames (count detected) (count crops))
|
||||||
|
(or (nil? interior) (= frames (count interior)))
|
||||||
(every? #(= points (count %)) dense))
|
(every? #(= points (count %)) dense))
|
||||||
(throw (ex-info "source tracks must have the same frame and landmark counts"
|
(throw (ex-info "source tracks must have the same frame and landmark counts"
|
||||||
{:frames frames :points points})))
|
{:frames frames :points points})))
|
||||||
|
|
@ -59,14 +119,16 @@
|
||||||
(aset landmarks (+ base 2) z)))
|
(aset landmarks (+ base 2) z)))
|
||||||
(when-let [crop (nth crops f)]
|
(when-let [crop (nth crops f)]
|
||||||
(.set pixels (:data crop) (nth offsets f))))
|
(.set pixels (:data crop) (nth offsets f))))
|
||||||
{"source/dense" (named "source/dense" analysis ["landmarks"]
|
(cond-> {"source/dense" (named "source/dense" analysis ["landmarks"]
|
||||||
{:type "float64" :frames frames :points points :stride (* 3 points)}
|
{:type "float64" :frames frames :points points :stride (* 3 points)}
|
||||||
landmarks)
|
landmarks)
|
||||||
"source/detected" (named "source/detected" analysis ["detected"]
|
"source/detected" (named "source/detected" analysis ["detected"]
|
||||||
{:type "uint8" :frames frames :stride 1} mask)
|
{:type "uint8" :frames frames :stride 1} mask)
|
||||||
"source/crops" (named "source/crops" analysis ["rgba"]
|
"source/crops" (named "source/crops" analysis ["rgba"]
|
||||||
{:type "uint8" :frames frames :boxes boxes :offsets offsets}
|
{:type "uint8" :frames frames :boxes boxes :offsets offsets}
|
||||||
pixels)})))
|
pixels)}
|
||||||
|
interior (assoc "source/interior"
|
||||||
|
(interior-block analysis params/defaults interior))))))
|
||||||
|
|
||||||
(defn wire-blocks [blocks]
|
(defn wire-blocks [blocks]
|
||||||
(into-array
|
(into-array
|
||||||
|
|
@ -80,7 +142,7 @@
|
||||||
(map (fn [role]
|
(map (fn [role]
|
||||||
(let [{:keys [key descriptor data]} (get blocks role)]
|
(let [{:keys [key descriptor data]} (get blocks role)]
|
||||||
#js {:key key :descriptor descriptor :data data}))
|
#js {:key key :descriptor descriptor :data data}))
|
||||||
roles)))
|
(filter #(contains? blocks %) (conj roles "source/interior")))))
|
||||||
|
|
||||||
(defn unpack
|
(defn unpack
|
||||||
"The three block-detail responses -> inputs for measurement and freeze."
|
"The three block-detail responses -> inputs for measurement and freeze."
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,44 @@
|
||||||
:detected detected
|
:detected detected
|
||||||
:presence presence)))
|
:presence presence)))
|
||||||
|
|
||||||
|
(defn anchor-base
|
||||||
|
"Shared upstream measurement for a feature regeneration."
|
||||||
|
[{:keys [aspect] :as params} {:keys [dense detected presence]}]
|
||||||
|
(let [fitted (anchor/fit {:aspect aspect} {:dense dense})]
|
||||||
|
(assoc (condition/anchor params fitted)
|
||||||
|
:detected detected :presence presence)))
|
||||||
|
|
||||||
|
(defn measure-part
|
||||||
|
"Recompute one feature type through the shared conditioned anchor. A brow
|
||||||
|
reads the eye corners, and teeth read mouth aperture; these are input edges,
|
||||||
|
not requests to freeze those other features."
|
||||||
|
[area {:keys [aspect] :as params}
|
||||||
|
{:keys [dense detected interior presence]} {:keys [transforms] :as base}]
|
||||||
|
(let [mouth (fn []
|
||||||
|
(mouth/measure {:aspect aspect}
|
||||||
|
{:dense dense :transforms transforms}))
|
||||||
|
eyes (fn []
|
||||||
|
(eyes/measure params {:dense dense :transforms transforms
|
||||||
|
:detected detected :presence presence}))]
|
||||||
|
(merge (select-keys base [:detected :presence])
|
||||||
|
(case area
|
||||||
|
:mouth (let [m (mouth)]
|
||||||
|
{:outer (condition/contours params (:outer m))
|
||||||
|
:inner (condition/contours params (:inner m))
|
||||||
|
:aperture (:aperture m)})
|
||||||
|
:eye {:eyes (condition-eyes/apply-defaults params (eyes))}
|
||||||
|
:brow {:brows (condition-brows/apply-defaults
|
||||||
|
params
|
||||||
|
(brows/measure params {:dense dense :transforms transforms})
|
||||||
|
(eyes))}
|
||||||
|
:teeth (let [aperture (:aperture (mouth))]
|
||||||
|
{:teeth (condition-interior/apply-defaults
|
||||||
|
params
|
||||||
|
(mapv interior/head-local interior transforms
|
||||||
|
(repeat aspect))
|
||||||
|
aperture)})
|
||||||
|
(throw (ex-info "unknown measured feature type" {:area area}))))))
|
||||||
|
|
||||||
(defn build
|
(defn build
|
||||||
[params inputs]
|
[params inputs]
|
||||||
(freeze/clip params (measure params inputs)))
|
(freeze/clip params (measure params inputs)))
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@
|
||||||
([entry kind]
|
([entry kind]
|
||||||
(let [id (keyword kind (str (swap! serial inc)))]
|
(let [id (keyword kind (str (swap! serial inc)))]
|
||||||
(when-let [old (:audio @loaded)]
|
(when-let [old (:audio @loaded)]
|
||||||
(when (.startsWith old "blob:") (js/URL.revokeObjectURL old)))
|
(when (and (not= old (:audio entry)) (.startsWith old "blob:"))
|
||||||
|
(js/URL.revokeObjectURL old)))
|
||||||
(reset! loaded (assoc entry :id id))
|
(reset! loaded (assoc entry :id id))
|
||||||
id)))
|
id)))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,103 @@
|
||||||
why scrubbing at speed does not re-render the page."
|
why scrubbing at speed does not re-render the page."
|
||||||
(:require [arthur.clock :as clock]
|
(:require [arthur.clock :as clock]
|
||||||
[arthur.db :as db]
|
[arthur.db :as db]
|
||||||
|
[arthur.domain.feature :as feature]
|
||||||
|
[arthur.domain.params :as params]
|
||||||
[arthur.events.footage :as footage]
|
[arthur.events.footage :as footage]
|
||||||
[arthur.events.playback :as pb]
|
[arthur.events.playback :as pb]
|
||||||
[arthur.events.project :as project]
|
[arthur.events.project :as project]
|
||||||
[arthur.subs.playback :as sub]
|
[arthur.subs.playback :as sub]
|
||||||
[arthur.subs.render :as render]
|
[arthur.subs.render :as render]
|
||||||
[arthur.ui.player :as player]
|
[arthur.ui.player :as player]
|
||||||
[re-frame.core :as rf]))
|
[re-frame.core :as rf]
|
||||||
|
[reagent.core :as r]))
|
||||||
|
|
||||||
(def ^:private zoom 2)
|
(def ^:private zoom 2)
|
||||||
|
(defonce ^:private selected-owner (r/atom nil))
|
||||||
|
(defonce ^:private drafts (r/atom {}))
|
||||||
|
|
||||||
|
(defn- setting-owners [clip]
|
||||||
|
(let [instances (for [[id node] (get-in clip [:timelines :main :nodes])
|
||||||
|
:when (= :symbol (:kind node))] id)
|
||||||
|
instances (if (seq instances) (sort-by str instances) [nil])]
|
||||||
|
(vec (for [instance instances
|
||||||
|
[scope ids] [[:subject (:subjects clip)]
|
||||||
|
[:feature (:features clip)]
|
||||||
|
[:group (:groups clip)]]
|
||||||
|
id (sort-by str (keys ids))]
|
||||||
|
[scope id instance]))))
|
||||||
|
|
||||||
|
(defn- slider-bounds [knob {:keys [type default min even?] upper :max}]
|
||||||
|
{:min (or min (if (= knob :blob-grow) -3 0))
|
||||||
|
:max (or upper (get {:verts 20 :eye-verts 16 :brow-verts 10
|
||||||
|
:teeth-verts 20 :blob-grow 3 :top-bias 2
|
||||||
|
:gaze-gain 4} knob)
|
||||||
|
(max 1 (* 2 default)))
|
||||||
|
:step (cond even? 2 (= type :integer) 1 :else 0.01)})
|
||||||
|
|
||||||
|
(defn- controls []
|
||||||
|
(let [clip @(rf/subscribe [::render/clip])
|
||||||
|
busy? (:busy? @(rf/subscribe [::sub/project]))
|
||||||
|
report @(rf/subscribe [::project/regeneration])
|
||||||
|
owners (setting-owners clip)
|
||||||
|
[scope id instance :as owner] (if (some #{(deref selected-owner)} owners)
|
||||||
|
@selected-owner (first owners))
|
||||||
|
area (case scope
|
||||||
|
:subject :subject
|
||||||
|
:feature (get-in clip [:features id :area])
|
||||||
|
:group :eye
|
||||||
|
nil)
|
||||||
|
values (case scope
|
||||||
|
:subject (merge (params/for-area :subject)
|
||||||
|
(get-in clip [:subjects id :params]))
|
||||||
|
:feature (when id (feature/effective-params clip id))
|
||||||
|
:group (merge (params/for-area :eye)
|
||||||
|
(get-in clip [:groups id :params]))
|
||||||
|
nil)]
|
||||||
|
[:section.controls
|
||||||
|
[:label "active object "
|
||||||
|
[:select {:value (or (first (keep-indexed
|
||||||
|
(fn [i option] (when (= option owner) i)) owners)) 0)
|
||||||
|
:on-change #(reset! selected-owner
|
||||||
|
(nth owners (js/parseInt (.. % -target -value) 10)))}
|
||||||
|
(if (seq owners)
|
||||||
|
(doall (for [[i [kind object-id]] (map-indexed vector owners)]
|
||||||
|
^{:key i} [:option {:value i}
|
||||||
|
(str (when-let [instance (nth (nth owners i) 2)]
|
||||||
|
(str (name instance) " / "))
|
||||||
|
(name kind) " · " (name object-id))]))
|
||||||
|
[:option {:value 0} "no tracked objects"])] ]
|
||||||
|
(when instance
|
||||||
|
[:div.shared-note "shared symbol · changes all seven stage instances"])
|
||||||
|
(when area
|
||||||
|
[:div.control-list
|
||||||
|
(doall
|
||||||
|
(for [[knob spec] (sort-by (comp str key) params/definitions)
|
||||||
|
:when (= area (:area spec))]
|
||||||
|
(let [draft-key [scope id knob]
|
||||||
|
value (get @drafts draft-key (get values knob))
|
||||||
|
{:keys [min max step]} (slider-bounds knob spec)]
|
||||||
|
^{:key (str draft-key)}
|
||||||
|
[:label.control-row
|
||||||
|
[:span (name knob)]
|
||||||
|
[:input {:type "range" :min min :max max :step step :value value
|
||||||
|
:disabled busy?
|
||||||
|
:on-change (fn [event]
|
||||||
|
(let [s (.. event -target -value)
|
||||||
|
v (if (= :integer (:type spec))
|
||||||
|
(js/parseInt s 10)
|
||||||
|
(js/parseFloat s))]
|
||||||
|
(swap! drafts assoc draft-key v)
|
||||||
|
(rf/dispatch [::project/preview-settings
|
||||||
|
{:scope scope :id id :knob knob
|
||||||
|
:value v}]))) }]
|
||||||
|
[:output (str value)]])))])
|
||||||
|
(when report
|
||||||
|
[:pre.regeneration-debug
|
||||||
|
(str "dirty features: " (pr-str (:features report)) "\n"
|
||||||
|
"new block roles: " (if (seq (:roles report))
|
||||||
|
(pr-str (sort (:roles report))) "none (tier 1 only)")
|
||||||
|
"\n" (get-in @(rf/subscribe [::sub/project]) [:status]))])]))
|
||||||
|
|
||||||
(defn- audio []
|
(defn- audio []
|
||||||
(let [src @(rf/subscribe [::sub/audio])]
|
(let [src @(rf/subscribe [::sub/audio])]
|
||||||
|
|
@ -153,6 +241,7 @@
|
||||||
[stage]
|
[stage]
|
||||||
[audio]
|
[audio]
|
||||||
[transport]
|
[transport]
|
||||||
|
[controls]
|
||||||
[:p.note
|
[:p.note
|
||||||
"Upload a video, choose its footage, then load frames. Save the project to "
|
"Upload a video, choose its footage, then load frames. Save the project to "
|
||||||
"share the analyzed take without detecting frames again."]])
|
"share the analyzed take without detecting frames again."]])
|
||||||
|
|
|
||||||
101
frontend/test/arthur/flow/regenerate_test.cljs
Normal file
101
frontend/test/arthur/flow/regenerate_test.cljs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
(ns arthur.flow.regenerate-test
|
||||||
|
(:require [cljs.test :refer [deftest is]]
|
||||||
|
[arthur.domain.project :as project]
|
||||||
|
[arthur.flow.address :as address]
|
||||||
|
[arthur.flow.regenerate :as regenerate]
|
||||||
|
[arthur.flow.take :as take]
|
||||||
|
[arthur.synth :as synth]))
|
||||||
|
|
||||||
|
(def ^:private frames 40)
|
||||||
|
(def ^:private inputs (delay {:dense (synth/synth-dense frames {:seed 9})}))
|
||||||
|
(def ^:private initial
|
||||||
|
(delay
|
||||||
|
(let [p (merge take/knobs
|
||||||
|
{:name "regen" :fps 30 :aspect 1 :stage [320 200]
|
||||||
|
:expose 1 :head :as-filmed
|
||||||
|
:analysis (address/analysis
|
||||||
|
{:detector "synth" :version "mulberry32"
|
||||||
|
:seed 9 :frames frames :fps 30 :aspect 1})})]
|
||||||
|
(assoc (take/build p @inputs) :source-inputs @inputs))))
|
||||||
|
|
||||||
|
(defn- full-at [change]
|
||||||
|
(take/build
|
||||||
|
(merge take/knobs
|
||||||
|
{:name "regen" :fps 30 :aspect 1 :stage [320 200]
|
||||||
|
:expose 1 :head :as-filmed
|
||||||
|
:analysis (get-in @initial [:clip :analysis])}
|
||||||
|
change)
|
||||||
|
@inputs))
|
||||||
|
|
||||||
|
(defn- channel [entry node path]
|
||||||
|
(get-in entry [:clip :timelines :main :nodes node :channels path]))
|
||||||
|
|
||||||
|
(deftest eye-rebuild-is-confined-to-the-edited-feature
|
||||||
|
(let [before @initial
|
||||||
|
after (regenerate/change before
|
||||||
|
{:scope :feature :id :eye-r :knob :gaze-gain :value 2})]
|
||||||
|
(is (not= (channel before :iris-r [:xform :pos])
|
||||||
|
(channel after :iris-r [:xform :pos])))
|
||||||
|
(is (= (channel before :iris-l [:xform :pos])
|
||||||
|
(channel after :iris-l [:xform :pos])))
|
||||||
|
(is (= (channel before :mouth [:geom :pts])
|
||||||
|
(channel after :mouth [:geom :pts])))
|
||||||
|
(is (= (get-in before [:clip :timelines :main :nodes :face])
|
||||||
|
(get-in after [:clip :timelines :main :nodes :face])))
|
||||||
|
(is (= (channel (full-at {:gaze-gain 2}) :iris-r [:xform :pos])
|
||||||
|
(channel after :iris-r [:xform :pos])))))
|
||||||
|
|
||||||
|
(deftest framed-eye-size-needs-no-new-dense-block
|
||||||
|
(let [before @initial
|
||||||
|
after (regenerate/change before
|
||||||
|
{:scope :feature :id :eye-r :knob :iris-size :value 0.6})]
|
||||||
|
(is (not= (channel before :iris-r [:geom :radius])
|
||||||
|
(channel after :iris-r [:geom :radius])))
|
||||||
|
(is (= (channel before :iris-l [:geom :radius])
|
||||||
|
(channel after :iris-l [:geom :radius])))
|
||||||
|
(is (= (channel (full-at {:iris-size 0.6}) :iris-r [:geom :radius])
|
||||||
|
(channel after :iris-r [:geom :radius])))
|
||||||
|
(is (= (set (keys (:store before))) (set (keys (:store after)))))))
|
||||||
|
|
||||||
|
(deftest mouth-edit-leaves-the-head-and-eye-alone
|
||||||
|
(let [before @initial
|
||||||
|
after (regenerate/change before
|
||||||
|
{:scope :feature :id :mouth :knob :verts :value 10})]
|
||||||
|
(is (not= (channel before :mouth [:geom :pts])
|
||||||
|
(channel after :mouth [:geom :pts])))
|
||||||
|
(is (= (get-in before [:clip :timelines :main :nodes :head])
|
||||||
|
(get-in after [:clip :timelines :main :nodes :head])))
|
||||||
|
(is (= (channel before :eye-r [:geom :pts])
|
||||||
|
(channel after :eye-r [:geom :pts])))
|
||||||
|
(is (= (channel (full-at {:verts 10}) :mouth [:geom :pts])
|
||||||
|
(channel after :mouth [:geom :pts])))))
|
||||||
|
|
||||||
|
(deftest subject-edit-recomputes-head-and-keeps-authored-placement
|
||||||
|
(let [before @initial
|
||||||
|
after (regenerate/change before
|
||||||
|
{:scope :subject :id :face-1 :knob :anchor-avg :value 4})
|
||||||
|
full (full-at {:anchor-avg 4})]
|
||||||
|
(is (not= (get-in before [:clip :timelines :main :nodes :head :measured])
|
||||||
|
(get-in after [:clip :timelines :main :nodes :head :measured])))
|
||||||
|
(is (= (get-in full [:clip :timelines :main :nodes :head :measured])
|
||||||
|
(get-in after [:clip :timelines :main :nodes :head :measured])))
|
||||||
|
(is (= (get-in before [:clip :timelines :main :nodes :face])
|
||||||
|
(get-in after [:clip :timelines :main :nodes :face])))))
|
||||||
|
|
||||||
|
(deftest contour-edit-does-not-rebuild-head-or-teeth
|
||||||
|
(let [before @initial
|
||||||
|
after (regenerate/change before
|
||||||
|
{:scope :subject :id :face-1 :knob :contour-avg :value 3})]
|
||||||
|
(is (= (get-in before [:clip :timelines :main :nodes :head])
|
||||||
|
(get-in after [:clip :timelines :main :nodes :head])))
|
||||||
|
(is (= (channel before :teeth [:geom :pts])
|
||||||
|
(channel after :teeth [:geom :pts])))))
|
||||||
|
|
||||||
|
(deftest edited-take-still-round-trips-through-normal-save
|
||||||
|
(let [after (regenerate/change @initial
|
||||||
|
{:scope :group :id :eyes-1 :knob :iris-size :value 0.6})
|
||||||
|
wire (project/save "regen" after)
|
||||||
|
loaded (project/load "regen" (js/JSON.parse (js/JSON.stringify wire)))]
|
||||||
|
(is (= (:clip after) (:clip loaded)))
|
||||||
|
(is (= (channel after :iris-r [:geom :radius])
|
||||||
|
(channel loaded :iris-r [:geom :radius])))))
|
||||||
|
|
@ -1,7 +1,25 @@
|
||||||
(ns arthur.flow.source-test
|
(ns arthur.flow.source-test
|
||||||
(:require [cljs.test :refer [deftest is]]
|
(:require [cljs.test :refer [deftest is]]
|
||||||
|
[arthur.domain.params :as params]
|
||||||
|
[arthur.domain.wire :as wire]
|
||||||
[arthur.flow.source :as source]))
|
[arthur.flow.source :as source]))
|
||||||
|
|
||||||
|
(deftest pixel-measurements-have-their-own-addressed-block
|
||||||
|
(let [settings params/defaults
|
||||||
|
ring (mapv (fn [i] {:x (/ i 10) :y (/ (+ i 1) 20)})
|
||||||
|
(range (:teeth-verts settings)))
|
||||||
|
measures [{:contour ring :contrast 0.7 :area 42}
|
||||||
|
{:contour nil :contrast 0 :area 0}]
|
||||||
|
block (source/interior-block "sha256:analysis" settings measures)
|
||||||
|
response #js {:descriptor (:descriptor block)
|
||||||
|
:data (wire/base64 (:data block))}]
|
||||||
|
(is (= (:key block)
|
||||||
|
(source/interior-key "sha256:analysis" settings 2)))
|
||||||
|
(is (= measures (source/unpack-interior response settings 2)))
|
||||||
|
(is (not= (:key block)
|
||||||
|
(source/interior-key "sha256:analysis"
|
||||||
|
(assoc settings :cavity-erode 0.2) 2)))))
|
||||||
|
|
||||||
(deftest source-blocks-round-trip-without-source-images
|
(deftest source-blocks-round-trip-without-source-images
|
||||||
(let [face (vec (repeat 478 {:x 0.25 :y 0.5 :z -0.125}))
|
(let [face (vec (repeat 478 {:x 0.25 :y 0.5 :z -0.125}))
|
||||||
pixels (js/Uint8ClampedArray. #js [12 24 36 255])
|
pixels (js/Uint8ClampedArray. #js [12 24 36 255])
|
||||||
|
|
@ -19,3 +37,12 @@
|
||||||
(is (nil? (second (:crops out))))
|
(is (nil? (second (:crops out))))
|
||||||
(is (= (mapv :key (vals blocks))
|
(is (= (mapv :key (vals blocks))
|
||||||
(mapv :key (vals (source/pack "sha256:analysis" out)))))))
|
(mapv :key (vals (source/pack "sha256:analysis" out)))))))
|
||||||
|
|
||||||
|
(deftest fresh-source-includes-its-pixel-measurement-block
|
||||||
|
(let [face (vec (repeat 478 {:x 0.25 :y 0.5 :z -0.125}))
|
||||||
|
input {:dense [face] :detected [false] :crops [nil]
|
||||||
|
:interior [{:contour nil :contrast 0 :area 0}]}
|
||||||
|
blocks (source/pack "sha256:analysis" input)]
|
||||||
|
(is (contains? blocks "source/interior"))
|
||||||
|
(is (= 4 (.-length (source/upload-blocks blocks))))
|
||||||
|
(is (= 3 (count source/roles)))))
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,8 @@ async function main() {
|
||||||
if (probe && probe.drawn > 0) break;
|
if (probe && probe.drawn > 0) break;
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
}
|
}
|
||||||
if (!probe) throw new Error('no canvas.stage on the page — is `shadow-cljs watch app` running?');
|
if (!probe) throw new Error('no canvas.stage on the page — ' +
|
||||||
|
(page.logs.slice(0, 3).join(' | ') || 'is `shadow-cljs watch app` running?'));
|
||||||
|
|
||||||
console.log(`\ncanvas ${probe.w}x${probe.h}, clip ${JSON.stringify(probe.selectedClip)}`);
|
console.log(`\ncanvas ${probe.w}x${probe.h}, clip ${JSON.stringify(probe.selectedClip)}`);
|
||||||
|
|
||||||
|
|
@ -393,6 +394,138 @@ async function main() {
|
||||||
check(!back[0].selectedClip.includes('take'), 'the picture is the reopened document',
|
check(!back[0].selectedClip.includes('take'), 'the picture is the reopened document',
|
||||||
JSON.stringify(back[0].selectedClip));
|
JSON.stringify(back[0].selectedClip));
|
||||||
|
|
||||||
|
// The local 8625 study is optional in a fresh test database. When present,
|
||||||
|
// tune its shared symbol while its stage is playing.
|
||||||
|
const stageSource = await page.eval(`fetch('/api/projects/4379f900-bdd2-409b-acf6-32081f8ce01f')
|
||||||
|
.then(r => r.ok)`);
|
||||||
|
if (stageSource) {
|
||||||
|
check(await page.eval(CLICK('stage 8625')), 'the 8625 stage loads');
|
||||||
|
const stageLoaded = await statusMatching(/loaded 8625 stage study/, 160);
|
||||||
|
check(stageLoaded !== null, 'the 8625 stage is ready', stageLoaded ?? (await page.eval(STATUS)));
|
||||||
|
const eyeSelected = await page.eval(`(() => {
|
||||||
|
const select = document.querySelector('.controls select');
|
||||||
|
const option = [...select.options]
|
||||||
|
.find(o => o.textContent.trim() === 'left / feature · eye-r');
|
||||||
|
if (!option) return false;
|
||||||
|
select.value = option.value;
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
check(eyeSelected, 'a stage instance exposes its tracked right eye');
|
||||||
|
await sleep(100);
|
||||||
|
const irisSlider = await page.eval(`(() => {
|
||||||
|
const row = [...document.querySelectorAll('.control-row')]
|
||||||
|
.find(row => row.querySelector('span')?.textContent === 'iris-size');
|
||||||
|
if (!row) return null;
|
||||||
|
const r = row.querySelector('input').getBoundingClientRect();
|
||||||
|
return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 };
|
||||||
|
})()`);
|
||||||
|
check(irisSlider !== null, 'the stage eye has an iris-size slider');
|
||||||
|
if (irisSlider) {
|
||||||
|
check(await page.eval(CLICK('play')), 'the stage starts playing');
|
||||||
|
const startFrame = await page.eval(`Number(document.querySelector('.readout span').textContent.match(/\\d+/)[0])`);
|
||||||
|
await page.send('Input.dispatchMouseEvent', {
|
||||||
|
type: 'mousePressed', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1,
|
||||||
|
});
|
||||||
|
await page.send('Input.dispatchMouseEvent', {
|
||||||
|
type: 'mouseReleased', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1,
|
||||||
|
});
|
||||||
|
const preview = await statusMatching(/preview · unsaved/, 160);
|
||||||
|
const debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`);
|
||||||
|
check(preview !== null, 'the slider updates the stage preview', preview ?? debug);
|
||||||
|
check(debug.includes(':eye-r') && debug.includes('tier 1 only'),
|
||||||
|
'the panel reports the affected feature and tier', debug);
|
||||||
|
const endFrame = await page.eval(`Number(document.querySelector('.readout span').textContent.match(/\\d+/)[0])`);
|
||||||
|
check(endFrame > startFrame, 'playback continues during tuning', `${startFrame} -> ${endFrame}`);
|
||||||
|
await page.eval(CLICK('pause'));
|
||||||
|
}
|
||||||
|
const subjectSelected = await page.eval(`(() => {
|
||||||
|
const select = document.querySelector('.controls select');
|
||||||
|
const option = [...select.options]
|
||||||
|
.find(o => o.textContent.includes('subject · face-1'));
|
||||||
|
if (!option) return false;
|
||||||
|
select.value = option.value;
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
check(subjectSelected, 'the stage exposes its tracked subject');
|
||||||
|
if (subjectSelected) {
|
||||||
|
await sleep(100);
|
||||||
|
const anchorSlider = await page.eval(`(() => {
|
||||||
|
const row = [...document.querySelectorAll('.control-row')]
|
||||||
|
.find(row => row.querySelector('span')?.textContent === 'anchor-avg');
|
||||||
|
if (!row) return null;
|
||||||
|
const r = row.querySelector('input').getBoundingClientRect();
|
||||||
|
return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 };
|
||||||
|
})()`);
|
||||||
|
check(anchorSlider !== null, 'the subject has an anchor slider');
|
||||||
|
if (anchorSlider) {
|
||||||
|
const anchorStart = Date.now();
|
||||||
|
await page.send('Input.dispatchMouseEvent', {
|
||||||
|
type: 'mousePressed', x: anchorSlider.x, y: anchorSlider.y,
|
||||||
|
button: 'left', clickCount: 1,
|
||||||
|
});
|
||||||
|
await page.send('Input.dispatchMouseEvent', {
|
||||||
|
type: 'mouseReleased', x: anchorSlider.x, y: anchorSlider.y,
|
||||||
|
button: 'left', clickCount: 1,
|
||||||
|
});
|
||||||
|
let debug = '';
|
||||||
|
for (let i = 0; i < 160; i++) {
|
||||||
|
debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`);
|
||||||
|
if (debug.includes('head-pos')) break;
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
check(debug.includes('head-pos') && debug.includes(':teeth'),
|
||||||
|
'anchor invalidates the head and teeth', debug);
|
||||||
|
const preview = await statusMatching(/preview · unsaved/, 160);
|
||||||
|
check(preview !== null, 'the anchor edit finishes previewing',
|
||||||
|
preview ? `${Date.now() - anchorStart}ms` : (await page.eval(STATUS)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const teethSelected = await page.eval(`(() => {
|
||||||
|
const select = document.querySelector('.controls select');
|
||||||
|
const option = [...select.options]
|
||||||
|
.find(o => o.textContent.trim() === 'left / feature · teeth');
|
||||||
|
if (!option) return false;
|
||||||
|
select.value = option.value;
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
check(teethSelected, 'the stage exposes its tracked teeth');
|
||||||
|
if (teethSelected) {
|
||||||
|
await sleep(100);
|
||||||
|
const slider = await page.eval(`(() => {
|
||||||
|
const row = [...document.querySelectorAll('.control-row')]
|
||||||
|
.find(row => row.querySelector('span')?.textContent === 'cavity-erode');
|
||||||
|
if (!row) return null;
|
||||||
|
const r = row.querySelector('input').getBoundingClientRect();
|
||||||
|
return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 };
|
||||||
|
})()`);
|
||||||
|
check(slider !== null, 'the teeth have a pixel-setting slider');
|
||||||
|
if (slider) {
|
||||||
|
await page.send('Input.dispatchMouseEvent', {
|
||||||
|
type: 'mousePressed', x: slider.x, y: slider.y,
|
||||||
|
button: 'left', clickCount: 1,
|
||||||
|
});
|
||||||
|
await page.send('Input.dispatchMouseEvent', {
|
||||||
|
type: 'mouseReleased', x: slider.x, y: slider.y,
|
||||||
|
button: 'left', clickCount: 1,
|
||||||
|
});
|
||||||
|
let debug = '';
|
||||||
|
for (let i = 0; i < 160; i++) {
|
||||||
|
debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`);
|
||||||
|
if (debug.includes('dirty features: [:teeth]')) break;
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
check(debug.includes('dirty features: [:teeth]'),
|
||||||
|
'the pixel setting invalidates only teeth', debug);
|
||||||
|
const preview = await statusMatching(/preview · unsaved/, 160);
|
||||||
|
check(preview !== null, 'the teeth edit finishes previewing',
|
||||||
|
preview ?? (await page.eval(STATUS)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Exercise the actual file input and FormData path, then wait for the
|
// Exercise the actual file input and FormData path, then wait for the
|
||||||
// extraction job's footage to appear in the server list.
|
// extraction job's footage to appear in the server list.
|
||||||
const uploadDir = mkdtempSync(join(tmpdir(), 'arthur-upload-'));
|
const uploadDir = mkdtempSync(join(tmpdir(), 'arthur-upload-'));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue