Add PNG sequence export and uuid-keyed stage placements

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JBYKfeMPQTK1WNcgcw41o
This commit is contained in:
Olive Vaughn 2026-09-28 20:47:10 -04:00
parent 3058b9a5f2
commit e22ee600b9
31 changed files with 2642 additions and 178 deletions

View file

@ -0,0 +1,50 @@
(ns arthur.domain.crc32-test
"CRC-32 against PUBLISHED vectors, and not against itself.
The failure this guards is the quiet one. A wrong polynomial, a table built
with the bits the unreflected way round, a missing final complement — each of
them produces a checksum that is stable, self-consistent, and rejected by every
PNG and ZIP reader on earth as \"this file is corrupt\". Nothing inside this
codebase can notice that, because both sides of every internal comparison would
be wrong together. So the expectations below are the standard's own values —
`0xcbf43926` for \"123456789\" is the check value printed in the CRC-32 spec —
cross-checked here against node's `zlib.crc32`."
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.crc32 :as crc32]))
(defn- ascii [^String s]
(let [out (js/Uint8Array. (.-length s))]
(dotimes [i (.-length s)] (aset out i (.charCodeAt s i)))
out))
(deftest known-vectors
;; The published check values. If any of these moves, the table is wrong and
;; every PNG and zip this tool writes is unreadable.
(doseq [[s expected] {"" 0x00000000
"a" 0xe8b7be43
"123456789" 0xcbf43926
"The quick brown fox jumps over the lazy dog" 0x414fa339}]
(is (= expected (crc32/of (ascii s)))
(str (pr-str s) " -> 0x"
(.padStart (.toString (crc32/of (ascii s)) 16) 8 "0")))))
(deftest it-is-unsigned
;; The field is a u32 in both formats, and ClojureScript's bit ops are signed.
;; A CRC whose top bit is set must not arrive here negative, or `u32!` writes
;; the two's-complement bytes of a negative number into the header.
(let [high (filter #(>= (crc32/of (ascii (str %))) 0x80000000) (range 512))]
(is (seq high) "no high-bit CRC in the sample, so this asserts nothing")
(doseq [n (take 8 high)]
(is (<= 0 (crc32/of (ascii (str n))) 0xffffffff)))))
(deftest the-range-arity-reads-only-the-range
;; PNG chunks rely on this: the CRC covers the type and payload but NOT the
;; leading length, so `of` has to be able to start partway in.
(let [whole (ascii "..123456789..")]
(is (= 0xcbf43926 (crc32/of whole 2 11))))
(testing "and agrees with a copy of the same bytes"
(let [whole (ascii "xx123456789")]
(is (= (crc32/of (ascii "123456789")) (crc32/of whole 2 11))))))
(deftest an-empty-range-is-the-empty-crc
(is (= 0 (crc32/of (ascii "abc") 1 1))))

View file

@ -99,6 +99,36 @@
(is (thrown-with-msg? ExceptionInfo #"cannot contain ~"
(leaf/segment (keyword "a~b")))))
(deftest a-placement-id-is-a-uuid-and-comes-back-one
;; A symbol instance is keyed by a uuid — `demo/stage/compose` has the argument
;; for why — and a leaf path is text, so reading one back has to return the id it
;; named and not a keyword that merely prints the same. A placement keyed
;; `:8f594d72-...` instead of `#uuid "8f594d72-..."` is a document that looks
;; right in a log and resolves nothing: `:linked-to` dangles and an export target
;; matches no node, with no error anywhere.
(let [u #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218"
c (one-timeline {u {:id u :kind :symbol :of :sym/face-8625 :parent nil
:z "a1" :name "8625 bottom left"}})
ls (leaf/leaves :c1 c)]
(is (contains? ls (str "clip/c1/timeline/main/node/" u))
"written plainly, with no sigil")
(is (= c (leaf/clip :c1 ls)))
(is (uuid? (first (keys (get-in (leaf/clip :c1 ls) [:timelines :main :nodes])))))))
(deftest only-a-whole-canonical-uuid-reads-as-one
;; The id encoding decides by SHAPE, so the boundaries of that shape are the
;; whole of the rule: an id that merely CONTAINS a uuid, or is one character off,
;; or is upper-case, is an ordinary keyword and has to stay one.
(testing "these are uuids"
(is (uuid? (leaf/unsegment "8f594d72-a97f-4a32-82fd-08d1670a2218"))))
(testing "and these are not"
(doseq [s ["face-8f594d72-a97f-4a32-82fd-08d1670a2218"
"8f594d72-a97f-4a32-82fd-08d1670a2218-left"
"8f594d72-a97f-4a32-82fd-08d1670a221"
"8F594D72-A97F-4A32-82FD-08D1670A2218"
"not-a-uuid" "main"]]
(is (keyword? (leaf/unsegment s)) (str s " must stay a keyword")))))
(deftest another-clips-leaves-are-ignored-rather-than-merged
;; A project's whole leaf map can be handed in for one clip, which is what makes
;; a two-clip project one fetch.

View file

@ -0,0 +1,227 @@
(ns arthur.domain.png-test
"The encoder, asserted by DECODING what it wrote.
This file reads the bytes back — chunk framing, CRCs, inflate, un-filter — and
compares the recovered pixels against the ramp expansion they are supposed to
be. Anything weaker would not be worth writing. The claim `domain/png` makes is
bit-exactness: the file holds the bytes `raster/draw-ops!` produced, expanded
through the ramp at an integer zoom, with nothing resampling or smoothing on the
way. A test that only checked the header would pass on a file whose every pixel
was wrong, and a test that only checked it parses would pass on one the encoder
and this test agreed to get wrong together. So the inflate here is node's own
`DecompressionStream` and the un-filter is written out longhand from the spec.
It is also the test that catches the encoder not running at all. The first
version of `deflate!` piped a `Blob` instead of the Blob's `.stream`, so every
export died at the first frame with `pipeThrough is not a function` — reachable
only by encoding something, which nothing under node did until this file."
(:require [cljs.test :refer [deftest is testing async]]
[arthur.domain.crc32 :as crc32]
[arthur.domain.png :as png]
[arthur.domain.raster :as raster]))
;; A ramp with no two entries alike, so a pixel that lands on the wrong index
;; cannot pass by holding a colour that happens to match its neighbour's.
(def ^:private ramp
(mapv (fn [i] [(* 10 i) (+ 1 (* 10 i)) (+ 2 (* 10 i))]) (range 16)))
(defn- chunks
"The file's chunks as [{:type :data :crc :crc-ok?}], after the signature."
[^js b]
(let [u32 (fn [at] (-> (+ (bit-shift-left (aget b at) 24)
(bit-shift-left (aget b (+ at 1)) 16)
(bit-shift-left (aget b (+ at 2)) 8)
(aget b (+ at 3)))
(unsigned-bit-shift-right 0)))]
(loop [at 8 acc []]
(if (>= at (.-length b))
acc
(let [n (u32 at)
tag (apply str (map #(char (aget b (+ at 4 %))) (range 4)))
data (.subarray b (+ at 8) (+ at 8 n))
crc (u32 (+ at 8 n))]
(recur (+ at 12 n)
(conj acc {:type tag :data data :crc crc
;; Over the TYPE and payload, not the length — which
;; is the detail a hand-rolled chunk writer gets wrong.
:crc-ok? (= crc (crc32/of b (+ at 4) (+ at 8 n)))})))))))
(defn- inflate!
[^js bytes]
(-> (js/Response. (.pipeThrough (.stream (js/Blob. #js [bytes]))
(js/DecompressionStream. "deflate")))
(.arrayBuffer)
(.then #(js/Uint8Array. %))))
(defn- unfilter
"Undo the per-row filters of an inflated PNG. Returns the rows as vectors of
bytes. Only filter 0 (None) and 2 (Up) are handled; anything else is a bug in
the encoder rather than something to be lenient about, so it throws."
[^js raw w h]
(let [stride (* w 3)]
(loop [y 0 prev (vec (repeat stride 0)) acc []]
(if (= y h)
acc
(let [at (* y (inc stride))
f (aget raw at)
_ (when-not (#{0 2} f)
(throw (ex-info "unexpected PNG filter type" {:row y :filter f})))
row (mapv (fn [i]
(let [s (aget raw (+ at 1 i))]
(bit-and (if (= f 2) (+ s (nth prev i)) s) 0xff)))
(range stride))]
(recur (inc y) row (conj acc row)))))))
(defn- decoded
"Promise of {:w :h :rows}, the encoder's output read back as RGB rows."
[^js file w h zoom]
(let [cs (chunks file)
ihdr (:data (first (filter #(= "IHDR" (:type %)) cs)))
idat (:data (first (filter #(= "IDAT" (:type %)) cs)))]
(-> (inflate! idat)
(.then (fn [raw]
{:chunks cs
:ihdr ihdr
:rows (unfilter raw (* w zoom) (* h zoom))})))))
(defn- try!
"Call `f`, turning a SYNCHRONOUS throw into a rejected promise.
`png/encoder`'s returned function does the whole of its pixel work before it
returns a promise, so a failure in there throws rather than rejecting, and an
uncaught throw out of a `deftest` body aborts the entire node suite at this
namespace — which is how the `pipeThrough` bug presented: 250 unrelated tests
stopped reporting. Routing it through a rejection keeps the blast radius to the
one test and leaves the `.catch` below as the single place failures land."
[f]
(try (js/Promise.resolve (f))
(catch :default e (js/Promise.reject e))))
(defn- ras
"A small raster whose indices are all different from each other, so a
transposed or off-by-one read cannot look right."
[w h]
(let [r (raster/make w h)]
(dotimes [y h]
(dotimes [x w]
(aset (:buf r) (+ (* y w) x) (mod (+ 1 x (* 3 y)) 16))))
r))
(deftest the-file-is-a-png
(async done
(let [r (ras 4 3)]
(-> (try! #((png/encoder 4 3 1) r ramp))
(.then (fn [file]
(testing "signature"
(is (= [0x89 0x50 0x4e 0x47 0x0d 0x0a 0x1a 0x0a]
(mapv #(aget file %) (range 8)))))
(let [cs (chunks file)]
(testing "chunk order: IHDR first, IEND last, one IDAT"
(is (= ["IHDR" "IDAT" "IEND"] (mapv :type cs))))
(testing "every chunk's CRC covers type and payload"
(doseq [c cs]
(is (:crc-ok? c) (str (:type c) " CRC")))))
(done)))
(.catch (fn [e] (is false (str "encode threw: " e)) (done)))))))
(deftest the-ihdr-declares-truecolour-at-the-zoomed-size
(async done
(-> (-> (try! #((png/encoder 4 3 3) (ras 4 3) ramp))
(.then #(decoded % 4 3 3)))
(.then (fn [{:keys [ihdr]}]
;; Width and height are the ZOOMED size: the zoom is baked into
;; the file, not left as a flag for a reader to honour.
(is (= 12 (aget ihdr 3)) "width")
(is (= 9 (aget ihdr 7)) "height")
(is (= 8 (aget ihdr 8)) "bit depth")
(is (= 2 (aget ihdr 9)) "colour type 2, truecolour")
(is (= 0 (aget ihdr 10)) "compression: deflate")
(is (= 0 (aget ihdr 11)) "filter method: adaptive")
(is (= 0 (aget ihdr 12)) "no interlace")
(done)))
(.catch (fn [e] (is false (str "threw: " e)) (done))))))
(deftest every-pixel-is-its-ramp-entry
;; The bit-exactness claim, at zoom 1: no filtering, no subsampling, no colour
;; management — the byte in the buffer indexes the ramp and the ramp's RGB is
;; what lands in the file.
(async done
(let [r (ras 5 4)]
(-> (-> (try! #((png/encoder 5 4 1) r ramp))
(.then #(decoded % 5 4 1)))
(.then (fn [{:keys [rows]}]
(is (= 4 (count rows)) "one row per source row")
(doseq [y (range 4) x (range 5)]
(let [want (nth ramp (aget (:buf r) (+ (* y 5) x)))
got [(nth (nth rows y) (* 3 x))
(nth (nth rows y) (+ 1 (* 3 x)))
(nth (nth rows y) (+ 2 (* 3 x)))]]
(is (= want got) (str "pixel " x "," y))))
(done)))
(.catch (fn [e] (is false (str "threw: " e)) (done)))))))
(deftest the-zoom-duplicates-pixels-and-interpolates-nothing
;; The other half of the claim. At zoom 3 each source pixel must be a 3x3 block
;; of the IDENTICAL colour. Any smoothing shows up as a block whose corners
;; differ from its centre, and any colour not in the ramp is interpolation.
(async done
(let [zoom 3 w 5 h 4
r (ras w h)]
(-> (-> (try! #((png/encoder w h zoom) r ramp))
(.then #(decoded % w h zoom)))
(.then (fn [{:keys [rows]}]
(is (= (* h zoom) (count rows)) "one row per zoomed row")
(doseq [y (range h) x (range w)]
(let [want (nth ramp (aget (:buf r) (+ (* y w) x)))
block (for [dy (range zoom) dx (range zoom)]
(let [row (nth rows (+ (* y zoom) dy))
px (* 3 (+ (* x zoom) dx))]
[(nth row px) (nth row (+ px 1)) (nth row (+ px 2))]))]
(is (= #{want} (set block))
(str "the " zoom "x" zoom " block at " x "," y
" should be one colour: " (pr-str (set block))))))
(testing "and no colour outside the ramp appears anywhere"
(let [seen (set (for [row rows x (range (/ (count row) 3))]
[(nth row (* 3 x)) (nth row (+ 1 (* 3 x)))
(nth row (+ 2 (* 3 x)))]))]
(is (empty? (remove (set ramp) seen))
(pr-str (remove (set ramp) seen)))))
(done)))
(.catch (fn [e] (is false (str "threw: " e)) (done)))))))
(deftest a-flat-frame-costs-almost-nothing
;; Why filter Up is chosen, made an assertion rather than a claim in a comment:
;; a single-colour stage at zoom 4 is duplicate scanlines, Up turns all but the
;; first into runs of zeros, and deflate takes those to nearly nothing. If this
;; ratio collapses, the filter or the row order has changed and every export
;; got many times bigger.
(async done
(let [w 64 h 64 zoom 4
flat (raster/clear! (raster/make w h) 7)]
(-> (try! #((png/encoder w h zoom) flat ramp))
(.then (fn [file]
(let [raw (* w zoom h zoom 3)]
(is (< (.-length file) (/ raw 100))
(str (.-length file) " bytes for " raw " raw")))
(done)))
(.catch (fn [e] (is false (str "threw: " e)) (done)))))))
(deftest the-encoder-is-reusable-across-frames
;; The export loop holds ONE encoder and feeds it every frame, because the
;; scratch inside it is megabytes. So the scratch must not leak between frames:
;; encoding a, then b, then a again has to give byte-identical files for the two
;; a's. A `prev` row left dirty from the previous frame fails exactly here.
(async done
(let [enc (png/encoder 5 4 2)
a (ras 5 4)
b (raster/clear! (raster/make 5 4) 9)
hex #(apply str (map (fn [i] (.toString (aget % i) 16)) (range (.-length %))))]
(-> (.then (try! #(enc a ramp))
(fn [first-a]
(-> (try! #(enc b ramp))
(.then (fn [_] (try! #(enc a ramp))))
(.then (fn [second-a]
(is (= (hex first-a) (hex second-a))
"the same raster encoded twice, around another frame")
(done))))))
(.catch (fn [e] (is false (str "threw: " e)) (done)))))))

View file

@ -1,5 +1,5 @@
(ns arthur.domain.symbol-test
(:require [cljs.test :refer [deftest is]]
(:require [cljs.test :refer [deftest is testing]]
[arthur.demo.stage :as stage]
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
@ -40,21 +40,48 @@
(is (= [[[:right :mark] 150]] (at 5)))
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))))
(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 [:timelines :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 (:timelines document)))))
(is (= :sym/face-8625 (get-in document [:timelines :main :nodes :left :of])))
(is (= :sym/face-8625 (get-in document [:timelines :main :nodes :right :of])))
(is (= :sym/face-8625 (:of (placement document :left))))
(is (= :sym/face-8625 (:of (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 #{:symbol} :kind val)
(get-in document [:timelines :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 (:of (val %))) symbols))
(is (every? #(string? (:name (val %))) symbols))
(is (= 7 (count (distinct (map #(:name (val %)) symbols))))))))
(is (= 7 (count (filter #(= :symbol (:kind %))
(vals (get-in document [:timelines :main :nodes]))))))
(is (= [48 280] (get-in document [:timelines :main :nodes :right :span])))
(let [scale (get-in document [:timelines :main :nodes :left
:channels [:xform :scale]])
anchor (get-in document [:timelines :main :nodes :left
:channels [:xform :anchor] :value])
pos (get-in document [:timelines :main :nodes :left
:channels [:xform :pos]])
(is (= [48 280] (:span (placement document :right))))
(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)]
(is (= [160 100] anchor) "the source center becomes a stored pivot")
(is (= [-120 -60] start-pos))
@ -68,12 +95,17 @@
(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"))))
(is (= :right (get-in document [:timelines :main :nodes :voice-right :linked-to])))
(is (= [48 260] (get-in document [:timelines :main :nodes :voice-right :span])))
(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] (:span (placement document :voice-right))))
(is (= 0.5 (ch/value-at
(get-in document [:timelines :main :nodes :voice-right
:channels [:audio :gain]]) 54)))
(get-in (placement document :voice-right)
[:channels [:audio :gain]]) 54)))
(is (< -0.8 (ch/value-at
(get-in document [:timelines :main :nodes :voice-right
:channels [:audio :pan]]) 110) 0.7))
(get-in (placement document :voice-right)
[:channels [:audio :pan]]) 110) 0.7))
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))))

View file

@ -0,0 +1,164 @@
(ns arthur.domain.zip-test
"The container, asserted field by field against the format.
Same reasoning as `png-test`: the only reader that matters is someone else's,
so an assertion that the writer agrees with itself is worth nothing. Every
expectation here is a number the ZIP specification fixes — the four signatures,
method 0, the offsets in the central directory, the MS-DOS date encoding — and
the test parses the archive back out of the bytes rather than being handed the
intermediate values.
`archive` takes its timestamp as an argument precisely so this file can exist:
with the clock pinned, the same entries produce the same bytes, and the whole
archive is comparable."
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.crc32 :as crc32]
[arthur.domain.zip :as zip]))
(defn- bytes-of [^String s]
(let [out (js/Uint8Array. (.-length s))]
(dotimes [i (.-length s)] (aset out i (.charCodeAt s i)))
out))
(defn- flatten-parts
"The vector of parts `archive` returns, as one buffer — which is what a `Blob`
of them would be on disk, and therefore what a reader sees."
[parts]
(let [n (reduce + (map #(.-length ^js %) parts))
out (js/Uint8Array. n)]
(reduce (fn [at ^js p] (.set out p at) (+ at (.-length p))) 0 parts)
out))
(defn- u16 [^js b at]
(+ (aget b at) (bit-shift-left (aget b (+ at 1)) 8)))
(defn- u32 [^js b at]
(-> (+ (aget b at)
(bit-shift-left (aget b (+ at 1)) 8)
(bit-shift-left (aget b (+ at 2)) 16)
(* 0x1000000 (aget b (+ at 3))))
(js/Math.round)))
(defn- ascii-at [^js b at n]
(apply str (map #(char (aget b (+ at %))) (range n))))
(def ^:private at (js/Date. 2026 8 28 14 30 20)) ; 2026-09-28 14:30:20
(def ^:private entries
[{:name "take/0001.png" :data (bytes-of "first-frame-bytes")}
{:name "take/0002.png" :data (bytes-of "second")}
{:name "take/audio.wav" :data (bytes-of "RIFF....WAVEfmt ")}])
(def ^:private built (delay (flatten-parts (zip/archive entries at))))
(defn- locals
"Every local file header in the archive, parsed, in the order they appear."
[^js b]
(loop [at 0 acc []]
(if (or (>= (+ at 4) (.-length b)) (not= 0x04034b50 (u32 b at)))
acc
(let [n (u16 b 26)
nlen (u16 b (+ at 26))
elen (u16 b (+ at 28))
size (u32 b (+ at 18))]
(recur (+ at 30 nlen elen size)
(conj acc {:offset at
:method (u16 b (+ at 8))
:time (u16 b (+ at 10))
:date (u16 b (+ at 12))
:crc (u32 b (+ at 14))
:csize (u32 b (+ at 18))
:usize (u32 b (+ at 22))
:name (ascii-at b (+ at 30) nlen)
:data (.subarray b (+ at 30 nlen elen)
(+ at 30 nlen elen size))})))))
)
(defn- eocd
"The end-of-central-directory record, which is the last 22 bytes when there is
no archive comment."
[^js b]
(let [at (- (.-length b) 22)]
{:signature (u32 b at)
:entries (u16 b (+ at 8))
:dir-size (u32 b (+ at 12))
:dir-at (u32 b (+ at 16))}))
(deftest the-archive-is-readable-as-a-zip
(let [b (deref built)
e (eocd b)]
(testing "end of central directory"
(is (= 0x06054b50 (:signature e)))
(is (= 3 (:entries e)) "one record per entry"))
(testing "the central directory is where the record says it is"
(is (= 0x02014b50 (u32 b (:dir-at e))))
(is (= (- (.-length b) 22) (+ (:dir-at e) (:dir-size e)))
"directory ends exactly where the EOCD begins"))))
(deftest every-entry-is-stored-verbatim
;; Method 0, and the two size fields equal — the namespace's whole premise is
;; that the payloads are already compressed and must not be touched. A stray
;; deflate here would show up as csize != usize.
(let [ls (locals (deref built))]
(is (= 3 (count ls)))
(doseq [[l entry] (map vector ls entries)]
(is (= 0 (:method l)) (str (:name l) " is stored"))
(is (= (:name entry) (:name l)))
(is (= (.-length (:data entry)) (:usize l)) "uncompressed size")
(is (= (:usize l) (:csize l)) "stored, so the two sizes agree")
(testing "the bytes come back identical"
(is (= (vec (array-seq (:data entry))) (vec (array-seq (:data l)))))))))
(deftest every-crc-is-the-crc-of-the-payload
;; Written into the local header AND the central directory, and an unzip checks
;; both. They have to agree with each other and with the data.
(let [b (deref built)
ls (locals b)
dir-at (:dir-at (eocd b))]
(loop [i 0 at dir-at]
(when (< i (count ls))
(let [nlen (u16 b (+ at 28))
l (nth ls i)]
(is (= (crc32/of (:data (nth entries i))) (:crc l))
(str (:name l) " local CRC"))
(is (= (:crc l) (u32 b (+ at 16)))
(str (:name l) " central CRC matches local"))
(testing "and the directory points at the local header"
(is (= (:offset l) (u32 b (+ at 42)))))
(recur (inc i) (+ at 46 nlen (u16 b (+ at 30)) (u16 b (+ at 32)))))))))
(deftest the-same-entries-and-clock-give-the-same-bytes
;; What makes an export assertable at all, and what `at` is a parameter for.
(is (= (vec (array-seq (flatten-parts (zip/archive entries at))))
(vec (array-seq (flatten-parts (zip/archive entries at)))))))
(deftest the-clock-is-the-only-thing-that-moves
(let [other (js/Date. 2026 8 28 14 30 40)]
(is (not= (vec (array-seq (flatten-parts (zip/archive entries other))))
(vec (array-seq (deref built))))
"a different timestamp must reach the headers")))
(deftest dos-time-is-the-format-s-encoding
;; Year offset from 1980, and seconds in 5 bits so they land on even values.
(let [[date time] (zip/dos-time (js/Date. 2026 8 28 14 30 21))]
(is (= 2026 (+ 1980 (bit-shift-right date 9))) "year")
(is (= 9 (bit-and (bit-shift-right date 5) 0xf)) "month is one-based")
(is (= 28 (bit-and date 0x1f)) "day")
(is (= 14 (bit-shift-right time 11)) "hour")
(is (= 30 (bit-and (bit-shift-right time 5) 0x3f)) "minute")
(is (= 20 (* 2 (bit-and time 0x1f))) "seconds round down to even"))
(testing "a date before 1980 clamps rather than wrapping into a plausible year"
(let [[date _] (zip/dos-time (js/Date. 1970 0 1))]
(is (= 1980 (+ 1980 (bit-shift-right date 9)))))))
(deftest a-non-ascii-name-is-refused
;; Rather than written without the UTF-8 flag and arriving mojibake'd.
(is (thrown? js/Error
(zip/archive [{:name "také/0001.png" :data (bytes-of "x")}] at))))
(deftest an-empty-archive-is-still-a-valid-zip
(let [b (flatten-parts (zip/archive [] at))
e (eocd b)]
(is (= 22 (.-length b)) "just the EOCD")
(is (= 0x06054b50 (:signature e)))
(is (= 0 (:entries e)))))

View file

@ -0,0 +1,91 @@
(ns arthur.events.export-test
"The picker's round trip, which is the whole of a bug that read as a hung tab.
An export target is a timeline or a placement inside one. A symbol timeline's id
is NAMESPACED (`:sym/face-8625`) and a placement's is a UUID, and the panel puts
both into `<option value>`s and reads them back out of a change event. Writing
that value with `name` drops the `sym`, the id comes back `:face-8625`, it
matches no key in `:timelines`, and `export/run!` throws from inside re-frame's
`:do-fx` where nothing catches it: `:busy?` latches on and the readout sits at
\"frame 0 /\" forever with nothing in the status line.
So the pair is asserted directly, on both kinds, because `name` and `keyword`
are individually reasonable-looking and only wrong together."
(:require [cljs.test :refer [deftest is testing]]
[arthur.events.export :as export]))
(def ^:private a-uuid #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218")
(deftest every-kind-of-target-survives-the-round-trip
(doseq [t [{:timeline :main}
{:timeline :sym/face-8625}
{:timeline :main :isolate a-uuid}
{:timeline :sym/face-8625 :isolate a-uuid}]]
(let [back (export/target-id (export/target-value t))]
(is (= (:timeline t) (:timeline back))
(str (pr-str t) " -> " (pr-str (export/target-value t))))
(is (= (:isolate t) (:isolate back)))
(testing "and a placement comes back a uuid, not a string or a keyword"
(when (:isolate t)
(is (uuid? (:isolate back))))))))
(deftest the-value-keeps-the-namespace-and-marks-the-two-kinds
;; Spelled out, because these are the strings that end up in the DOM.
(is (= "t:main" (export/target-value {:timeline :main})))
(is (= "t:sym/face-8625" (export/target-value {:timeline :sym/face-8625})))
(is (= (str "n:main:" a-uuid)
(export/target-value {:timeline :main :isolate a-uuid}))))
(deftest a-whole-timeline-has-no-isolate
;; Switching from a placement back to the clip must clear it, or the new target
;; would still be filtered down to one node that may not even be in it.
(is (nil? (:isolate (export/target-id "t:main")))))
(deftest the-old-encoding-is-the-bug
;; A guard against someone "simplifying" this back to `name`. `name` is lossy on
;; exactly the ids the stage produces, and this states what that costs.
(is (not= :sym/face-8625 (keyword (name :sym/face-8625)))
"name/keyword loses the namespace, which is what broke the export")
(is (thrown? js/Error (name a-uuid))
"and a placement's id is not something `name` can take at all"))
;; ---- what the picker offers ----
(def ^:private clip
"A clip with one symbol in its library, placed twice, plus a decoy: a node that
is not a symbol must not show up as a face."
{:fps 30 :width 320 :height 200
:timelines
{:main {:frames 280
:nodes {:root {:id :root :kind :group :z "a1"}
#uuid "22222222-2222-4222-8222-222222222222"
{:id #uuid "22222222-2222-4222-8222-222222222222"
:kind :symbol :of :sym/face :parent :root :z "a2"
:name "8625 right"}
#uuid "11111111-1111-4111-8111-111111111111"
{:id #uuid "11111111-1111-4111-8111-111111111111"
:kind :symbol :of :sym/face :parent :root :z "a1"
:name "8625 left"}
:a-rect {:id :a-rect :kind :rect :parent :root :z "a3"}}}
:sym/face {:frames 40 :nodes {:root {:id :root :kind :group :z "a1"}}}}})
(deftest the-picker-offers-the-clip-the-drawing-and-every-placement
(let [ts (export/targets clip)]
(is (= ["main (the clip)" "face" "8625 left" "8625 right"] (mapv :label ts))
"the clip, then the library, then the placements")
(testing "the placements isolate a node on :main and the library ones do not"
(is (= [nil nil] (mapv :isolate (take 2 ts))))
(is (every? uuid? (mapv :isolate (drop 2 ts))))
(is (every? #(= :main (:timeline %)) (drop 2 ts))))
(testing "ordered by label, because a uuid sorts at random"
(is (= ["8625 left" "8625 right"] (mapv :label (drop 2 ts)))))
(testing "and a node that is not a symbol is not a placement"
(is (not-any? #{"a-rect"} (map :label ts))))))
(deftest every-offered-target-round-trips
;; The picker and the encoding asserted against each other, so neither can drift
;; into offering something that cannot be selected.
(doseq [t (export/targets clip)]
(let [norm #(merge {:isolate nil} (select-keys % [:timeline :isolate]))
back (export/target-id (export/target-value t))]
(is (= (norm t) (norm back)) (pr-str t)))))

View file

@ -0,0 +1,214 @@
(ns arthur.export.frames-test
"The archive a frame-sequence export produces: its manifest, and its determinism.
The sink is driven through the `Exporter` protocol DIRECTLY here rather than
through `export/run!`, because what this namespace decides is separable from the
walk that feeds it: entry names, the zero-padding an NLE's importer looks for,
whether the sound is in the archive, and whether the same frames give the same
bytes. `export-test` covers the walk, with a sink that only records.
The zip is read back with a small local reader rather than the field-by-field
one in `zip-test`. They are asking different questions — that file is about the
container conforming to the format, this one is about the MANIFEST — and a
reader shared between them would have to serve both and would make neither
obvious."
(:require [cljs.test :refer [deftest is testing async]]
[arthur.domain.png :as png]
[arthur.export :as export]
[arthur.export.frames :as frames]
[arthur.domain.raster :as raster]))
(def ^:private ramp
(mapv (fn [i] [(mod (* 37 i) 256) (mod (* 91 i) 256) (mod (* 17 i) 256)]) (range 16)))
(def ^:private at (js/Date. 2026 8 28 14 30 20))
(defn- fake-audio
"Enough of an `AudioBuffer` for `mix/wav-bytes`, which is all this needs.
`AudioBuffer` is a browser type and the rest of the export stack is asserted
under node on purpose, so the seam is the four accessors wav-bytes actually
reaches for."
[frames rate]
(let [data (js/Float32Array. frames)]
(dotimes [i frames] (aset data i (* 0.5 (js/Math.sin (/ i 8)))))
#js {:numberOfChannels 1
:length frames
:sampleRate rate
:getChannelData (fn [_] data)}))
(defn- entries-of
"The archive's entries as [{:name :size}], in order, read out of the local
headers. Names and sizes are all this file asserts on."
[^js b]
(let [u16 (fn [at] (+ (aget b at) (bit-shift-left (aget b (+ at 1)) 8)))
u32 (fn [at] (js/Math.round (+ (aget b at)
(bit-shift-left (aget b (+ at 1)) 8)
(bit-shift-left (aget b (+ at 2)) 16)
(* 0x1000000 (aget b (+ at 3))))))]
(loop [at 0 acc []]
(if (or (> (+ at 30) (.-length b)) (not= 0x04034b50 (u32 at)))
acc
(let [nlen (u16 (+ at 26))
elen (u16 (+ at 28))
size (u32 (+ at 18))
nm (apply str (map #(char (aget b (+ at 30 %))) (range nlen)))]
(recur (+ at 30 nlen elen size)
(conj acc {:name nm :size size
:data (.subarray b (+ at 30 nlen elen)
(+ at 30 nlen elen size))})))))))
(defn- bytes-of-blob
"A `js/Blob`'s bytes. `finish!` hands back a Blob because a download wants one."
[^js blob]
(-> (.arrayBuffer blob) (.then #(js/Uint8Array. %))))
(defn- run-sink!
"Drive `exporter` over `n` frames, painting frame i entirely with index
`(index i)`. Promise of `{:filename :bytes :entries}`.
ONE raster for the whole walk, repainted in place, because that is what
`export/run!` does and what `frame!` has to cope with."
[exporter {:keys [n w h zoom name audio index]}]
(let [ras (raster/make w h)]
(-> (js/Promise.resolve
(export/begin! exporter {:name name :width w :height h :zoom zoom
:fps 24 :frames n :ramp ramp :audio audio}))
(.then (fn [_]
(reduce (fn [chain i]
(.then chain
(fn [_]
(raster/clear! ras (index i))
(js/Promise.resolve (export/frame! exporter i ras)))))
(js/Promise.resolve)
(range n))))
(.then (fn [_] (export/finish! exporter)))
(.then (fn [{:keys [filename blob]}]
(-> (bytes-of-blob blob)
(.then (fn [bytes]
{:filename filename
:bytes bytes
:entries (entries-of bytes)}))))))))
(defn- run-default! [opts]
(run-sink! (frames/exporter at)
(merge {:n 3 :w 8 :h 6 :zoom 1 :name "take" :index identity} opts)))
(deftest the-frames-are-one-based-and-zero-padded
;; What an image-sequence importer looks for: a common stem, a fixed-width
;; counter, one extension. One-based because that is the convention, and padded
;; so nothing sorts 10 before 9.
(async done
(-> (run-default! {:n 3})
(.then (fn [{:keys [filename entries]}]
(is (= "take.zip" filename))
(is (= ["take/0001.png" "take/0002.png" "take/0003.png"]
(mapv :name entries)))
(done)))
(.catch (fn [e] (is false (str "threw: " e)) (done))))))
(deftest the-declared-frame-count-sets-the-width-not-the-frames-that-arrive
;; Four digits minimum, more when the count needs them, so a 12000-frame export
;; is 00001..12000 and still sorts. The width comes from the count `begin!` was
;; DECLARED — the one number that arrives from the spec rather than from the
;; walk — so one frame is enough to assert it and 12000 need not be rendered.
(async done
(let [ex (frames/exporter at)
ras (raster/make 4 4)]
(-> (js/Promise.resolve
(export/begin! ex {:name "big" :width 4 :height 4 :zoom 1 :fps 24
:frames 12000 :ramp ramp :audio nil}))
(.then (fn [_] (js/Promise.resolve (export/frame! ex 0 (raster/clear! ras 3)))))
(.then (fn [_] (export/finish! ex)))
(.then (fn [{:keys [blob]}] (bytes-of-blob blob)))
(.then (fn [bytes]
(is (= ["big/00001.png"] (mapv :name (entries-of bytes)))
"a 12000-frame export pads to five digits")
(done)))
(.catch (fn [e] (is false (str "threw: " e)) (done)))))))
(deftest the-sound-travels-with-the-picture
;; One archive, both tracks — they have to stay in sync all the way to the
;; cutting room, so a WAV entry appears exactly when there is audio.
(async done
(-> (run-default! {:n 2 :audio (fake-audio 2000 48000)})
(.then (fn [{:keys [entries]}]
(is (= ["take/audio.wav" "take/0001.png" "take/0002.png"]
(mapv :name entries)))
(testing "and the WAV is a RIFF header over 16-bit PCM"
(let [w (:data (first entries))]
(is (= "RIFF" (apply str (map #(char (aget w %)) (range 4)))))
(is (= "WAVE" (apply str (map #(char (aget w %)) (range 8 12)))))
;; 2000 mono frames at 16 bits, plus the 44-byte header.
(is (= (+ 44 (* 2000 2)) (:size (first entries))))))
(done)))
(.catch (fn [e] (is false (str "threw: " e)) (done))))))
(deftest a-silent-timeline-produces-no-wav
(async done
(-> (run-default! {:n 2 :audio nil})
(.then (fn [{:keys [entries]}]
(is (= ["take/0001.png" "take/0002.png"] (mapv :name entries)))
(done)))
(.catch (fn [e] (is false (str "threw: " e)) (done))))))
(deftest each-frame-is-encoded-before-the-raster-moves-on
;; THE HAZARD the protocol documents: the walk hands back the same buffer every
;; frame. A sink that kept the reference and encoded at `finish!` would write
;; the LAST frame N times, and every entry would be a valid PNG of the wrong
;; picture — which no structural check would notice. Three frames painted three
;; different flat colours must give three different payloads.
;;
;; Stronger than "the three differ": each entry is compared against the PNG of
;; the colour that frame was painted, encoded on its own. So frame 2 holding
;; frame 3's picture fails even though both are valid and distinct. (Their
;; LENGTHS are all equal, incidentally — three uniform fills deflate to the
;; same size and differ only in bytes, which is why size is no evidence here.)
(async done
(let [index (fn [i] (+ 3 i))
reference (fn [i]
(let [enc (png/encoder 8 6 1)]
(enc (raster/clear! (raster/make 8 6) (index i)) ramp)))]
(-> (js/Promise.all #js [(run-default! {:n 3 :index index})
(js/Promise.all (into-array (map reference (range 3))))])
(.then (fn [[{:keys [entries]} refs]]
(let [payloads (mapv (fn [e] (vec (array-seq (:data e)))) entries)]
(is (= 3 (count (distinct payloads)))
"three distinct frames, so nothing was encoded late")
(doseq [i (range 3)]
(is (= (vec (array-seq (nth refs i))) (nth payloads i))
(str "entry " (inc i) " holds the picture painted at frame " i))))
(done)))
(.catch (fn [e] (is false (str "threw: " e)) (done)))))))
(deftest the-same-frames-and-clock-give-the-same-archive
;; What `at` is a parameter for, stated as the assertion it exists to enable.
(async done
(-> (js/Promise.all
#js [(run-default! {:n 3 :index (fn [i] (+ 2 i))})
(run-default! {:n 3 :index (fn [i] (+ 2 i))})])
(.then (fn [[a b]]
(is (= (vec (array-seq (:bytes a))) (vec (array-seq (:bytes b))))
"byte for byte")
(done)))
(.catch (fn [e] (is false (str "threw: " e)) (done))))))
(deftest the-zoom-reaches-the-files
;; The sink passes width, height and zoom to the encoder; this is the assertion
;; that it passes the zoom at all rather than dropping it and writing 1:1.
(async done
(-> (js/Promise.all #js [(run-default! {:n 1 :w 8 :h 6 :zoom 1})
(run-default! {:n 1 :w 8 :h 6 :zoom 4})])
(.then (fn [[one four]]
;; The IHDR's width is at a fixed offset: 8 signature + 8 chunk
;; header, then a big-endian u32.
(let [ihdr-w (fn [{:keys [entries]}]
(let [d (:data (first entries))]
(+ (bit-shift-left (aget d 16) 24)
(bit-shift-left (aget d 17) 16)
(bit-shift-left (aget d 18) 8)
(aget d 19))))]
(is (= 8 (ihdr-w one)) "zoom 1")
(is (= 32 (ihdr-w four)) "zoom 4"))
(done)))
(.catch (fn [e] (is false (str "threw: " e)) (done))))))

View file

@ -0,0 +1,298 @@
(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-timeline
"One square under a `:root` group.
The group is not decoration: a reduced picture rate is applied by writing a time
map onto the node called `:root` — `export/sampled` and `subs/render` both do it,
which is what keeps the export's poses identical to the preview's — so a timeline
without one is not a shape this tool produces."
[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
:timelines {clip/root-id (a-timeline 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 :timeline clip/root-id :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}) :timeline clip/root-id :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 {}) :timeline clip/root-id :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}) :timeline clip/root-id
: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}) :timeline clip/root-id
:picture-fps fps})]
(is (= 48 (:poses p)) (str "picture-fps " fps))))))
(deftest plan-of-a-timeline-that-is-not-there-is-nothing
(is (nil? (export/plan {:clip (a-clip {}) :timeline :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-timeline-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 {}) :timeline :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 (= [clip/root-id] (:timelines (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})
[:timelines :sym]
(a-timeline 4))]
(-> (run!* c :timeline :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
:timelines
{clip/root-id
{:frames 12
:nodes (cond-> {:root {:id :root :kind :group :z "a1"}
p1 {:id p1 :kind :symbol :of :sym/face :parent :root :z "a1"
:name "left" :channels {[:xform :pos] (ch/framed [0 0])}}
p2 {:id p2 :kind :symbol :of :sym/face :parent :root :z "a2"
:name "right" :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-timeline 6)}})
(deftest isolating-keeps-the-placement-its-chain-and-its-voice
(let [tl (clip/timeline (staged :voice? true) clip/root-id)
kept (set (keys (:nodes (export/isolate tl 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 [tl (clip/timeline (staged :voice? true) clip/root-id)
kept (set (keys (:nodes (export/isolate tl p2))))]
(is (= #{:root p2} kept))))
(deftest isolating-nothing-leaves-the-timeline-alone
(let [tl (clip/timeline (staged :voice? true) clip/root-id)]
(is (= tl (export/isolate tl nil)))
(testing "and so does isolating a node that is not there"
(is (= tl (export/isolate tl (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 :timeline clip/root-id :isolate p1}))))
(is (= 6 (:frames (export/plan {:clip c :timeline :sym/face})))
"the drawing's own frame space is its own")
(is (= 30 (:fps (export/plan {:clip c :timeline clip/root-id :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)))))))

View file

@ -1,4 +1,5 @@
(ns arthur.flow.measure.mouth-test
(:refer-clojure :exclude [spread])
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.landmarks :as lm]
[arthur.domain.ring :as ring]

View file

@ -1,7 +1,12 @@
(ns arthur.flow.regenerate-test
(:require [cljs.test :refer [deftest is]]
(: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]))
@ -99,3 +104,168 @@
(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")))))

View file

@ -1,5 +1,5 @@
(ns arthur.flow.source-test
(:require [cljs.test :refer [deftest is]]
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.params :as params]
[arthur.domain.wire :as wire]
[arthur.flow.source :as source]))
@ -41,8 +41,15 @@
(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}]}
:interior [{:contour nil :contrast 0 :area 0}]
:interior-settings params/defaults}
blocks (source/pack "sha256:analysis" input)]
(is (contains? blocks "source/interior"))
(is (= 4 (.-length (source/upload-blocks blocks))))
(is (= 3 (count source/roles)))))
(is (= 3 (count source/roles)))
(testing "and cannot be packed without the settings it was measured at"
;; The block is addressed BY those knobs. Defaulting them would name it
;; after settings its bytes did not come from, which is a key that lies.
(is (thrown-with-msg?
ExceptionInfo #"needs the settings"
(source/pack "sha256:analysis" (dissoc input :interior-settings)))))))

View file

@ -404,8 +404,13 @@ async function main() {
check(stageLoaded !== null, 'the 8625 stage is ready', stageLoaded ?? (await page.eval(STATUS)));
const eyeSelected = await page.eval(`(() => {
const select = document.querySelector('.controls select');
// The instance prefix is the placement's NAME ("8625 left"), not its id:
// a placement is keyed by a uuid now, and a uuid is not something to show
// anyone. So this matches the feature and requires SOME instance prefix,
// rather than pinning the label a rename is free to change.
const option = [...select.options]
.find(o => o.textContent.trim() === 'left / feature · eye-r');
.find(o => o.textContent.trim().endsWith('/ feature · eye-r')
&& o.textContent.includes(' / '));
if (!option) return false;
select.value = option.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
@ -417,6 +422,12 @@ async function main() {
const row = [...document.querySelectorAll('.control-row')]
.find(row => row.querySelector('span')?.textContent === 'iris-size');
if (!row) return null;
// Scrolled into view FIRST, because the click below is dispatched at
// viewport coordinates: a control panel that has grown past the fold
// otherwise reports a y outside the window, the click lands on nothing,
// and the failure reads as "regeneration is broken" rather than "the
// slider was off-screen". Which is exactly what it read as once.
row.scrollIntoView({ block: 'center' });
const r = row.querySelector('input').getBoundingClientRect();
return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 };
})()`);
@ -455,6 +466,12 @@ async function main() {
const row = [...document.querySelectorAll('.control-row')]
.find(row => row.querySelector('span')?.textContent === 'anchor-avg');
if (!row) return null;
// Scrolled into view FIRST, because the click below is dispatched at
// viewport coordinates: a control panel that has grown past the fold
// otherwise reports a y outside the window, the click lands on nothing,
// and the failure reads as "regeneration is broken" rather than "the
// slider was off-screen". Which is exactly what it read as once.
row.scrollIntoView({ block: 'center' });
const r = row.querySelector('input').getBoundingClientRect();
return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 };
})()`);
@ -485,7 +502,8 @@ async function main() {
const teethSelected = await page.eval(`(() => {
const select = document.querySelector('.controls select');
const option = [...select.options]
.find(o => o.textContent.trim() === 'left / feature · teeth');
.find(o => o.textContent.trim().endsWith('/ feature · teeth')
&& o.textContent.includes(' / '));
if (!option) return false;
select.value = option.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
@ -498,6 +516,12 @@ async function main() {
const row = [...document.querySelectorAll('.control-row')]
.find(row => row.querySelector('span')?.textContent === 'cavity-erode');
if (!row) return null;
// Scrolled into view FIRST, because the click below is dispatched at
// viewport coordinates: a control panel that has grown past the fold
// otherwise reports a y outside the window, the click lands on nothing,
// and the failure reads as "regeneration is broken" rather than "the
// slider was off-screen". Which is exactly what it read as once.
row.scrollIntoView({ block: 'center' });
const r = row.querySelector('input').getBoundingClientRect();
return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 };
})()`);