Making a lane one row cost the thing a row was for. A clip stopped being a row, so there was no longer any way to open a clip and see what was inside it, and the inside of a drawing — the most ordinary thing in the document — became reachable only by opening it as its own tab. This is that capability back, from the root timeline, down as far as it goes. An expanded lane opens exactly ONE clip: the selected one. Its own keys, then the lanes and nodes of the symbol it places, then theirs, each mapped into this ruler by the recursive walk that was already there. Twelve clips in a lane still cost one row, and inspection costs one branch rather than twelve. Two things that only showed up once it ran. The portal is chosen by the whole LINEAGE of the selection and not by the selected id: selecting a shape inside the clip — or the end of its span — is still working inside that clip, and matching the id alone shut the portal the instant anything under it was touched. And selecting now waits for the pointer to come UP, because selecting on the way down re-drew the timeline before the gesture had said anything: it shut the portal holding the lane being dragged INTO, out from under the pointer. A HELD clip opens too, which the old row walk never did either. `source-time` is nil for a hold, so the walk stopped there and the contents of every drawing were invisible from here. Its rows are shown across the hold — which is when the node is on screen — and marked `:unmapped?`: no keys, and no draggable edges, because a frozen clock gives no frame inside it a place on this ruler. Refusing to place the keys is the honest half; refusing to show the rows was not. Double-clicking a clip opens the symbol it places as a tab, as double-clicking the same symbol in the pool does. That was already written and had never once run: the track captures the pointer for a slide, so the click and double-click that follow are delivered to the track and never to the block. The track now resolves them itself. Fixing the delivery exposed two more: `symbol/lineage` reported a `parent cycle` for any id in a symbol with NO nodes, because a one-element chain is longer than zero nodes — and opening a symbol left the selection pointing into the symbol being left, which the breadcrumb and the inspector then tried to resolve. The editor unmounted. Both are fixed where they were wrong, and the browser test asserts the editor is still standing afterwards. Audio is a clip in a lane like everything else. A dropped sound lands in one and is trimmed and moved by the same commands; a lane holds picture or sound and not both, which is the explicit capability the model asked for rather than a guess per frame. The refusal lives in the commands and not only in validation, because placement claims time: `blank` would have deleted the sound to make room for the picture and left a perfectly valid document behind. What is in a lane of the open symbol is drawn as a lane; what is nested inside a placed symbol is still flattened by `audio-tracks`, so no sound is on two rows. Everything that enters the timeline now enters a lane: a converted take, a symbol brought in from another project, a sound. One rule answers where — `lane-destination` — and every symbol is born with a lane for it to answer with. An unaimed drop fills an EMPTY lane rather than taking an occupied one nobody pointed at, because the alternative is trimming away what was there to make room for what was dropped. Shift during a clip-body drag means the other intention: put this node INSIDE the symbol the clip under the pointer places, through `nest/move-node`, which is what keeps the world transform and the root timing. Overlap cannot say which of the two is meant — dropping on occupied time already means claiming it — so the person says, and a label by the pointer says it back. The label asks `nest/move-refusal`, the same check the command makes, so it cannot promise what the drop would refuse. Today it refuses more than it allows: both clips have to be on screen at one frame, which two clips in one lane never are, and a held destination has no clock to move through at all. `docs/lane-nesting-notes.md` argues that the second refusal is stronger than the facts require and says what would settle it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
292 lines
17 KiB
Clojure
292 lines
17 KiB
Clojure
(ns arthur.domain.instance-test
|
|
(:require [cljs.test :refer [deftest is testing]]
|
|
[arthur.demo.stage :as stage]
|
|
[arthur.domain.channel :as ch]
|
|
[arthur.domain.clip :as clip]
|
|
[arthur.domain.leaf :as leaf]
|
|
[arthur.domain.node :as node]
|
|
[arthur.domain.paint :as paint]
|
|
[arthur.domain.pose :as pose]
|
|
[arthur.domain.palette :as pal]
|
|
[arthur.domain.symbol :as symbol]))
|
|
|
|
(def source
|
|
{:name "source" :fps 30 :width 320 :height 200
|
|
:symbols
|
|
{:main {:id :main :frames 4
|
|
:nodes {:root {:id :root :kind :group :z "a1"}
|
|
:mark {:id :mark :kind :rect :parent :root :z "a1"
|
|
:channels {[:xform :pos] (ch/keyed {0 [0 0] 1 [10 0]
|
|
2 [20 0] 3 [30 0]} :hold)
|
|
[:geom :size] (ch/framed 4)
|
|
[:style :color] (ch/framed :brow)}}}}}})
|
|
|
|
(deftest two-instances-own-their-frame-and-placement
|
|
(let [document
|
|
(-> source
|
|
(assoc-in [:symbols :main]
|
|
{:id :main :frames 6
|
|
:nodes {:root {:id :root :kind :group :z "a1"}
|
|
:left {:id :left :kind :instance
|
|
:parent :root :z "a1" :span [0 4]
|
|
:source {:symbol :sym/test}
|
|
:channels {[:xform :pos] (ch/framed [100 50])}}
|
|
:right {:id :right :kind :instance
|
|
:parent :root :z "a2" :span [0 4]
|
|
:time {:mode :map :at 2 :rate 1}
|
|
:source {:symbol :sym/test}
|
|
:channels {[:xform :pos] (ch/framed [120 50])}}}})
|
|
(assoc-in [:symbols :sym/test]
|
|
(assoc (get-in source [:symbols :main]) :id :sym/test)))
|
|
resolve (clip/resolver document :main nil pal/index-of nil)
|
|
at (fn [f] (mapv (juxt :node :cx) (resolve f)))]
|
|
(is (empty? (clip/problems document)))
|
|
(is (= [[[:left :mark] 110]] (at 1)))
|
|
(is (= [[[:left :mark] 120] [[:right :mark] 120]] (at 2)))
|
|
(is (= [[[:right :mark] 150]] (at 5)))
|
|
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))))
|
|
|
|
(deftest a-placement-holds-and-cuts-each-generated-shape-independently
|
|
(let [values (js/Int16Array. (clj->js (range 2 32)))
|
|
visible (ch/keyed {0 true 20 true 21 false} :hold)
|
|
dense {:animated? true :interp :hold
|
|
:dense {:store "sizes" :offset 0 :stride 1 :frames 30}
|
|
:pose-sampled? true
|
|
:over [(ch/layer :nudge [7 8] :offset (ch/framed 100))]}
|
|
shape (fn [id z group]
|
|
{:id id :kind :rect :parent :root :z z :pose-group group
|
|
:channels {[:xform :pos] (ch/keyed {0 [0 0] 8 [8 0]} :hold)
|
|
[:geom :size] dense
|
|
[:vis] (assoc visible :pose-sampled? true)
|
|
[:style :color] (ch/framed :brow)}})
|
|
symbol {:id :sym/poses :frames 30
|
|
:nodes {:root {:id :root :kind :group :z "a1"}
|
|
:mouth (shape :mouth "a1" :mouth)
|
|
:mouth-detail (shape :mouth-detail "a2" :mouth)
|
|
:eye (shape :eye "a3" :eye)
|
|
:brow (shape :brow "a4" :brow)}}
|
|
document {:fps 30 :width 320 :height 200
|
|
:symbols
|
|
{:main {:id :main :frames 30
|
|
:nodes {:root {:id :root :kind :group :z "a1"}
|
|
:first {:id :first :kind :instance
|
|
:parent :root :z "a1"
|
|
:source {:symbol :sym/poses}
|
|
:playback {:tracks {:mouth {0 0, 8 20, 9 21}
|
|
[:node :mouth-detail] {0 0, 8 4}
|
|
:eye {0 0, 4 4}}}}
|
|
:second {:id :second :kind :instance
|
|
:parent :root :z "a2"
|
|
:source {:symbol :sym/poses}
|
|
:playback {:tracks {:mouth {0 0, 8 8}}}}}}
|
|
:sym/poses symbol}}
|
|
store {"sizes" {:data values}}
|
|
resolve (clip/resolver document :main store pal/index-of nil)
|
|
at (fn [f] (into {} (map (fn [op] [(:node op) op])) (resolve f)))]
|
|
(is (empty? (clip/problems document)))
|
|
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))
|
|
(is (= 102 (:size (get (at 7) [:first :mouth]))) "eight static frames, plus its correction")
|
|
(is (= 22 (:size (get (at 8) [:first :mouth]))) "cut to source pose 20")
|
|
(is (= 6 (:size (get (at 8) [:first :mouth-detail])))
|
|
"one node may depart from its shared mouth group")
|
|
(is (= 10 (:size (get (at 8) [:second :mouth]))) "other instance chooses pose 8")
|
|
(is (= 106 (:size (get (at 7) [:first :eye]))) "eye has its own timing")
|
|
(is (= 8 (:cx (get (at 8) [:first :eye]))) "authored position still reads stage time")
|
|
(is (nil? (get (at 9) [:first :mouth]))
|
|
"generated visibility is read from the same selected pose")
|
|
(is (some? (get (at 9) [:second :mouth])))
|
|
(let [sym (get-in document [:symbols :sym/poses])
|
|
opts {:pose-tracks {:mouth {0 0, 8 20}}}]
|
|
(is (= (mapv #(select-keys % [:node :cx :size])
|
|
(symbol/eval-frame sym 8 store pal/index-of opts))
|
|
(mapv #(select-keys % [:node :cx :size])
|
|
((symbol/resolver sym store pal/index-of opts) 8)))
|
|
"pure evaluation and playback apply the same pose choice"))))
|
|
|
|
(deftest stage-pose-edits-preserve-earlier-motion-and-survive-save
|
|
(let [document (-> source
|
|
(assoc-in [:symbols :main :nodes :placed]
|
|
{:id :placed :kind :instance :parent :root
|
|
:z "a2"
|
|
:source {:symbol :sym/test}})
|
|
(assoc-in [:symbols :sym/test]
|
|
{:id :sym/test :frames 4
|
|
:nodes {:root {:id :root :kind :group :z "a1"}
|
|
:mark {:id :mark :kind :rect :parent :root
|
|
:z "a1" :pose-group :mark
|
|
:channels {[:geom :size]
|
|
{:animated? true :interp :hold
|
|
:keys {0 2 1 3 2 4 3 5}
|
|
:pose-sampled? true}}}}})
|
|
(pose/put-cut :main :placed :mark 2 3))
|
|
cuts (get-in document [:symbols :main :nodes :placed :playback :tracks :mark])]
|
|
(is (= {2 3} cuts))
|
|
(is (= 1 (pose/source-frame (pose/prepare {:mark cuts}) :mark 1 1))
|
|
"before the first cut, dense motion continues")
|
|
(is (= 3 (pose/source-frame (pose/prepare {:mark cuts}) :mark 2 2)))
|
|
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))
|
|
(is (nil? (get-in (pose/remove-cut document :main :placed :mark 2)
|
|
[:symbols :main :nodes :placed :playback :tracks :mark])))
|
|
(is (seq (clip/problems (assoc-in document
|
|
[:symbols :main :nodes :placed :playback :tracks :mark]
|
|
{4 3}))))))
|
|
|
|
(defn- uuid-of
|
|
"The uuid the layout authors for the placement whose handle is `id`.
|
|
|
|
Read out of `stage/layout` rather than written here as a literal: what this test
|
|
is about is the mapping `compose` performs, and nine copied uuids would assert
|
|
that someone copied them correctly."
|
|
[id]
|
|
(or (->> (concat (:instances stage/layout) (:audio stage/layout))
|
|
(some (fn [p] (when (= id (:id p)) (:uuid p)))))
|
|
(throw (ex-info "no such placement in the layout" {:id id}))))
|
|
|
|
(defn- placement
|
|
"The composed node for the placement the layout calls `id`."
|
|
[document id]
|
|
(get-in document [:symbols :main :nodes (uuid-of id)]))
|
|
|
|
(deftest stage-fixture-keeps-source-as-one-symbol
|
|
(let [document (stage/compose source)]
|
|
(is (empty? (clip/problems document)))
|
|
(is (= #{:main :sym/face-8625} (set (keys (:symbols document)))))
|
|
(is (= #{:sym/face-8625} (node/sources (placement document :left))))
|
|
(is (= #{:sym/face-8625} (node/sources (placement document :right))))
|
|
(testing "every placement is keyed by its own uuid"
|
|
;; The identity change: seven placements of one drawing are seven things,
|
|
;; and each is named by something that means only itself. Sharing a key, or
|
|
;; keying by a description of where a thing sits, is what this rules out.
|
|
(let [symbols (filter (comp #{:instance} :kind val)
|
|
(get-in document [:symbols :main :nodes]))]
|
|
(is (= 7 (count symbols)))
|
|
(is (every? uuid? (map key symbols)))
|
|
(is (= 7 (count (distinct (map key symbols)))))
|
|
(testing "and each still says which drawing it plays and what to call it"
|
|
(is (every? #(= #{:sym/face-8625} (node/sources (val %))) symbols))
|
|
(is (every? #(string? (:name (val %))) symbols))
|
|
(is (= 7 (count (distinct (map #(:name (val %)) symbols))))))))
|
|
(is (= 7 (count (filter #(= :instance (:kind %))
|
|
(vals (get-in document [:symbols :main :nodes]))))))
|
|
(is (= [0 232] (:span (placement document :right))) "its own frames, from its own 0")
|
|
(is (= [48 280] (node/placed-span (placement document :right))) "and where that sits on the stage")
|
|
(let [left (placement document :left)
|
|
scale (get-in left [:channels [:xform :scale]])
|
|
anchor (get-in left [:channels [:xform :anchor] :value])
|
|
pos (get-in left [:channels [:xform :pos]])
|
|
start-pos (ch/value-at pos 0 nil)]
|
|
(is (= [160 100] anchor) "the source center becomes a stored pivot")
|
|
(is (= [-120 -60] start-pos))
|
|
(is (not= start-pos (ch/value-at pos 40 nil)) "the face drifts during playback")
|
|
(is (= [0.4 0.4] (ch/value-at scale 0 nil)))
|
|
(is (= [0.56 0.56] (ch/value-at scale 12 nil)))
|
|
(is (= [0.52 0.52] (ch/value-at scale 48 nil)))
|
|
(doseq [f [0 12 48]]
|
|
(let [m (node/local! (node/mat) start-pos 0 (ch/value-at scale f nil) [0 0] anchor)
|
|
out (js/Float64Array. 2)]
|
|
(node/apply-pt! out 0 m 160 100)
|
|
(is (= [40 40] [(aget out 0) (aget out 1)])
|
|
"the face center stays put while it scales"))))
|
|
(testing "the editorial link resolves to the placement's uuid"
|
|
;; The EDN names `:right`; the document must carry the identity, or the link
|
|
;; dangles the moment anything is renamed. `clip/problems` above checks it
|
|
;; resolves to a node at all; this checks it resolves to the RIGHT one.
|
|
(is (= (uuid-of :right) (:linked-to (placement document :voice-right))))
|
|
(is (uuid? (:linked-to (placement document :voice-right)))))
|
|
(is (= [48 260] (node/placed-span (placement document :voice-right))))
|
|
(is (= 0.5 (ch/value-at
|
|
(get-in (placement document :voice-right)
|
|
[:channels [:audio :gain]]) 54 nil)))
|
|
(is (< -0.8 (ch/value-at
|
|
(get-in (placement document :voice-right)
|
|
[:channels [:audio :pan]]) 110 nil) 0.7))
|
|
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))))
|
|
|
|
(defn- nested
|
|
"Three symbols: :outer places :inner, and :loose is placed by nothing."
|
|
[]
|
|
(-> (clip/blank)
|
|
(assoc-in [:symbols :outer] {:id :outer :frames 200 :nodes {}})
|
|
(assoc-in [:symbols :inner] {:id :inner :frames 10 :nodes {}})
|
|
(assoc-in [:symbols :loose] {:id :loose :frames 30 :nodes {}})
|
|
(clip/place-symbol nil :outer :inner 5 (random-uuid) nil)))
|
|
|
|
(deftest no-symbol-is-special
|
|
(let [c (nested)]
|
|
(testing "a document opens on the longest symbol nothing places"
|
|
(is (= [:loose :main :outer] (clip/unplaced c)))
|
|
(is (= :outer (clip/opens-on c)))
|
|
(is (= :main (clip/opens-on (clip/blank)))))
|
|
(testing "an instance can go into any symbol, and spans that symbol's frames"
|
|
(let [[n] (vals (get-in c [:symbols :outer :nodes]))]
|
|
(is (= #{:inner} (node/sources n)))
|
|
(is (= [0 10] (:span n)) "its own frames: all of what it places, from its own 0")
|
|
(is (= [5 15] (node/placed-span n)) "and where that lands in the symbol it is in")))
|
|
(testing "placing is refused when it would make a cycle"
|
|
(is (clip/contains-symbol? c :outer :inner))
|
|
(is (not (clip/contains-symbol? c :inner :outer)))
|
|
(is (= c (clip/place-symbol c nil :inner :outer 0 (random-uuid) nil))
|
|
"outer inside inner, which is inside outer")
|
|
(is (= c (clip/place-symbol c nil :inner :inner 0 (random-uuid) nil))
|
|
"a symbol inside itself"))
|
|
(testing "and the result is a valid document whose instance saves"
|
|
(is (empty? (clip/problems c)))
|
|
(is (= (get-in c [:symbols :outer :nodes])
|
|
(get-in (leaf/clip "c" (leaf/leaves "c" c)) [:symbols :outer :nodes]))))))
|
|
|
|
(deftest a-new-symbol-is-empty-and-placed-where-it-was-asked-for
|
|
(let [c (nested)
|
|
u #uuid "00000000-0000-4000-8000-000000000001"
|
|
id (clip/fresh-id c)
|
|
made (clip/new-symbol c :outer id 20 u)]
|
|
(is (= :symbol-1 id))
|
|
(is (= :symbol-2 (clip/fresh-id made)) "the next one does not collide")
|
|
(is (= {:id :symbol-1 :name "symbol-1" :fps 30 :frames 180
|
|
:nodes {:lane clip/lane-node}}
|
|
(clip/symbol made :symbol-1))
|
|
"empty but for the lane every symbol is born with, and as long as the
|
|
rest of what it was placed in")
|
|
(is (= {:span [0 180] :time {:mode :map :at 20 :rate 1}}
|
|
(select-keys (get-in made [:symbols :outer :nodes u]) [:span :time])))
|
|
(is (= #{:symbol-1} (node/sources (get-in made [:symbols :outer :nodes u])))
|
|
"and it places the symbol it just made")
|
|
(is (empty? (clip/problems made)))
|
|
(is (= c (clip/new-symbol c :outer :inner 0 u)) "an id already in use is refused")
|
|
(is (= c (clip/new-symbol c :outer id 200 u)) "past the end is refused")))
|
|
|
|
(deftest an-instance-span-is-in-its-own-frames
|
|
(let [n {:id :i :kind :instance :z "a1" :span [3 13]
|
|
:source {:symbol :x}
|
|
:time {:mode :map :at 40 :rate 2}}]
|
|
(is (= [41.5 46.5] (node/placed-span n)) "own frames 3 to 13, at double rate, from 40")
|
|
(is (= 0 (node/local-frame n 40)) "the parent's :at is where its own frame 0 lands")
|
|
(is (= 8 (node/local-frame n 44)))
|
|
(is (= [2 9] (node/placed-span {:kind :poly :span [2 9]}))
|
|
"a shape has no time of its own, so its span is already the parent's")
|
|
(is (seq (node/problems (assoc-in n [:time :in] 3)))
|
|
"a stale :in is reported rather than silently ignored")))
|
|
|
|
(deftest an-instance-pivots-about-the-middle-of-what-it-draws
|
|
(let [square (fn [x y] {:kind :poly :z "a1" :id :sq
|
|
:channels {[:geom :pts] (ch/framed [x y (+ x 10) y (+ x 10) (+ y 10) x (+ y 10)])
|
|
[:style :color] (ch/framed :brow)}})
|
|
c (-> (clip/blank)
|
|
(assoc-in [:symbols :box] {:id :box :frames 4 :nodes {:sq (assoc (square 20 30) :id :sq)}})
|
|
(assoc-in [:symbols :empty] {:id :empty :frames 4 :nodes {}}))
|
|
u #uuid "00000000-0000-4000-8000-0000000000cc"
|
|
placed (fn [c sid point] (get-in (clip/place-symbol c nil :main sid 0 u point)
|
|
[:symbols :main :nodes u :channels]))]
|
|
(is (= [25 35] (clip/center c nil :box)) "the middle of the square")
|
|
(is (= [160 100] (clip/center c nil :empty)) "nothing drawn: the stage's middle")
|
|
(testing "the anchor is the middle, and it moves nothing at the identity"
|
|
(is (= [25 35] (get-in (placed c :box nil) [[:xform :anchor] :value])))
|
|
(is (= [0 0] (get-in (placed c :box nil) [[:xform :pos] :value]))
|
|
"dropped on the timeline: where it was drawn"))
|
|
(testing "dropped on a stage pixel, its middle goes there"
|
|
(is (= [75 65] (get-in (placed c :box [100 100]) [[:xform :pos] :value]))))
|
|
(testing "and growing the symbol later does not move an instance's pivot"
|
|
(let [c (clip/place-symbol c nil :main :box 0 u nil)
|
|
grown (assoc-in c [:symbols :box :nodes :sq2] (assoc (square 80 30) :id :sq2 :z "a2"))]
|
|
(is (= [55 35] (clip/center grown nil :box)) "the symbol's middle moved")
|
|
(is (= [25 35] (get-in grown [:symbols :main :nodes u :channels [:xform :anchor] :value]))
|
|
"the instance's did not")))))
|