268 lines
12 KiB
Clojure
268 lines
12 KiB
Clojure
(ns arthur.export-test
|
|
"The output-grid frame walk and duration, using a recording sink."
|
|
(: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-output-rate-keeps-native-duration
|
|
(let [doc (assoc-in (a-clip {:frames 48 :fps 24}) [:symbols :main :fps] 24)
|
|
p (export/plan {:clip (clip/set-fps doc 12) :symbol :main})]
|
|
(is (= 24 (:frames p)))
|
|
(is (= 12 (:fps p)))
|
|
(is (= 2 (:seconds p)))))
|
|
|
|
(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-output-rate-emits-the-selected-grid
|
|
(async done
|
|
(-> (run!* (clip/set-fps (a-clip {:frames 12 :fps 24}) 8))
|
|
(.then (fn [{:keys [frames spec]}]
|
|
(is (= (range 4) (map :i frames)))
|
|
(is (= 8 (:fps spec)))
|
|
(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)))))))
|