A lane's drawings were going to be one instance whose source was a KEYED
channel: frame 0 says `:drawing-a`, frame 4 says `:drawing-b`, and the cels of
a row are that channel's keys. Two things followed from it, and both were
wrong.
The first is that playback meant whichever shape the channel happened to have.
A framed source played its symbol; a keyed source froze the selected frame.
So `node/placed-at` read animation out of storage, and adding an ordinary key
to a still turned it into an animation — the last-key bug, which was not a bug
in the code so much as the rule working as written. But WHICH drawing is used
and HOW time runs inside it are independent questions, and all four combinations
are ordinary: hold one drawing, play one animation, cut between held drawings,
cut between playing ones.
So an occurrence names one symbol in `:source {:symbol ...}` and says how its
source time advances in `:playback {:in :speed :end}` — `source = in + speed *
f`, a hold being speed 0, with `:stop`, `:hold` or `:loop` at the end named
rather than guessed. `node/placed-frame` samples it forwards, which works for
holds too, and `node/source-time` is the separate, invertible edit map, nil
where inversion is meaningless. The two were one function before, and a hold
had to lie about one of them.
The second is that a keyed source only looked necessary because an occurrence
was assumed to need a ROW. It does not. A lane is a group with `:layout
:sequence`, its occurrences are ordinary instances in the same flat node map,
and `timeline/rows` draws them as cel blocks on the lane's own row: twelve
exposures, one row, each cel still separately selectable and addressable. The
vertical growth that justified the keyed source is a presentation question, and
it is answered in the view.
`arthur.domain.sequence` holds the first commands over that shape — add lane,
append drawing, extend hold — each one history step, each refusing rather than
half-applying. Extending a hold leaves the lane's keys at their authored times,
because you are adjusting drawings underneath timed motion; a correction owned
by an occurrence travels with it. Ownership does that work, so no key needs a
flag saying what it follows. Ripple past the symbol's end is refused with the
frame count it would need, and `:extent :grow-symbol` is the caller saying yes.
`clip/blank` no longer carries `:subjects {} :features {} :groups {}`. Empty
maps write no leaf, so a blank document could not survive its own round trip —
`leaf/leaves` promises exactness and was the only honest side of that.
Documents are schema 3. A version 2 document is not read; nothing here converts
one. `docs/lane-model.md` is the design, and says which of its parts are built.
392 tests, 5,525 assertions, and `test/browser/sequence.mjs` drives the editor
through create, hold, explicit overflow and undo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
296 lines
14 KiB
Clojure
296 lines
14 KiB
Clojure
(ns arthur.export-test
|
|
"The frame walk and the arithmetic above the sink.
|
|
|
|
THE SYNC RULE IS THE POINT OF THIS FILE. `arthur.export` states it twice — in
|
|
its own docstring and in `plan`'s comment — because it is the one failure the
|
|
export path exists to prevent: a lower picture rate must HOLD each pose across
|
|
several frames and never drop frames, so the emitted length always matches the
|
|
audio. Decimating instead gives a file that is silently short, whose sound
|
|
slides progressively out of sync, and which looks correct in every other
|
|
respect. Nothing downstream can detect that, so it is asserted here, at both
|
|
levels: `plan` reports poses separately from frames, and `run!` emits every
|
|
frame of the frame space whatever the picture rate is.
|
|
|
|
The sink is a recording fake. What the walk owes a sink is an ordering and a
|
|
count, and a fake is the only way to assert on those without also asserting on
|
|
PNG bytes — which `export.frames-test` already does."
|
|
(:require [cljs.test :refer [deftest is testing async]]
|
|
[arthur.domain.channel :as ch]
|
|
[arthur.domain.clip :as clip]
|
|
[arthur.domain.palette :as pal]
|
|
[arthur.export :as export]))
|
|
|
|
(defn- poly [id z pts color]
|
|
{:id id :kind :poly :z z
|
|
:channels {[:geom :pts] (ch/framed pts) [:style :color] (ch/framed color)}})
|
|
|
|
(defn- a-symbol
|
|
"One authored square under a `:root` group. Picture sampling now applies to
|
|
marked generated channels in the shared resolver, leaving this square alone."
|
|
[frames]
|
|
{:frames frames
|
|
:nodes {:root {:id :root :kind :group :z "a1"}
|
|
:sq (assoc (poly :sq "a1" [1 1 6 1 6 5] :brow) :parent :root)}})
|
|
|
|
(defn- a-clip
|
|
"A clip with one square on one timeline. The picture is irrelevant here — what
|
|
matters is its frame space — so it is the smallest thing that resolves to an op."
|
|
[{:keys [frames fps w h] :or {frames 10 fps 24 w 8 h 6}}]
|
|
{:name "t" :fps fps :width w :height h
|
|
:symbols {:main (a-symbol frames)}})
|
|
|
|
(defn- recorder
|
|
"An `Exporter` that records the calls rather than encoding anything.
|
|
|
|
`:rasters` holds the raster OBJECT each frame arrived with, not a copy, so the
|
|
reuse contract can be asserted by identity."
|
|
[log]
|
|
(reify export/Exporter
|
|
(begin! [_ spec] (swap! log assoc :spec spec :frames []) nil)
|
|
(frame! [_ i ras]
|
|
(swap! log update :frames conj {:i i :index (aget (:buf ras) 0)})
|
|
(swap! log update :rasters (fnil conj []) ras)
|
|
nil)
|
|
(finish! [_] (js/Promise.resolve {:filename "t.zip" :blob :a-blob}))))
|
|
|
|
(defn- run!*
|
|
"Run an export over `clip`, returning a promise of the recorded log."
|
|
[clip & {:as opts}]
|
|
(let [log (atom {})]
|
|
(-> (export/run! (merge {:clip clip :symbol :main :store {}
|
|
:palette pal/index-of :ramp pal/rgb :zoom 1
|
|
:name "t"}
|
|
opts)
|
|
(recorder log)
|
|
(fn [done total] (swap! log update :progress (fnil conj []) [done total])))
|
|
(.then (fn [result] (assoc @log :result result))))))
|
|
|
|
;; ---- plan ----
|
|
|
|
(deftest plan-reports-what-the-export-will-be
|
|
(let [p (export/plan {:clip (a-clip {:frames 48 :fps 24 :w 320 :h 200}) :symbol :main :zoom 3})]
|
|
(is (= 48 (:frames p)))
|
|
(is (= 24 (:fps p)))
|
|
(is (= 3 (:zoom p)))
|
|
(is (= 960 (:width p)) "the zoom is in the reported size")
|
|
(is (= 600 (:height p)))
|
|
(is (= 2 (:seconds p)))))
|
|
|
|
(deftest the-zoom-is-an-integer-of-at-least-one
|
|
;; Anything else resamples, and a zoom of 0 would be a zero-byte picture.
|
|
(let [zoom-of #(:zoom (export/plan {:clip (a-clip {}) :symbol :main :zoom %}))]
|
|
(is (= 2 (zoom-of 2.7)) "truncated, not rounded")
|
|
(is (= 1 (zoom-of 0)))
|
|
(is (= 1 (zoom-of -4)))
|
|
(is (= 1 (zoom-of nil)) "an absent zoom is 1:1")
|
|
(is (= 1 (zoom-of 1.9)))))
|
|
|
|
(deftest a-lower-picture-rate-changes-the-poses-and-not-the-length
|
|
;; THE SYNC RULE, in the arithmetic. 48 frames at 24fps is two seconds; at a
|
|
;; 12fps picture rate it is still 48 frames and two seconds, holding 24 poses.
|
|
;; If :frames ever tracks :poses here, every export at a reduced picture rate
|
|
;; comes out half length with the audio sliding off it.
|
|
(let [p (export/plan {:clip (a-clip {:frames 48 :fps 24}) :symbol :main
|
|
:picture-fps 12})]
|
|
(is (= 48 (:frames p)) "the frame count does not move")
|
|
(is (= 2 (:seconds p)) "and neither does the duration")
|
|
(is (= 24 (:poses p)) "but the picture holds half as many poses"))
|
|
(testing "a picture rate at or above the clip's rate changes nothing"
|
|
(doseq [fps [24 48 nil]]
|
|
(let [p (export/plan {:clip (a-clip {:frames 48 :fps 24}) :symbol :main
|
|
:picture-fps fps})]
|
|
(is (= 48 (:poses p)) (str "picture-fps " fps))))))
|
|
|
|
(deftest plan-of-a-symbol-that-is-not-there-is-nothing
|
|
(is (nil? (export/plan {:clip (a-clip {}) :symbol :nope :zoom 1}))))
|
|
|
|
;; ---- the walk ----
|
|
|
|
(deftest every-frame-is-emitted-once-and-in-order
|
|
(async done
|
|
(-> (run!* (a-clip {:frames 7}))
|
|
(.then (fn [{:keys [frames spec]}]
|
|
(is (= (range 7) (map :i frames)) "0..6, in order, no gaps")
|
|
(is (= 7 (:frames spec)) "and the sink was told how many to expect")
|
|
(done)))
|
|
(.catch (fn [e] (is false (str "threw: " e)) (done))))))
|
|
|
|
(deftest a-lower-picture-rate-still-emits-every-frame
|
|
;; THE SYNC RULE, in the walk — the assertion that matters most in this file.
|
|
;; The poses repeat; the frames do not thin out.
|
|
(async done
|
|
(-> (run!* (a-clip {:frames 12 :fps 24}) :picture-fps 8)
|
|
(.then (fn [{:keys [frames spec]}]
|
|
(is (= 12 (count frames))
|
|
"a 12-frame timeline exports 12 frames at any picture rate")
|
|
(is (= (range 12) (map :i frames)))
|
|
(is (= 24 (:fps spec))
|
|
"and the file's rate is the CLIP's, not the picture rate")
|
|
(done)))
|
|
(.catch (fn [e] (is false (str "threw: " e)) (done))))))
|
|
|
|
(deftest the-spec-carries-the-unzoomed-stage-and-the-zoom
|
|
;; The sink multiplies; it is not handed a pre-multiplied size. `frames/exporter`
|
|
;; passes all three to `png/encoder`, which is where the zoom is applied.
|
|
(async done
|
|
(-> (run!* (a-clip {:w 320 :h 200}) :zoom 4)
|
|
(.then (fn [{:keys [spec]}]
|
|
(is (= 320 (:width spec)) "stage width, before zoom")
|
|
(is (= 200 (:height spec)))
|
|
(is (= 4 (:zoom spec)))
|
|
(is (= "t" (:name spec)))
|
|
(is (= pal/rgb (:ramp spec)))
|
|
(done)))
|
|
(.catch (fn [e] (is false (str "threw: " e)) (done))))))
|
|
|
|
(deftest progress-counts-completed-frames-against-the-total
|
|
;; `[done total]`, one-based on done, so a readout can say "3 of 7" and reach
|
|
;; "7 of 7" at the end rather than stopping at 6.
|
|
(async done
|
|
(-> (run!* (a-clip {:frames 5}))
|
|
(.then (fn [{:keys [progress]}]
|
|
(is (= [[1 5] [2 5] [3 5] [4 5] [5 5]] progress))
|
|
(done)))
|
|
(.catch (fn [e] (is false (str "threw: " e)) (done))))))
|
|
|
|
(deftest the-raster-is-one-reused-buffer
|
|
;; The protocol documents this and `frames/exporter` depends on knowing it: the
|
|
;; walk hands back the SAME raster every frame. If this ever stops being true
|
|
;; the contract has loosened and the warnings about encoding late are stale.
|
|
(async done
|
|
(-> (run!* (a-clip {:frames 4}))
|
|
(.then (fn [{:keys [rasters]}]
|
|
(is (= 4 (count rasters)))
|
|
(is (apply = (map :buf rasters))
|
|
"every frame arrived in the same buffer")
|
|
(done)))
|
|
(.catch (fn [e] (is false (str "threw: " e)) (done))))))
|
|
|
|
(deftest the-result-is-the-sink-s
|
|
;; `run!` returns what `finish!` produced, untouched — the walk does not decide
|
|
;; what the artefact is called.
|
|
(async done
|
|
(-> (run!* (a-clip {:frames 2}))
|
|
(.then (fn [{:keys [result]}]
|
|
(is (= {:filename "t.zip" :blob :a-blob} result))
|
|
(done)))
|
|
(.catch (fn [e] (is false (str "threw: " e)) (done))))))
|
|
|
|
(deftest exporting-a-symbol-that-is-not-there-is-an-error
|
|
;; And it names the timelines that ARE there, because the id came from a UI and
|
|
;; "no such timeline" alone does not say what went wrong.
|
|
(let [thrown (try (export/run! {:clip (a-clip {}) :symbol :nope :store {}
|
|
:palette pal/index-of :ramp pal/rgb}
|
|
(recorder (atom {})) nil)
|
|
nil
|
|
(catch :default e e))]
|
|
(is (some? thrown) "it throws rather than resolving to an empty archive")
|
|
(is (= [:main] (:symbols (ex-data thrown))))))
|
|
|
|
(deftest a-symbol-is-exported-by-being-rooted-at-its-own-frame-space
|
|
;; "Render that symbol" is rooting the resolver at it, so the walk's length is
|
|
;; the SYMBOL's frame count and not the clip's.
|
|
(async done
|
|
(let [c (assoc-in (a-clip {:frames 30})
|
|
[:symbols :sym]
|
|
(a-symbol 4))]
|
|
(-> (run!* c :symbol :sym)
|
|
(.then (fn [{:keys [frames spec]}]
|
|
(is (= 4 (count frames)) "the symbol's four frames, not the clip's 30")
|
|
(is (= 4 (:frames spec)))
|
|
(done)))
|
|
(.catch (fn [e] (is false (str "threw: " e)) (done)))))))
|
|
|
|
;; ---- isolating one placement ----
|
|
|
|
(def ^:private p1 #uuid "11111111-1111-4111-8111-111111111111")
|
|
(def ^:private p2 #uuid "22222222-2222-4222-8222-222222222222")
|
|
(def ^:private v1 #uuid "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa")
|
|
|
|
(defn- staged
|
|
"A stage: two placements of one symbol under a root, and a loose rect that
|
|
belongs to neither.
|
|
|
|
`:voice?` adds an audio track linked to the first placement. It is OFF by
|
|
default because a placed track sends `mix/buffer!` to fetch its footage, which
|
|
under node is a failed URL parse rather than a mix — so the walk is driven over
|
|
a silent stage, and the audio's isolation is asserted on `isolate` itself, where
|
|
it needs no clock."
|
|
[& {:keys [voice?]}]
|
|
{:name "stage" :fps 30 :width 8 :height 6
|
|
:symbols
|
|
{:main
|
|
{:frames 12
|
|
:nodes (cond-> {:root {:id :root :kind :group :z "a1"}
|
|
p1 {:id p1 :kind :instance :parent :root :z "a1"
|
|
:name "left" :source {:symbol :sym/face}
|
|
:channels {[:xform :pos] (ch/framed [0 0])}}
|
|
p2 {:id p2 :kind :instance :parent :root :z "a2"
|
|
:name "right" :source {:symbol :sym/face}
|
|
:channels {[:xform :pos] (ch/framed [4 0])}}
|
|
:loose (assoc (poly :loose "a4" [0 0 1 0 1 1] :brow)
|
|
:parent :root)}
|
|
voice? (assoc v1 {:id v1 :kind :audio :parent :root :z "a3"
|
|
:linked-to p1 :source {:footage "f"} :span [0 12]}))}
|
|
:sym/face (a-symbol 6)}})
|
|
|
|
(deftest isolating-keeps-the-placement-its-chain-and-its-voice
|
|
(let [sym (clip/symbol (staged :voice? true) :main)
|
|
kept (set (keys (:nodes (export/isolate sym p1))))]
|
|
(is (contains? kept p1) "the placement itself")
|
|
(is (contains? kept :root) "and the root it hangs from, or it would move")
|
|
(is (contains? kept v1) "and the voice linked to it")
|
|
(testing "and nothing else"
|
|
(is (not (contains? kept p2)) "the sibling placement goes")
|
|
(is (not (contains? kept :loose)) "and so does everything unrelated")
|
|
(is (= #{:root p1 v1} kept)))))
|
|
|
|
(deftest isolating-the-other-placement-drops-the-first-s-voice
|
|
;; The voice is linked to p1, so isolating p2 must not carry it: an isolated
|
|
;; export that kept every track would have the whole stage's sound over one face.
|
|
(let [sym (clip/symbol (staged :voice? true) :main)
|
|
kept (set (keys (:nodes (export/isolate sym p2))))]
|
|
(is (= #{:root p2} kept))))
|
|
|
|
(deftest isolating-nothing-leaves-the-symbol-alone
|
|
(let [sym (clip/symbol (staged :voice? true) :main)]
|
|
(is (= sym (export/isolate sym nil)))
|
|
(testing "and so does isolating a node that is not there"
|
|
(is (= sym (export/isolate sym (random-uuid)))))))
|
|
|
|
(deftest isolating-keeps-the-frame-space
|
|
;; What makes this different from exporting the symbol the placement plays: the
|
|
;; STAGE's length and rate are what comes out, not the drawing's own.
|
|
(let [c (staged)]
|
|
(is (= 12 (:frames (export/plan {:clip c :symbol :main :isolate p1}))))
|
|
(is (= 6 (:frames (export/plan {:clip c :symbol :sym/face})))
|
|
"the drawing's own frame space is its own")
|
|
(is (= 30 (:fps (export/plan {:clip c :symbol :main :isolate p1}))))))
|
|
|
|
(deftest an-isolated-walk-emits-the-stage-s-frames
|
|
(async done
|
|
(-> (run!* (staged) :isolate p1)
|
|
(.then (fn [{:keys [frames spec]}]
|
|
(is (= 12 (count frames)) "the stage's twelve, not the symbol's six")
|
|
(is (= (range 12) (map :i frames)))
|
|
(is (= 12 (:frames spec)))
|
|
(done)))
|
|
(.catch (fn [e] (is false (str "threw: " e)) (done))))))
|
|
|
|
(deftest an-isolated-export-draws-less-than-the-whole-stage
|
|
;; The observable consequence, on the pixels: with one of two placements removed
|
|
;; the stage cannot be drawing the same picture. Asserted as a count of non-bg
|
|
;; pixels rather than as an image, which is what `domain/raster` is for.
|
|
(async done
|
|
(let [painted (fn [{:keys [rasters]}]
|
|
;; every frame arrives in the same buffer, so this is the last
|
|
;; frame's count; it only has to differ, not to be a number.
|
|
(count (remove zero? (array-seq (:buf (last rasters))))))]
|
|
(-> (js/Promise.all #js [(run!* (staged))
|
|
(run!* (staged) :isolate p1)])
|
|
(.then (fn [[whole one]]
|
|
(is (pos? (painted whole)) "the whole stage draws something")
|
|
(is (< (painted one) (painted whole))
|
|
"and one placement alone draws strictly less")
|
|
(done)))
|
|
(.catch (fn [e] (is false (str "threw: " e)) (done)))))))
|