271 lines
13 KiB
Clojure
271 lines
13 KiB
Clojure
(ns arthur.flow.regenerate-test
|
|
(:require [cljs.test :refer [deftest is testing]]
|
|
[clojure.walk :as walk]
|
|
[arthur.demo.stage :as stage]
|
|
[arthur.domain.clip :as clip]
|
|
[arthur.domain.params :as params]
|
|
[arthur.domain.project :as project]
|
|
[arthur.flow.address :as address]
|
|
[arthur.flow.freeze :as freeze]
|
|
[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 :free
|
|
: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 :free
|
|
: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])))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; the dirty set
|
|
|
|
(def ^:private crossings
|
|
"A threshold knob needs a value that CROSSES something or a re-freeze proves
|
|
nothing about it. `:aperture-cut` is a fraction of the widest frame's aperture
|
|
and the synthetic mouth is open on every frame, so gating one takes a value near
|
|
the top of its range rather than a nudge off the default."
|
|
{:aperture-cut 0.9})
|
|
|
|
(defn- bump [id]
|
|
(let [{:keys [type default] must-even? :even?} (get params/definitions id)]
|
|
(if-let [crossing (get crossings id)]
|
|
crossing
|
|
(cond (and (= :integer type) must-even?) (+ default 2)
|
|
(= :integer type) (+ default 1)
|
|
(zero? default) 0.5
|
|
:else (* default 1.75)))))
|
|
|
|
(def ^:private fixture-blind
|
|
"Knobs a synthetic dense track cannot move, stated rather than quietly skipped.
|
|
|
|
The teeth's thresholds and its vertex budget reach the contour through
|
|
`source/measure-crop` — real pixels, an otsu threshold and a radial sweep, all
|
|
above the stage this fixture starts at — so they are `address-test`'s half of the
|
|
same table, asserted there at the descriptor. And `:blink-cut` needs a face that
|
|
blinks: it gates `[:vis]` keys, the synthetic eyes never shut, and a threshold
|
|
with nothing to cross reads as a knob the eye does not have."
|
|
#{:cavity-erode :tongue-reject :blob-grow :top-bias :teeth-verts :min-area
|
|
:blink-cut})
|
|
|
|
(def ^:private interior-track
|
|
"A contour and a contrast on every frame, both moving, so `condition/interior`
|
|
has its own decisions to make. Contrast runs in five-frame blocks either side of
|
|
`teeth-on` because the threshold has hysteresis and a dwell: a one-frame dip is
|
|
suppressed on purpose, and a fixture that only dipped for one frame would report
|
|
`:teeth-on` as a knob the teeth do not read."
|
|
(delay
|
|
(vec (for [f (range frames)]
|
|
{:contrast (if (< (mod f 10) 5) 0.15 0.45)
|
|
:area (+ 30 (mod f 3))
|
|
:contour (vec (for [i (range (:teeth-verts take/knobs))]
|
|
{:x (+ 0.45 (* 0.02 (js/Math.cos (+ i f))))
|
|
:y (+ 0.5 (* 0.02 (js/Math.sin (+ i f))))}))}))))
|
|
|
|
(defn- plain [x]
|
|
(if (some? (some-> x .-BYTES_PER_ELEMENT)) (vec (array-seq x)) x))
|
|
|
|
(defn- output
|
|
"A frozen part as a VALUE, which is what makes the comparison below mean
|
|
anything: a block's `:data` is a typed array and two of those are never `=`
|
|
however identical their contents, so an unguarded `=` reports every part as
|
|
different and proves nothing in either direction.
|
|
|
|
The block KEYS are elided and the bytes are not. A key that moved is
|
|
`block-knobs` agreeing with itself; the bytes and the tier-1 channels are the
|
|
fact. `address-test` draws the line in the same place."
|
|
[part]
|
|
{:blocks (into #{} (map (fn [[_ b]] [(plain (:data b)) (plain (:state b))]))
|
|
(:store part))
|
|
:channels (walk/postwalk #(if (map? %) (dissoc % :store) %) (:nodes part))})
|
|
|
|
(defn- part-at [area overrides]
|
|
(let [p (merge take/knobs
|
|
{:fps 30 :aspect 1 :analysis (get-in @initial [:clip :analysis])}
|
|
overrides)
|
|
inputs (assoc @inputs :interior @interior-track)]
|
|
(freeze/part area p (take/measure-part area p inputs (take/anchor-base p inputs)))))
|
|
|
|
(def ^:private swept
|
|
"Per area, the knobs whose bytes or channels that area's freeze could plausibly
|
|
read at all — its own, plus the subject's two that every feature inherits."
|
|
{:mouth #{:anchor-avg :contour-avg :verts :aperture-cut}
|
|
:eye (into #{:anchor-avg :contour-avg} (keys (params/for-area :eye)))
|
|
:brow (into #{:anchor-avg :contour-avg} (keys (params/for-area :brow)))
|
|
:teeth #{:anchor-avg :contour-avg :aperture-cut :teeth-on :teeth-smooth}})
|
|
|
|
(deftest area-knobs-is-asserted-by-re-freezing-each-part
|
|
;; The biconditional `address-test` runs per BLOCK, run per feature AREA and
|
|
;; across both tiers. This is the question a regeneration asks — "is this
|
|
;; feature stale" — and `plan` used to answer it with a hand-written case per
|
|
;; knob, on the one side of the table nothing checked.
|
|
(doseq [[area knobs] (sort-by (comp str key) swept)
|
|
id (sort (remove fixture-blind knobs))]
|
|
(let [dirty? (contains? (address/area-knobs area) id)
|
|
same? (= (output (part-at area {}))
|
|
(output (part-at area {id (bump id)})))]
|
|
(testing (str area " " id " " (get params/defaults id) " -> " (bump id))
|
|
(is (= (not same?) dirty?)
|
|
(str "the frozen part is " (if same? "unchanged" "different")
|
|
" but area-knobs says " (if dirty? "dirty" "clean") " — "
|
|
(if same?
|
|
(str "remove " id " from " area "'s roles or framed-knobs")
|
|
(str "add " id " to " area "'s roles or framed-knobs"))))))))
|
|
|
|
;; ---- the stage: a shared symbol behind many placements ----
|
|
|
|
(defn- staged
|
|
"The take composed onto the 8625 stage: its timeline becomes the shared symbol,
|
|
and seven uuid-keyed placements sit on `:main`.
|
|
|
|
This path had no coverage, and it is the one that differs structurally: `change`
|
|
re-roots the SYMBOL as the take, edits that, and puts it back, so every
|
|
assumption `change-take` makes about `:main` holding the tracked nodes is only
|
|
true of the re-rooted document and not of the stage itself."
|
|
[]
|
|
;; The ENTRY with its clip composed, not the composed clip: `change` takes an
|
|
;; entry (`:clip`, `:store`, `:source-inputs`) and the store is what the
|
|
;; regenerated blocks merge into.
|
|
(update @initial :clip stage/compose))
|
|
|
|
(defn- sym-channel [entry node path]
|
|
(get-in entry [:clip :timelines :sym/face-8625 :nodes node :channels path]))
|
|
|
|
(deftest a-stage-edit-is-previewable-at-all
|
|
;; The guard `events/project/::preview-settings` bails on, stated here so a
|
|
;; document that cannot be previewed fails in the suite rather than as a slider
|
|
;; that silently does nothing in the browser.
|
|
(let [entry (staged)]
|
|
(is (some? (:analysis (:clip entry)))
|
|
"the composed stage keeps the analysis the edit needs")
|
|
(is (some? (:source-inputs entry)))
|
|
(testing "and the features still say which timeline they live in"
|
|
(is (every? #(= :sym/face-8625 (:timeline %))
|
|
(vals (:features (:clip entry))))))))
|
|
|
|
(deftest a-stage-edit-plans-the-same-features-as-a-take-edit
|
|
(let [edit {:scope :feature :id :eye-r :knob :iris-size :value 0.6}]
|
|
(is (= (:features (regenerate/plan (:clip @initial) edit))
|
|
(:features (regenerate/plan (:clip (staged)) edit)))
|
|
"the same knob dirties the same features on a stage as on a take")))
|
|
|
|
(deftest a-stage-edit-rewrites-the-shared-symbol
|
|
;; The payoff of the symbol being shared: ONE edit, and every placement reads it
|
|
;; on the next paint. So the changed channel has to land in the symbol timeline,
|
|
;; and `:main` — which holds only placements — must come back untouched.
|
|
(let [before (staged)
|
|
after (regenerate/change before
|
|
{:scope :feature :id :eye-r :knob :iris-size :value 0.6})]
|
|
(is (not= (sym-channel before :iris-r [:geom :radius])
|
|
(sym-channel after :iris-r [:geom :radius]))
|
|
"the shared drawing is what changed")
|
|
(is (= (sym-channel before :iris-l [:geom :radius])
|
|
(sym-channel after :iris-l [:geom :radius]))
|
|
"and only the edited side of it")
|
|
(testing "the placements are left exactly as they were"
|
|
(is (= (get-in before [:clip :timelines :main :nodes])
|
|
(get-in after [:clip :timelines :main :nodes]))))
|
|
(testing "and the document is still a document"
|
|
(is (empty? (clip/problems (:clip after)))))))
|
|
|
|
(deftest a-stage-edit-keeps-every-placement-and-its-link
|
|
;; A regeneration that dropped or re-keyed the placements would take the seven
|
|
;; faces off the stage, or dangle the voice links, while looking like a
|
|
;; successful edit of the drawing.
|
|
(let [after (regenerate/change (staged)
|
|
{:scope :feature :id :eye-r :knob :iris-size :value 0.6})
|
|
nodes (get-in after [:clip :timelines :main :nodes])
|
|
syms (filter (comp #{:symbol} :kind val) nodes)]
|
|
(is (= 7 (count syms)))
|
|
(is (every? uuid? (map key syms)))
|
|
(doseq [[_ n] (filter (comp #{:audio} :kind val) nodes)]
|
|
(is (contains? nodes (:linked-to n))
|
|
(str "the voice " (:id n) " still links to a node that is there")))))
|