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:
parent
3058b9a5f2
commit
e22ee600b9
31 changed files with 2642 additions and 178 deletions
|
|
@ -48,6 +48,14 @@ and PUTs with ordinary CSRF protection — no endpoint in this app is exempt.
|
|||
color: var(--fg); background: #1c1f2b; border: 1px solid #2b3040;
|
||||
font: inherit; max-width: 360px; }
|
||||
.load-status { margin-top: 6px; font-size: 12px; opacity: .75; }
|
||||
.export { width: 640px; margin-top: 14px; padding-top: 12px;
|
||||
border-top: 1px solid #2b3040; font-size: 12px; }
|
||||
.export .row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.export .gap { flex: 1; }
|
||||
.export select { margin-left: 6px; padding: 3px 5px; color: var(--fg);
|
||||
background: #1c1f2b; border: 1px solid #2b3040; font: inherit; }
|
||||
.export .readout { margin-top: 7px; }
|
||||
.export .note { margin: 7px 0 0; }
|
||||
.controls { width: 640px; margin-top: 18px; padding-top: 12px;
|
||||
border-top: 1px solid #2b3040; font-size: 12px; }
|
||||
.controls select { margin-left: 8px; padding: 3px 5px; color: var(--fg);
|
||||
|
|
|
|||
|
|
@ -3,12 +3,25 @@
|
|||
|
||||
The mix is derived from saved audio track leaves and immutable footage blobs.
|
||||
The transport still has one audio element, so seeking, rate changes and looping
|
||||
stay tied to the same clock the picture reads."
|
||||
stay tied to the same clock the picture reads.
|
||||
|
||||
THE AUDIO BUFFER IS THE PRODUCT AND THE WAV IS ONE PACKAGING OF IT. Playback
|
||||
wants a URL an `<audio>` element can hold; an export wants the samples, either
|
||||
as WAV bytes to put in an archive or as the `AudioBuffer` a muxer takes as an
|
||||
audio track. So `buffer!` renders and the two wrappers below it package, rather
|
||||
than the render being spelled once per consumer."
|
||||
(:require [arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.node :as node]))
|
||||
|
||||
(defn- wav [^js buffer]
|
||||
(defn wav-bytes
|
||||
"An `AudioBuffer` -> the bytes of a 16-bit PCM WAV.
|
||||
|
||||
PEAK-NORMALISED ONLY IF IT WOULD CLIP. A mix of several tracks can sum past
|
||||
1.0, and 16-bit PCM has nowhere to put that, so the alternative to scaling is
|
||||
audible clipping on exactly the loudest moment. Below the threshold nothing is
|
||||
touched, so a single-track mix is the footage's own audio sample for sample."
|
||||
[^js buffer]
|
||||
(let [channels (.-numberOfChannels buffer)
|
||||
frames (.-length buffer)
|
||||
rate (.-sampleRate buffer)
|
||||
|
|
@ -35,7 +48,11 @@
|
|||
(let [sample (* level (aget (get samples c) i))]
|
||||
(.setInt16 view (+ 44 (* (+ (* i channels) c) 2))
|
||||
(js/Math.round (* 32767 (max -1 (min 1 sample)))) true))))
|
||||
(js/URL.createObjectURL (js/Blob. #js [bytes] #js {:type "audio/wav"}))))
|
||||
(js/Uint8Array. bytes)))
|
||||
|
||||
(defn- wav-url [^js buffer]
|
||||
(js/URL.createObjectURL
|
||||
(js/Blob. #js [(wav-bytes buffer)] #js {:type "audio/wav"})))
|
||||
|
||||
(defn- source! [footage-id]
|
||||
(-> (js/fetch (str "/api/footage/" footage-id))
|
||||
|
|
@ -73,10 +90,19 @@
|
|||
(.linearRampToValueAtTime param (* factor v) (/ f fps))
|
||||
(.setValueAtTime param (* factor v) (/ f fps)))))))
|
||||
|
||||
(defn- render! [document sources store]
|
||||
(defn tracks-of
|
||||
"The audio nodes of one of the clip's timelines.
|
||||
|
||||
A timeline parameter rather than always the root, because a symbol is a
|
||||
timeline and may carry its own sound. `:main` is the clip's own, which is what
|
||||
playback mixes."
|
||||
[document tid]
|
||||
(filter #(= :audio (:kind %)) (vals (:nodes (clip/timeline document tid)))))
|
||||
|
||||
(defn- render! [document tid sources store]
|
||||
(let [fps (:fps document)
|
||||
frames (clip/frames document)
|
||||
tracks (filter #(= :audio (:kind %)) (vals (clip/nodes document)))
|
||||
frames (:frames (clip/timeline document tid))
|
||||
tracks (tracks-of document tid)
|
||||
output (js/OfflineAudioContext.
|
||||
2 (js/Math.ceil (* (/ frames fps) 44100)) 44100)]
|
||||
(doseq [track tracks]
|
||||
|
|
@ -104,16 +130,41 @@
|
|||
(.connect pan (.-destination output))
|
||||
(.start sound (/ start (:fps document)) (/ (node/local-frame track start) fps))
|
||||
(.stop sound (/ end (:fps document))))))
|
||||
(-> (.startRendering output) (.then wav))))
|
||||
(.startRendering output)))
|
||||
|
||||
(defn buffer!
|
||||
"Promise of the `AudioBuffer` one timeline's audio tracks mix down to, or nil
|
||||
when it has none.
|
||||
|
||||
The raw product. `mix!` packages it as a WAV URL for the transport and
|
||||
`export/frames` packages it as WAV bytes in an archive; a muxer would take it as
|
||||
it is, which is why this is the function the others are written in terms of."
|
||||
([document tid] (buffer! document tid nil))
|
||||
([document tid store]
|
||||
(let [tracks (tracks-of document tid)]
|
||||
(if (empty? tracks)
|
||||
(js/Promise.resolve nil)
|
||||
(-> (js/Promise.all
|
||||
(into-array (map source! (distinct (map #(get-in % [:source :footage]) tracks)))))
|
||||
(.then (fn [pairs] (render! document tid (into {} (array-seq pairs)) store))))))))
|
||||
|
||||
(defn decode!
|
||||
"Promise of the `AudioBuffer` behind a URL. What a clip whose audio is a plain
|
||||
file rather than placed tracks exports."
|
||||
[url]
|
||||
(-> (js/fetch url)
|
||||
(.then (fn [^js response]
|
||||
(when-not (.-ok response)
|
||||
(throw (ex-info "the clip's audio did not load"
|
||||
{:url url :status (.-status response)})))
|
||||
(.arrayBuffer response)))
|
||||
(.then (fn [bytes]
|
||||
(.decodeAudioData (js/OfflineAudioContext. 1 1 44100) bytes)))))
|
||||
|
||||
(defn mix!
|
||||
"Promise of a mixed WAV URL, or the original URL for a clip without audio
|
||||
tracks. Each track can be trimmed and faded independently of its linked picture."
|
||||
([document fallback-url] (mix! document fallback-url nil))
|
||||
([document fallback-url store]
|
||||
(let [tracks (filter #(= :audio (:kind %)) (vals (clip/nodes document)))]
|
||||
(if (empty? tracks)
|
||||
(js/Promise.resolve fallback-url)
|
||||
(-> (js/Promise.all
|
||||
(into-array (map source! (distinct (map #(get-in % [:source :footage]) tracks)))))
|
||||
(.then (fn [pairs] (render! document (into {} (array-seq pairs)) store))))))))
|
||||
(-> (buffer! document clip/root-id store)
|
||||
(.then (fn [buffer] (if buffer (wav-url buffer) fallback-url))))))
|
||||
|
|
|
|||
|
|
@ -89,6 +89,15 @@
|
|||
;; how scrubbing becomes inspectable in re-frame-10x, and a collaborator's
|
||||
;; playhead is a feature — putting it outside app-db puts it outside the
|
||||
;; machinery that would share it.
|
||||
;; --- export ---
|
||||
;;
|
||||
;; The REQUEST and its progress, never the frames. Which timeline to write and
|
||||
;; at what integer zoom is authored state like anything else; the megabytes the
|
||||
;; render produces are handed straight to a download and never enter the db.
|
||||
;; `:isolate` is the placement to render alone, or nil for the whole timeline.
|
||||
:export {:timeline :main :isolate nil :zoom 4 :busy? false :done 0 :total 0
|
||||
:status nil}
|
||||
|
||||
:playback {:frame 0
|
||||
:playing? false
|
||||
:rate 1.0
|
||||
|
|
|
|||
|
|
@ -19,16 +19,37 @@
|
|||
(+ (second base) (* dy (- (wave f 132) y0)))]]))
|
||||
:linear)))
|
||||
|
||||
(defn compose [source]
|
||||
(defn compose
|
||||
"The authored layout plus a source clip -> the composed stage document.
|
||||
|
||||
A PLACEMENT IS KEYED BY ITS :uuid, not by the authored id. The authored id
|
||||
(`:left`, `:voice-right`) is a handle for reading the EDN and for the
|
||||
`:linked-to` written there; it does not appear in the document this returns.
|
||||
What replaces it is an identity that means one placement and nothing else: seven
|
||||
instances of one symbol are seven different things to name — to export on their
|
||||
own, to link a voice to, to point at later — and an id like `:left` is a
|
||||
description of where a thing sits, which is exactly what changes when the stage
|
||||
is re-arranged. `:name` carries the label for a human and `:of` carries the
|
||||
symbol, so the node still says what it is and which drawing it plays."
|
||||
[source]
|
||||
(let [{:keys [name width height frames symbol instances audio scale]} layout
|
||||
default-anchor (or (:anchor layout)
|
||||
[(/ (:width source) 2) (/ (:height source) 2)])
|
||||
original (get-in source [:timelines :main])
|
||||
;; Authored id -> uuid, so the `:linked-to` in the EDN resolves to the
|
||||
;; identity the document uses. Built before either pass because the audio
|
||||
;; nodes refer to the instances.
|
||||
by-id (into {} (map (juxt :id :uuid)) (concat instances audio))
|
||||
uuid-of (fn [what id]
|
||||
(or (get by-id id)
|
||||
(throw (ex-info "the stage layout names a placement that is not there"
|
||||
{:in what :id id
|
||||
:known (vec (sort-by str (keys by-id)))}))))
|
||||
nodes (into
|
||||
{:root {:id :root :name "stage" :kind :group :z "a1"}}
|
||||
(map (fn [{:keys [id name z span at in center anchor drift phase]}]
|
||||
(map (fn [{:keys [uuid name z span at in center anchor drift phase]}]
|
||||
(let [anchor (or anchor default-anchor)]
|
||||
[id {:id id :name name :kind :symbol :of symbol
|
||||
[uuid {:id uuid :name name :kind :symbol :of symbol
|
||||
:parent :root :z z :span span
|
||||
:time {:mode :map :at at :in in :rate 1}
|
||||
:channels {[:xform :pos] (if drift
|
||||
|
|
@ -38,9 +59,10 @@
|
|||
[:xform :scale] scale}}]))
|
||||
instances))
|
||||
nodes (into nodes
|
||||
(map (fn [{:keys [id linked-to z source span at in gain pan]}]
|
||||
[id {:id id :kind :audio :parent :root :z z
|
||||
:linked-to linked-to :source source :span span
|
||||
(map (fn [{:keys [uuid linked-to z source span at in gain pan]}]
|
||||
[uuid {:id uuid :kind :audio :parent :root :z z
|
||||
:linked-to (uuid-of uuid linked-to)
|
||||
:source source :span span
|
||||
:time {:mode :map :at at :in in :rate 1}
|
||||
:channels (cond-> {[:audio :gain] gain}
|
||||
pan (assoc [:audio :pan] pan))}])
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@
|
|||
;; Audio placements are ordinary timeline nodes with channel parameters.
|
||||
;; :linked-to is an editorial link; their spans and time maps are independent.
|
||||
:audio
|
||||
[{:id :voice-left :linked-to :left :z "a3"
|
||||
[{:id :voice-left :uuid #uuid "eeaa49c3-1238-469f-bf54-44929e379f6b"
|
||||
:linked-to :left :z "a3"
|
||||
:source {:footage "f8cace9e-4ad3-4796-973c-c62eeebe3d01"}
|
||||
:span [0 280] :at 0 :in 0
|
||||
:gain {:animated? false :value 1.0}}
|
||||
{:id :voice-right :linked-to :right :z "a4"
|
||||
{:id :voice-right :uuid #uuid "468239dd-0e3a-4e5c-ac6f-1858430a0355"
|
||||
:linked-to :right :z "a4"
|
||||
:source {:footage "f8cace9e-4ad3-4796-973c-c62eeebe3d01"}
|
||||
:span [48 260] :at 48 :in 0
|
||||
:gain {:animated? true :interp :linear
|
||||
|
|
@ -34,25 +36,42 @@
|
|||
:pan {:animated? true :interp :linear
|
||||
:keys {48 -0.8, 90 -0.8, 130 0.7, 175 0.7, 220 -0.65, 259 0.65}
|
||||
:over []}}]
|
||||
;;
|
||||
;; EVERY PLACEMENT CARRIES A :uuid, and it is authored here rather than generated
|
||||
;; in `compose`. The uuid is the node's identity in the composed document — it is
|
||||
;; the key in the timeline's node map — so generating one per load would give the
|
||||
;; same stage a different document on every load, and nothing that refers to a
|
||||
;; placement (`:linked-to` above, an export target in the UI, a comment in a
|
||||
;; review) could survive a reload. The `:id` beside it stays as the AUTHORING
|
||||
;; handle: it is what the reader of this file uses to see which placement is
|
||||
;; which, and what the `:linked-to` above names, and `compose` resolves it to the
|
||||
;; uuid. Nothing downstream of `compose` sees the authored id.
|
||||
:instances
|
||||
[{:id :left :name "8625 left" :z "a1"
|
||||
[{:id :left :uuid #uuid "ee7321c8-faf1-46d7-8029-37771898accb"
|
||||
:name "8625 left" :z "a1"
|
||||
:span [0 280] :at 0 :in 0
|
||||
:center [40 40] :drift [3 2] :phase 0}
|
||||
{:id :right :name "8625 right" :z "a2"
|
||||
{:id :right :uuid #uuid "1aa0da78-b4ed-4bb6-8d70-b09a3ec5e2c3"
|
||||
:name "8625 right" :z "a2"
|
||||
:span [48 280] :at 48 :in 0
|
||||
:center [120 40] :drift [-3 2] :phase 17}
|
||||
{:id :top-third :name "8625 top third" :z "a5"
|
||||
{:id :top-third :uuid #uuid "23bb697d-eba7-4af6-a86c-606c50107088"
|
||||
:name "8625 top third" :z "a5"
|
||||
:span [24 280] :at 24 :in 0
|
||||
:center [200 40] :drift [2 -3] :phase 31}
|
||||
{:id :top-fourth :name "8625 top fourth" :z "a6"
|
||||
{:id :top-fourth :uuid #uuid "f4f0241d-026e-4e50-9bea-a4ccde896d8a"
|
||||
:name "8625 top fourth" :z "a6"
|
||||
:span [72 280] :at 72 :in 0
|
||||
:center [280 40] :drift [-2 -2] :phase 49}
|
||||
{:id :bottom-left :name "8625 bottom left" :z "a7"
|
||||
{:id :bottom-left :uuid #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218"
|
||||
:name "8625 bottom left" :z "a7"
|
||||
:span [96 280] :at 96 :in 0
|
||||
:center [70 135] :drift [3 -2] :phase 63}
|
||||
{:id :bottom-middle :name "8625 bottom middle" :z "a8"
|
||||
{:id :bottom-middle :uuid #uuid "63f3fb32-9e94-4d68-a1c2-12e6de2d04b5"
|
||||
:name "8625 bottom middle" :z "a8"
|
||||
:span [120 280] :at 120 :in 0
|
||||
:center [160 135] :drift [-2 3] :phase 81}
|
||||
{:id :bottom-right :name "8625 bottom right" :z "a9"
|
||||
{:id :bottom-right :uuid #uuid "fa338701-cb21-4d45-89f1-a5e706f045ec"
|
||||
:name "8625 bottom right" :z "a9"
|
||||
:span [144 280] :at 144 :in 0
|
||||
:center [250 135] :drift [2 2] :phase 107}]}
|
||||
|
|
|
|||
|
|
@ -108,9 +108,17 @@
|
|||
|
||||
Each instance owns its own timeline resolver, so two offsets never share a
|
||||
channel cursor or point buffer. The returned ops must be drawn before the next
|
||||
frame, as with timeline/resolver."
|
||||
([clip store] (resolver clip store pal/index-of))
|
||||
([clip store palette]
|
||||
frame, as with timeline/resolver.
|
||||
|
||||
`root` is which timeline to resolve AS the root, and it defaults to the clip's.
|
||||
Passing a symbol's id is the whole of \"render that symbol\": a library timeline
|
||||
and the clip's own are the same type, so a symbol resolves by being rooted
|
||||
rather than by a second code path — which is the return on collapsing the two
|
||||
into `domain/timeline`. Its frame space is its own `:frames`, and nested symbols
|
||||
inside it still resolve, because this is the function that knows how to do that."
|
||||
([clip store] (resolver clip store pal/index-of root-id))
|
||||
([clip store palette] (resolver clip store palette root-id))
|
||||
([clip store palette root]
|
||||
(letfn [(build [tid chain]
|
||||
(when (some #{tid} chain)
|
||||
(throw (ex-info "symbol timeline cycle" {:chain (conj chain tid)})))
|
||||
|
|
@ -143,7 +151,7 @@
|
|||
[]))
|
||||
(when-let [op (get by-id id)] [op]))))
|
||||
ids))))))]
|
||||
(build root-id []))))
|
||||
(build root []))))
|
||||
|
||||
(defn problems
|
||||
"Human-readable reasons this clip will not evaluate or will not save. Empty
|
||||
|
|
|
|||
47
frontend/src/arthur/domain/crc32.cljs
Normal file
47
frontend/src/arthur/domain/crc32.cljs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
(ns arthur.domain.crc32
|
||||
"CRC-32, as PNG chunks and ZIP entries both define it.
|
||||
|
||||
ONE implementation for both, and that is not premature sharing: a PNG chunk's
|
||||
trailing checksum and a ZIP local header's `crc-32` field are the same function
|
||||
of the same bytes — IEEE 802.3, reflected, with an initial and final complement
|
||||
— down to the polynomial. Two copies would be two chances to get the table
|
||||
wrong in a way that reads as \"the file is corrupt\" rather than as \"these two
|
||||
functions disagree\".
|
||||
|
||||
It lives beside `domain/sha256` for the same reason that one does: a digest is
|
||||
a pure function of bytes with no DOM in it, so every assertion about it runs
|
||||
under node.")
|
||||
|
||||
(def ^:private table
|
||||
;; The standard 256-entry table, built once. The bit-twiddling loop IS the
|
||||
;; definition of the polynomial and there is no collection idiom hiding in it:
|
||||
;; each entry is eight dependent shifts of one accumulator.
|
||||
(let [t (js/Uint32Array. 256)]
|
||||
(dotimes [n 256]
|
||||
(aset t n (loop [c n k 0]
|
||||
(if (= k 8)
|
||||
c
|
||||
(recur (if (odd? c)
|
||||
(bit-xor 0xedb88320 (unsigned-bit-shift-right c 1))
|
||||
(unsigned-bit-shift-right c 1))
|
||||
(inc k))))))
|
||||
t))
|
||||
|
||||
(defn of
|
||||
"CRC-32 of a byte array, or of the half-open range [from to) of one, as an
|
||||
unsigned 32-bit number.
|
||||
|
||||
`loop` over the bytes rather than a reduce over a `range`: this walks the whole
|
||||
of every PNG written, which at 1920x1200 is seven megabytes a frame, and a seq
|
||||
cell per byte is the allocation the rest of this codebase is arranged to
|
||||
avoid."
|
||||
([bytes] (of bytes 0 (.-length bytes)))
|
||||
([bytes from to]
|
||||
(-> (loop [c 0xffffffff i from]
|
||||
(if (>= i to)
|
||||
c
|
||||
(recur (bit-xor (aget table (bit-and (bit-xor c (aget bytes i)) 0xff))
|
||||
(unsigned-bit-shift-right c 8))
|
||||
(inc i))))
|
||||
(bit-xor 0xffffffff)
|
||||
(unsigned-bit-shift-right 0))))
|
||||
|
|
@ -69,10 +69,27 @@
|
|||
{:id id})))
|
||||
(str/replace s "/" "~")))
|
||||
|
||||
(def ^:private uuid-segment
|
||||
"Canonical UUID form: 8-4-4-4-12 hex digits, and nothing else.
|
||||
|
||||
A PLACEMENT'S ID IS A UUID — see `demo/stage/compose` for why — and a leaf path
|
||||
is text, so reading one back has to decide which ids are uuids and which are
|
||||
keywords. It decides by SHAPE, which is a judgement worth stating: a keyword
|
||||
that happened to be thirty-six characters of hex in exactly this grouping would
|
||||
come back a uuid. Nothing names a node that by hand, and the alternative — a
|
||||
sigil on every segment — would change the shape of every path in every leaf to
|
||||
disambiguate a case that does not arise. `^` and `$` are the load-bearing part;
|
||||
without them a longer id CONTAINING a uuid would match."
|
||||
#"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||||
|
||||
(defn unsegment
|
||||
"One path segment -> the id it names."
|
||||
"One path segment -> the id it names: a uuid when it is shaped like one, a
|
||||
keyword otherwise."
|
||||
[s]
|
||||
(keyword (str/replace s "~" "/")))
|
||||
(let [s (str/replace s "~" "/")]
|
||||
(if (re-find uuid-segment s)
|
||||
(uuid s)
|
||||
(keyword s))))
|
||||
|
||||
(defn- prop->path
|
||||
"A channel's property vector -> one path segment. `[:geom :pts]` is \"geom.pts\"
|
||||
|
|
|
|||
149
frontend/src/arthur/domain/png.cljs
Normal file
149
frontend/src/arthur/domain/png.cljs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
(ns arthur.domain.png
|
||||
"An indexed raster -> one PNG, at integer zoom.
|
||||
|
||||
THE EXPORT'S MASTER FORMAT, and the reasons are all about not resampling.
|
||||
Everything above `domain/raster` exists to put hard-edged flat fills into a
|
||||
byte buffer; a lossy encoder would put chroma fringes on exactly the edges the
|
||||
whole idiom is made of, and a fractional scale would put grey on them. So the
|
||||
picture leaves the tool as PNG, and it leaves it at an INTEGER zoom — a pixel
|
||||
becomes a block of identical pixels and nothing is interpolated.
|
||||
|
||||
TRUECOLOUR, NOT PALETTED, and that is a deliberate loss. Colour type 3 would be
|
||||
the faithful shape — the buffer IS palette indices and a PLTE chunk is the ramp
|
||||
— and it would be a third of the bytes into deflate. But the entire purpose of
|
||||
this file is to be imported by a program we cannot test against here, and
|
||||
type 2 is the type every reader on earth handles. Faithfulness that depends on
|
||||
someone else's PNG decoder being complete is not faithfulness. The pixels are
|
||||
identical either way; only the file is bigger, and deflate takes most of that
|
||||
back because the art is flat.
|
||||
|
||||
`CompressionStream` does the deflating, which is why `encoder` hands back a
|
||||
promise. It is the zlib-wrapped variety — RFC 1950, which is what an IDAT
|
||||
requires — and getting that wrong is a one-word difference from `deflate-raw`
|
||||
and a file no reader will open.
|
||||
|
||||
No DOM. A canvas `toBlob` would be shorter and would put this namespace out of
|
||||
reach of node, where the rest of the rasteriser is asserted about; it would also
|
||||
hand the encoding decisions to the browser, and the point of this file is that
|
||||
they are decisions."
|
||||
;; A `chunk` is the format's own word for its one structural unit, and chunked
|
||||
;; seqs never come up in here, so core's loses the name rather than ours.
|
||||
(:refer-clojure :exclude [chunk])
|
||||
(:require [arthur.domain.crc32 :as crc32]))
|
||||
|
||||
(def ^:private signature
|
||||
(js/Uint8Array. #js [0x89 0x50 0x4e 0x47 0x0d 0x0a 0x1a 0x0a]))
|
||||
|
||||
(defn- u32! [^js bytes at n]
|
||||
(aset bytes at (bit-and (unsigned-bit-shift-right n 24) 0xff))
|
||||
(aset bytes (+ at 1) (bit-and (unsigned-bit-shift-right n 16) 0xff))
|
||||
(aset bytes (+ at 2) (bit-and (unsigned-bit-shift-right n 8) 0xff))
|
||||
(aset bytes (+ at 3) (bit-and n 0xff)))
|
||||
|
||||
(defn chunk
|
||||
"One PNG chunk: length, type, payload, CRC over type and payload.
|
||||
|
||||
Big-endian throughout, which is the format's and not the machine's — the same
|
||||
reason `domain/raster/->rgba` has to ask which way round the machine is and this
|
||||
does not."
|
||||
[tag ^js payload]
|
||||
(let [n (.-length payload)
|
||||
out (js/Uint8Array. (+ n 12))]
|
||||
(u32! out 0 n)
|
||||
(dotimes [i 4] (aset out (+ 4 i) (.charCodeAt tag i)))
|
||||
(.set out payload 8)
|
||||
(u32! out (+ 8 n) (crc32/of out 4 (+ 8 n)))
|
||||
out))
|
||||
|
||||
(defn- ihdr [w h]
|
||||
(let [p (js/Uint8Array. 13)]
|
||||
(u32! p 0 w)
|
||||
(u32! p 4 h)
|
||||
(aset p 8 8) ; bit depth
|
||||
(aset p 9 2) ; colour type 2: truecolour RGB
|
||||
(aset p 10 0) ; deflate, the only compression PNG has
|
||||
(aset p 11 0) ; adaptive filtering, the only method
|
||||
(aset p 12 0) ; no interlace
|
||||
p))
|
||||
|
||||
(defn- deflate!
|
||||
"Promise of the zlib stream of `bytes`.
|
||||
|
||||
`CompressionStream` rather than a deflate implementation: it is in every browser
|
||||
this tool runs in and in node, so the one thing here that would be hundreds of
|
||||
lines is none of them."
|
||||
[^js bytes]
|
||||
;; `.stream` first: `pipeThrough` is a ReadableStream's method, not a Blob's.
|
||||
(-> (js/Response. (.pipeThrough (.stream (js/Blob. #js [bytes]))
|
||||
(js/CompressionStream. "deflate")))
|
||||
(.arrayBuffer)
|
||||
(.then #(js/Uint8Array. %))))
|
||||
|
||||
(defn- concat!
|
||||
[parts]
|
||||
(let [out (js/Uint8Array. (transduce (map #(.-length ^js %)) + 0 parts))]
|
||||
(reduce (fn [at ^js part] (.set out part at) (+ at (.-length part))) 0 parts)
|
||||
out))
|
||||
|
||||
(defn encoder
|
||||
"(fn [raster ramp] -> promise of PNG bytes), for one stage size and one zoom.
|
||||
|
||||
Built once per export rather than per frame, in the shape `timeline/resolver`
|
||||
already uses: everything that does not change frame to frame is held here. What
|
||||
that buys is the scanline scratch, which at zoom 6 is seven megabytes — a
|
||||
per-frame allocation of that size is the one thing that would make a long export
|
||||
thrash, and it is the same buffer every frame because the stage is.
|
||||
|
||||
FILTER TYPE 2 (Up) ON EVERY ROW, including the first, where PNG defines the
|
||||
prior row as zeros and Up therefore degenerates to None. It is chosen for the
|
||||
zoom: at zoom 4 three of every four output rows are byte-identical to the one
|
||||
above, so Up turns them into runs of zeros and deflate takes them to almost
|
||||
nothing. Paletted or not, that is where the size of an upscaled flat-fill frame
|
||||
goes."
|
||||
[w h zoom]
|
||||
(let [zoom (max 1 (js/Math.floor zoom))
|
||||
out-w (* w zoom)
|
||||
out-h (* h zoom)
|
||||
stride (* out-w 3)
|
||||
;; One filter byte per output row, then the filtered row.
|
||||
raw (js/Uint8Array. (* out-h (inc stride)))
|
||||
;; The row as it actually is, kept because Up filters against the
|
||||
;; UNFILTERED row above, not against the stored bytes.
|
||||
cur (js/Uint8Array. stride)
|
||||
prev (js/Uint8Array. stride)
|
||||
head (concat! [signature (chunk "IHDR" (ihdr out-w out-h))])
|
||||
tail (chunk "IEND" (js/Uint8Array. 0))]
|
||||
(fn [{:keys [buf] :as _raster} ramp]
|
||||
;; The ramp is read as a flat byte table for the same reason ->rgba reads
|
||||
;; one: `nth` into a vector of vectors is four protocol dispatches a pixel,
|
||||
;; and this walks every pixel of every frame.
|
||||
(let [p8 (js/Uint8Array. (* 256 3))]
|
||||
(dotimes [i 256]
|
||||
(let [c (or (nth ramp i nil) [255 0 255])]
|
||||
(aset p8 (* i 3) (nth c 0))
|
||||
(aset p8 (+ 1 (* i 3)) (nth c 1))
|
||||
(aset p8 (+ 2 (* i 3)) (nth c 2))))
|
||||
(.fill prev 0)
|
||||
(dotimes [y h]
|
||||
(let [srow (* y w)]
|
||||
;; Expand one SOURCE row through the ramp once, repeating each pixel
|
||||
;; `zoom` times across.
|
||||
(dotimes [x w]
|
||||
(let [p (* 3 (aget buf (+ srow x)))
|
||||
r (aget p8 p) g (aget p8 (+ p 1)) b (aget p8 (+ p 2))]
|
||||
(dotimes [k zoom]
|
||||
(let [o (* 3 (+ (* x zoom) k))]
|
||||
(aset cur o r)
|
||||
(aset cur (+ o 1) g)
|
||||
(aset cur (+ o 2) b)))))
|
||||
;; …and emit it `zoom` times down. The second and later copies filter
|
||||
;; to all zeros, which is the whole point of Up here.
|
||||
(dotimes [k zoom]
|
||||
(let [at (* (+ (* y zoom) k) (inc stride))]
|
||||
(aset raw at 2)
|
||||
(dotimes [i stride]
|
||||
(aset raw (+ at 1 i)
|
||||
(bit-and (- (aget cur i) (aget prev i)) 0xff)))
|
||||
(.set prev cur)))))
|
||||
(-> (deflate! raw)
|
||||
(.then (fn [z] (concat! [head (chunk "IDAT" z) tail]))))))))
|
||||
149
frontend/src/arthur/domain/zip.cljs
Normal file
149
frontend/src/arthur/domain/zip.cljs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
(ns arthur.domain.zip
|
||||
"A ZIP with STORED entries, for shipping a frame sequence and its audio as one
|
||||
file.
|
||||
|
||||
STORED — compression method 0, the bytes verbatim — because every entry going
|
||||
into it is already deflated (a PNG's IDAT) or is PCM that the user is about to
|
||||
hand a codec (the WAV). Deflating a deflated stream buys nothing and costs a
|
||||
pass over every byte of a long export, and it is also what lets this namespace
|
||||
be about the container alone: an archive with no compressor in it is a handful
|
||||
of little-endian headers, and there is no dependency to vendor.
|
||||
|
||||
WHY A ZIP AND NOT A DIRECTORY. `showDirectoryPicker` would write the numbered
|
||||
sequence straight to disk, which is closer to what an NLE wants, and it does not
|
||||
exist in Firefox — which is a browser this tool is already known to behave
|
||||
differently in (see `flow/ingest` on seeking). One archive downloads the same
|
||||
way everywhere and pairs the picture with the sound it has to stay in sync with,
|
||||
so the two cannot be separated on the way to the cutting room.
|
||||
|
||||
NO ZIP64. The offsets and sizes here are 32-bit, so this refuses an archive at
|
||||
4GB rather than writing one whose central directory silently wraps. A 900-frame
|
||||
export at 1920x1200 is tens of megabytes, so the limit is not in the way; a
|
||||
limit that corrupts instead of refusing would be."
|
||||
(:require [arthur.domain.crc32 :as crc32]))
|
||||
|
||||
(def ^:private limit
|
||||
"The largest archive this writer will produce. Past it the format needs Zip64
|
||||
and every offset below would have to be 64-bit."
|
||||
0xffffffff)
|
||||
|
||||
(defn- bytes-of [^String s]
|
||||
;; ASCII by construction — `0001.png`, `audio.wav` — and asserted rather than
|
||||
;; assumed, because a non-ASCII name would need the UTF-8 general-purpose flag
|
||||
;; and would otherwise arrive mojibake'd in the archive.
|
||||
(let [out (js/Uint8Array. (.-length s))]
|
||||
(dotimes [i (.-length s)]
|
||||
(let [c (.charCodeAt s i)]
|
||||
(when (> c 127)
|
||||
(throw (ex-info "a zip entry name must be ASCII" {:name s})))
|
||||
(aset out i c)))
|
||||
out))
|
||||
|
||||
(defn- u16! [^js b at n]
|
||||
(aset b at (bit-and n 0xff))
|
||||
(aset b (+ at 1) (bit-and (unsigned-bit-shift-right n 8) 0xff)))
|
||||
|
||||
(defn- u32! [^js b at n]
|
||||
(u16! b at (bit-and n 0xffff))
|
||||
(u16! b (+ at 2) (unsigned-bit-shift-right n 16)))
|
||||
|
||||
(defn dos-time
|
||||
"A `js/Date` as the two 16-bit fields ZIP inherited from MS-DOS: [date time].
|
||||
|
||||
Seconds have one bit less than they need, so they land on even values, and the
|
||||
year is an offset from 1980. Both are the format's, not an approximation — a
|
||||
date before 1980 is not representable and is clamped rather than wrapped into a
|
||||
plausible-looking wrong one."
|
||||
[^js d]
|
||||
[(bit-or (bit-shift-left (max 0 (- (.getFullYear d) 1980)) 9)
|
||||
(bit-shift-left (inc (.getMonth d)) 5)
|
||||
(.getDate d))
|
||||
(bit-or (bit-shift-left (.getHours d) 11)
|
||||
(bit-shift-left (.getMinutes d) 5)
|
||||
(quot (.getSeconds d) 2))])
|
||||
|
||||
(defn archive
|
||||
"Entries -> the parts of one ZIP, ready for a `js/Blob`.
|
||||
|
||||
Each entry is `{:name \"0001.png\" :data <Uint8Array>}`. Returns a vector of
|
||||
byte arrays rather than one buffer: the payloads are already in memory and a
|
||||
long export is tens of megabytes, so the archive REFERS to them instead of
|
||||
copying every one into a second buffer of the same size. `js/Blob` takes the
|
||||
parts as they are.
|
||||
|
||||
`at` is the modification time stamped on every entry. Passed in rather than read
|
||||
from the clock so that the same frames produce the same archive, byte for byte,
|
||||
which is what makes it assertable."
|
||||
([entries] (archive entries (js/Date.)))
|
||||
([entries ^js at]
|
||||
(let [[date time] (dos-time at)
|
||||
;; One pass, because a central directory entry needs the local header's
|
||||
;; OFFSET and therefore the running total, and the CRC is wanted in both
|
||||
;; places. Building the two lists separately would mean either computing
|
||||
;; every CRC twice or keeping a parallel vector of them.
|
||||
{:keys [parts central offset]}
|
||||
(reduce
|
||||
(fn [{:keys [parts central offset]} {:keys [name data]}]
|
||||
(let [nm (bytes-of name)
|
||||
n (.-length nm)
|
||||
size (.-length ^js data)
|
||||
crc (crc32/of data)
|
||||
local (js/Uint8Array. (+ 30 n))
|
||||
dir (js/Uint8Array. (+ 46 n))]
|
||||
(u32! local 0 0x04034b50) ; local file header
|
||||
(u16! local 4 10) ; version needed: 1.0 is enough to store
|
||||
(u16! local 6 0) ; no flags; the name is ASCII
|
||||
(u16! local 8 0) ; method 0: stored
|
||||
(u16! local 10 time)
|
||||
(u16! local 12 date)
|
||||
(u32! local 14 crc)
|
||||
(u32! local 18 size) ; compressed size — the same, stored
|
||||
(u32! local 22 size)
|
||||
(u16! local 26 n)
|
||||
(u16! local 28 0) ; no extra field
|
||||
(.set local nm 30)
|
||||
|
||||
(u32! dir 0 0x02014b50) ; central directory header
|
||||
(u16! dir 4 10) ; made by
|
||||
(u16! dir 6 10) ; version needed
|
||||
(u16! dir 8 0)
|
||||
(u16! dir 10 0)
|
||||
(u16! dir 12 time)
|
||||
(u16! dir 14 date)
|
||||
(u32! dir 16 crc)
|
||||
(u32! dir 20 size)
|
||||
(u32! dir 24 size)
|
||||
(u16! dir 28 n)
|
||||
(u16! dir 30 0) ; extra
|
||||
(u16! dir 32 0) ; comment
|
||||
(u16! dir 34 0) ; disk number
|
||||
(u16! dir 36 0) ; internal attributes
|
||||
(u32! dir 38 0) ; external attributes
|
||||
(u32! dir 42 offset)
|
||||
(.set dir nm 46)
|
||||
|
||||
(when (> (+ offset (.-length local) size) limit)
|
||||
(throw (ex-info "this export is too big for a zip without Zip64"
|
||||
{:bytes (+ offset (.-length local) size)})))
|
||||
{:parts (conj parts local data)
|
||||
:central (conj central dir)
|
||||
:offset (+ offset (.-length local) size)}))
|
||||
{:parts [] :central [] :offset 0}
|
||||
entries)
|
||||
dir-size (transduce (map #(.-length ^js %)) + 0 central)
|
||||
end (js/Uint8Array. 22)]
|
||||
(u32! end 0 0x06054b50) ; end of central directory
|
||||
(u16! end 4 0) ; this disk
|
||||
(u16! end 6 0) ; the disk the directory starts on
|
||||
(u16! end 8 (count central))
|
||||
(u16! end 10 (count central))
|
||||
(u32! end 12 dir-size)
|
||||
(u32! end 16 offset)
|
||||
(u16! end 20 0) ; no archive comment
|
||||
(-> (into parts central) (conj end) vec))))
|
||||
|
||||
(defn blob
|
||||
"The archive as one `js/Blob`, which is what a download wants."
|
||||
([entries] (blob entries (js/Date.)))
|
||||
([entries at]
|
||||
(js/Blob. (into-array (archive entries at)) #js {:type "application/zip"})))
|
||||
216
frontend/src/arthur/events/export.cljs
Normal file
216
frontend/src/arthur/events/export.cljs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
(ns arthur.events.export
|
||||
"Export, as intents and one effect.
|
||||
|
||||
The walk is not an event and must not become one: it is a promise chain that
|
||||
runs for as long as the timeline is long, and re-frame events are the wrong unit
|
||||
for something with a middle. So `::start` collects what the render needs out of
|
||||
the db and hands it to an fx, and the fx dispatches progress back — the same
|
||||
arrangement `events/project`'s save uses, and for the same reason.
|
||||
|
||||
WHAT GOES IN THE DB IS THE REQUEST AND THE PROGRESS, never the frames. A
|
||||
megabyte of PNG in app-db would be compared by every mounted subscription on
|
||||
every tick."
|
||||
(:require [arthur.domain.palette :as pal]
|
||||
[arthur.export :as export]
|
||||
[arthur.export.frames :as frames]
|
||||
[arthur.footage.store :as store]
|
||||
[clojure.string :as str]
|
||||
[re-frame.core :as rf]))
|
||||
|
||||
(def zooms
|
||||
"The integer zooms offered. 320x200 times these is 320x200 up to 1920x1200.
|
||||
|
||||
INTEGERS ONLY, and the list is short for that reason rather than for tidiness:
|
||||
a stage at a non-integer scale has to invent pixels, and there is nothing in
|
||||
this tool downstream of `domain/raster` that is allowed to. 6 is here because
|
||||
1920 wide is what a delivery timeline usually is; the 1200 height that comes
|
||||
with it is 16:10 and is the project's aspect, not a mistake to letterbox away."
|
||||
[1 2 3 4 6])
|
||||
|
||||
(defn- stem
|
||||
"A filesystem-safe name for the artefact: the clip's label and the target's.
|
||||
|
||||
The target is in the name because the clip, each symbol in its library and each
|
||||
placement on its stage are all exportable, and they would otherwise land in the
|
||||
downloads folder as the same file. It is the target's LABEL rather than its id
|
||||
because a placement's id is a uuid, and `arthur-8f594d72-a97f-....zip` names
|
||||
nothing to the person who has to find it again."
|
||||
[label target]
|
||||
(-> (str (or label "arthur") "-" (or target "main"))
|
||||
(str/replace #"[^A-Za-z0-9._-]+" "-")
|
||||
(str/replace #"^-+|-+$" "")
|
||||
(str/lower-case)))
|
||||
|
||||
(defn target-value
|
||||
"An export target as a `<select>` option value.
|
||||
|
||||
Two kinds, told apart by a leading letter: `t:<timeline>` is a whole timeline,
|
||||
`n:<timeline>:<node>` is one placement inside one. The parts are joined with `:`
|
||||
because neither a timeline id nor a uuid contains one.
|
||||
|
||||
IT CARRIES THE NAMESPACE. `(name :sym/face-8625)` is \"face-8625\", and a value
|
||||
written that way cannot be read back: `keyword` on it gives `:face-8625`, which
|
||||
is not a key in `:timelines`, so the plan silently becomes nil and the export
|
||||
throws \"there is no such timeline\" from inside re-frame's `:do-fx`. That
|
||||
presented as the tab locking up rather than as an error — see `::run!` below for
|
||||
the other half of why — and it is the reason this is a named pair of functions
|
||||
with a test rather than `name` and `keyword` at the two ends of a select."
|
||||
[{:keys [timeline isolate]}]
|
||||
(let [tl (subs (str (or timeline :main)) 1)]
|
||||
(if isolate (str "n:" tl ":" isolate) (str "t:" tl))))
|
||||
|
||||
(defn target-id
|
||||
"The inverse of `target-value`. `keyword` splits on the `/` itself, so a
|
||||
namespaced timeline id survives; a placement comes back a uuid, which is what
|
||||
the node map is keyed by."
|
||||
[v]
|
||||
(let [[kind tl node] (str/split v #":")]
|
||||
(cond-> {:timeline (keyword tl)}
|
||||
(= "n" kind) (assoc :isolate (uuid node)))))
|
||||
|
||||
(defn targets
|
||||
"Everything an export can be pointed at, in the order the picker lists them.
|
||||
|
||||
THREE KINDS, and the distinction is the point. `:main` is the clip. A symbol
|
||||
timeline is the DRAWING — one file however many times it is placed, in its own
|
||||
frame space. A placement is that drawing WHERE IT SITS: the stage's length and
|
||||
rate, with the other placements removed, which is why seven instances of one
|
||||
symbol are seven different exports rather than seven copies of one.
|
||||
|
||||
Placements are ordered and labelled by `:name`, never by id: a uuid sorts at
|
||||
random and means nothing to read."
|
||||
[clip]
|
||||
(let [libs (cons :main (sort-by str (remove #{:main} (keys (:timelines clip)))))
|
||||
placements (->> (get-in clip [:timelines :main :nodes])
|
||||
(filter (comp #{:symbol} :kind val))
|
||||
(sort-by (fn [[id n]] [(or (:name n) "") (str id)])))]
|
||||
(into (mapv (fn [tid]
|
||||
{:timeline tid
|
||||
:label (if (= :main tid) "main (the clip)" (name tid))})
|
||||
libs)
|
||||
(mapv (fn [[id n]]
|
||||
{:timeline :main :isolate id
|
||||
:label (or (:name n) (str id))})
|
||||
placements))))
|
||||
|
||||
(defn- label-of
|
||||
"The label of the target `db` currently points at, for the filename."
|
||||
[clip {:keys [timeline isolate]}]
|
||||
(:label (or (first (filter #(and (= timeline (:timeline %))
|
||||
(= isolate (:isolate %)))
|
||||
(targets clip)))
|
||||
{:label (some-> timeline name)})))
|
||||
|
||||
(rf/reg-sub ::state (fn [db _] (:export db)))
|
||||
|
||||
(rf/reg-sub
|
||||
::targets
|
||||
(fn [db _]
|
||||
(targets (:clip (store/entry (:clip/current db))))))
|
||||
|
||||
(rf/reg-sub
|
||||
::plan
|
||||
(fn [db _]
|
||||
(let [{:keys [clip]} (store/entry (:clip/current db))
|
||||
{:keys [timeline zoom isolate]} (:export db)]
|
||||
(export/plan {:clip clip :timeline timeline :zoom zoom :isolate isolate
|
||||
:picture-fps (get-in db [:clip :display-fps])}))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-target
|
||||
;; Both keys always, so switching from a placement back to a whole timeline
|
||||
;; clears the isolate rather than leaving it to filter the new target.
|
||||
(fn [db [_ {:keys [timeline isolate]}]]
|
||||
(update db :export merge {:timeline (or timeline :main) :isolate isolate})))
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-zoom
|
||||
(fn [db [_ z]] (assoc-in db [:export :zoom] z)))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::start
|
||||
(fn [{:keys [db]} _]
|
||||
(if (get-in db [:export :busy?])
|
||||
{}
|
||||
(let [id (:clip/current db)
|
||||
entry (store/entry id)
|
||||
{:keys [timeline zoom isolate]} (:export db)]
|
||||
{:db (update db :export merge {:busy? true :done 0
|
||||
:total (:frames (export/plan
|
||||
{:clip (:clip entry)
|
||||
:timeline timeline
|
||||
:isolate isolate
|
||||
:zoom zoom}))
|
||||
:status "rendering…"})
|
||||
::run! {:clip (:clip entry)
|
||||
:timeline timeline
|
||||
:isolate isolate
|
||||
:store (:store entry)
|
||||
;; The same palette and ramp the preview resolves and blits
|
||||
;; through. Read here rather than in the fx so that the effect
|
||||
;; takes data and nothing else.
|
||||
:palette (get {:arthur/default pal/index-of}
|
||||
(:palette db) pal/index-of)
|
||||
:ramp (get {:arthur/default pal/rgb} (:palette db) pal/rgb)
|
||||
:zoom zoom
|
||||
:picture-fps (get-in db [:clip :display-fps])
|
||||
:audio-url (:audio entry)
|
||||
:name (stem (:label entry)
|
||||
(label-of (:clip entry)
|
||||
{:timeline timeline :isolate isolate}))}}))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::progress
|
||||
(fn [db [_ done total]]
|
||||
(update db :export merge {:done done :total total})))
|
||||
|
||||
(rf/reg-event-db
|
||||
::done
|
||||
(fn [db [_ filename bytes]]
|
||||
(update db :export merge
|
||||
{:busy? false
|
||||
:status (str "wrote " filename " · "
|
||||
(.toFixed (/ bytes 1048576) 1) " MB")})))
|
||||
|
||||
(rf/reg-event-db
|
||||
::failed
|
||||
(fn [db [_ message]]
|
||||
(update db :export merge {:busy? false :status (str "export failed: " message)})))
|
||||
|
||||
(defn- download!
|
||||
"Hand the browser a blob as a file.
|
||||
|
||||
The object URL is revoked on a timeout rather than immediately: the click starts
|
||||
the download asynchronously and revoking in the same turn cancels it in some
|
||||
browsers, which presents as the button doing nothing at all."
|
||||
[filename ^js blob]
|
||||
(let [url (js/URL.createObjectURL blob)
|
||||
a (.createElement js/document "a")]
|
||||
(set! (.-href a) url)
|
||||
(set! (.-download a) filename)
|
||||
(.appendChild (.-body js/document) a)
|
||||
(.click a)
|
||||
(.removeChild (.-body js/document) a)
|
||||
(js/setTimeout #(js/URL.revokeObjectURL url) 30000)))
|
||||
|
||||
(rf/reg-fx
|
||||
::run!
|
||||
(fn [spec]
|
||||
;; THE CALL IS GUARDED because `export/run!` validates its request BEFORE it
|
||||
;; returns a promise, so a bad timeline id throws synchronously — here, inside
|
||||
;; re-frame's `:do-fx` interceptor. An uncaught throw there never reaches the
|
||||
;; `.catch` below, so `::failed` never dispatches and `:busy?` stays true: the
|
||||
;; button sits disabled on \"rendering…\" and the readout on \"frame 0 /\"
|
||||
;; forever. That reads as the tab having locked up, which is the worst way for
|
||||
;; an export to fail — there is nothing to see and nothing in the status line.
|
||||
;; Turning the throw into a rejection gives every failure one path to the user.
|
||||
(-> (try (export/run! spec
|
||||
(frames/exporter)
|
||||
(fn [done total] (rf/dispatch [::progress done total])))
|
||||
(catch :default e (js/Promise.reject e)))
|
||||
(.then (fn [{:keys [filename ^js blob]}]
|
||||
(download! filename blob)
|
||||
(rf/dispatch [::done filename (.-size blob)])))
|
||||
(.catch (fn [error]
|
||||
(js/console.error error)
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
|
||||
|
|
@ -89,7 +89,7 @@
|
|||
(let [gaps (detect/fill-gaps @raw)]
|
||||
(mark! "fill-gaps")
|
||||
(assoc gaps :dimensions [w h] :crops @crops
|
||||
:interior @inner)))))))
|
||||
:interior @inner :interior-settings take/knobs)))))))
|
||||
|
||||
(defn- build-clip [manifest detector
|
||||
{:keys [dense detected dimensions interior missing first-real]
|
||||
|
|
@ -131,6 +131,7 @@
|
|||
(-> (http/GET (str "/api/blocks/" key))
|
||||
(.then (fn [block]
|
||||
(assoc track :interior (source/unpack-interior block take/knobs frames)
|
||||
:interior-settings take/knobs
|
||||
:interior-key key)))
|
||||
(.catch (fn [error]
|
||||
(if (= 404 (:status (ex-data error))) track (throw error)))))))
|
||||
|
|
@ -153,10 +154,21 @@
|
|||
(saved-source! (:id (take/analysis-for manifest detector))
|
||||
[(:width manifest) (:height manifest)]))
|
||||
|
||||
(defn- measure-cached! [track]
|
||||
;; Older analyses may lack the optional interior block. Spread their one-time
|
||||
;; measurement over event-loop turns just like the fresh decode path.
|
||||
(if (:interior track)
|
||||
(defn measure-crops!
|
||||
"Measure every retained crop's interior, ONE PER EVENT-LOOP TURN.
|
||||
|
||||
Done in a tight loop instead, it is ten seconds of synchronous arithmetic with
|
||||
the main thread held and the frame counter frozen on its last value — which
|
||||
reads as the decoder hanging, and was diagnosed as that twice.
|
||||
|
||||
Two callers, and the only difference between them is who is waiting: opening an
|
||||
older analysis that predates the interior block backfills it behind a progress
|
||||
line, and a knob drag that needs the pixels measured at new settings does the
|
||||
same work with nobody watching, so it passes no `on-step`. The settings ride
|
||||
back on the track, because a measurement and the knobs it was taken at are one
|
||||
fact — `source/pack` will not address an interior block without them."
|
||||
[settings track on-step]
|
||||
(if (or (:interior track) (not (:crops track)))
|
||||
(js/Promise.resolve track)
|
||||
(let [crops (:crops track)
|
||||
total (count crops)
|
||||
|
|
@ -165,12 +177,11 @@
|
|||
(fn [resolve reject]
|
||||
(letfn [(step [i]
|
||||
(if (= i total)
|
||||
(resolve (assoc track :interior @interior))
|
||||
(resolve (assoc track :interior @interior
|
||||
:interior-settings settings))
|
||||
(try
|
||||
(swap! interior conj (source/measure-crop take/knobs (nth crops i)))
|
||||
(when (or (zero? i) (zero? (mod (inc i) 4)) (= (inc i) total))
|
||||
(rf/dispatch [::progress
|
||||
(str "measuring " (inc i) "/" total)]))
|
||||
(swap! interior conj (source/measure-crop settings (nth crops i)))
|
||||
(when on-step (on-step (inc i) total))
|
||||
(js/setTimeout #(step (inc i)) 0)
|
||||
(catch :default error (reject error)))))]
|
||||
(step 0)))))))
|
||||
|
|
@ -186,7 +197,13 @@
|
|||
(.then (fn [track]
|
||||
(if track
|
||||
(do (rf/dispatch [::progress "reusing saved analysis…"])
|
||||
(-> (measure-cached! track)
|
||||
(-> (measure-crops!
|
||||
take/knobs track
|
||||
(fn [done total]
|
||||
(when (or (= 1 done) (zero? (mod done 4))
|
||||
(= done total))
|
||||
(rf/dispatch
|
||||
[::progress (str "measuring " done "/" total)]))))
|
||||
(.then (fn [measured]
|
||||
(build-clip manifest detector measured)))))
|
||||
(do (rf/dispatch [::progress "loading MediaPipe…"])
|
||||
|
|
|
|||
|
|
@ -210,24 +210,6 @@
|
|||
(defonce ^:private retained-source (atom nil))
|
||||
(defonce ^:private retained-interior (atom nil))
|
||||
|
||||
(defn- measure-crops! [inputs settings]
|
||||
;; Existing analyses without an interior block pay this once. One crop per
|
||||
;; event-loop turn keeps playback responsive during that backfill.
|
||||
(if (or (:interior inputs) (not (:crops inputs)))
|
||||
(js/Promise.resolve inputs)
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(let [crops (:crops inputs)
|
||||
measured (atom [])]
|
||||
(letfn [(step [i]
|
||||
(if (= i (count crops))
|
||||
(resolve (assoc inputs :interior @measured))
|
||||
(try
|
||||
(swap! measured conj (source/measure-crop settings (nth crops i)))
|
||||
(js/setTimeout #(step (inc i)) 0)
|
||||
(catch :default error (reject error)))))]
|
||||
(step 0)))))))
|
||||
|
||||
(defn- retained-interior! [analysis settings inputs]
|
||||
(let [frames (count (:crops inputs))
|
||||
block-key (source/interior-key analysis settings frames)]
|
||||
|
|
@ -238,7 +220,7 @@
|
|||
:interior-key block-key)))
|
||||
(.catch (fn [error]
|
||||
(if (= 404 (:status (ex-data error)))
|
||||
(-> (measure-crops! (dissoc inputs :interior) settings)
|
||||
(-> (footage/measure-crops! settings (dissoc inputs :interior) nil)
|
||||
(.then (fn [measured]
|
||||
(let [{:keys [key descriptor data]}
|
||||
(source/interior-block analysis settings
|
||||
|
|
|
|||
242
frontend/src/arthur/export.cljs
Normal file
242
frontend/src/arthur/export.cljs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
(ns arthur.export
|
||||
"Export a TIMELINE: one frame walk, and a sink that decides what comes out.
|
||||
|
||||
THE SINK IS A PROTOCOL because there is more than one right answer to \"a
|
||||
video file\" and they disagree about the thing this project cares most about.
|
||||
A PNG sequence is bit-exact — the file holds the bytes `raster/draw-ops!`
|
||||
produced, expanded through the ramp and nothing else. A muxed MP4 is one file
|
||||
that plays anywhere, and every codec a browser can reach either subsamples
|
||||
chroma (which puts fringes on precisely the hard flat-colour edges the whole
|
||||
idiom is made of) or is a codec an NLE will not open. Those are different
|
||||
trades for different jobs, not a better and a worse, so both should be
|
||||
reachable and neither should be the other's special case.
|
||||
|
||||
What is genuinely shared is everything above the sink, and it is most of the
|
||||
work: rooting the resolver at the chosen timeline, the picture-rate time map,
|
||||
the raster, the frame loop, the audio mix, the progress reporting and the
|
||||
yielding that lets the page paint. So `run!` owns all of that and calls three
|
||||
methods.
|
||||
|
||||
TWO RULES THE WALK ENFORCES, both about sync:
|
||||
|
||||
Every frame of the timeline's frame space is emitted, at the CLIP's rate. A
|
||||
lower picture rate holds a pose across several frames — it never drops them —
|
||||
so the exported duration matches the audio no matter what the picture rate is.
|
||||
Decimating instead is how an export silently runs short and the sound slides
|
||||
off the picture, which is the one artefact this tool exists to prevent.
|
||||
|
||||
The zoom is an INTEGER. A pixel becomes a block of identical pixels. Anything
|
||||
else resamples, and `domain/png` and `ui/canvas` both have the longer argument
|
||||
for why that is not allowed to happen here."
|
||||
;; `run!` is the verb this namespace is about, and nothing here folds a
|
||||
;; side-effect over a seq, so core's loses the name rather than ours.
|
||||
(:refer-clojure :exclude [run!])
|
||||
(:require [arthur.audio.mix :as mix]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.raster :as raster]))
|
||||
|
||||
(defprotocol Exporter
|
||||
"A sink for a rendered timeline. Implementations live under `arthur.export.*`.
|
||||
|
||||
Called in this order, once, per export: `begin!`, then `frame!` for every frame
|
||||
in order from 0, then `finish!`. Any of them may return a promise and the walk
|
||||
waits for it, which is what keeps a slow encoder from being fed faster than it
|
||||
drains and what gives the page a chance to paint between frames.
|
||||
|
||||
`Exporter` rather than `IExporter`, which is what `domain/timeline`'s
|
||||
`IResolver` would suggest, because it names a role a thing plays rather than a
|
||||
capability a value has."
|
||||
|
||||
(begin! [this spec]
|
||||
"Prepare to receive frames.
|
||||
|
||||
`spec` carries everything constant for the export:
|
||||
|
||||
:name a filesystem-safe stem for the artefact
|
||||
:width stage width in raster pixels, before zoom
|
||||
:height stage height, before zoom
|
||||
:zoom integer pixel multiplier
|
||||
:fps frames per second of the finished file — the CLIP's rate
|
||||
:frames how many frames will arrive
|
||||
:ramp index -> [r g b], the palette to expand through
|
||||
:audio an AudioBuffer, or nil when the timeline has no sound
|
||||
|
||||
The ramp and the audio are here rather than on `frame!` because neither
|
||||
changes across an export, and a muxer has to declare its tracks before it
|
||||
will accept a sample.")
|
||||
|
||||
(frame! [this i raster]
|
||||
"Take frame `i`, an indexed `domain/raster`.
|
||||
|
||||
THE RASTER IS REUSED and must be consumed before this returns (or before the
|
||||
promise it returns settles). The walk hands back the same buffer every frame,
|
||||
for the same reason `timeline/resolver` reuses its point buffers: a 900-frame
|
||||
export that allocates a stage per frame is a tab that swaps. A sink that wants
|
||||
to keep pixels has to copy or encode them here.")
|
||||
|
||||
(finish! [this]
|
||||
"Close the artefact. Promise of `{:filename :blob}`."))
|
||||
|
||||
(defn kin
|
||||
"The ids to keep when isolating `id` in `nodes`.
|
||||
|
||||
Four things, and each for its own reason:
|
||||
|
||||
the node itself;
|
||||
everything ABOVE it, because a placement's transform is relative to its
|
||||
parent and dropping the chain would move the thing being isolated;
|
||||
everything BELOW it, because a group instance is its children;
|
||||
any audio track `:linked-to` it, because the link is the statement that this
|
||||
sound belongs to that placement, and a face exported without its voice is
|
||||
not the thing that was asked for.
|
||||
|
||||
Siblings go. That is the whole point: what comes out is one placement, where it
|
||||
sits, on the timeline it sits on."
|
||||
[nodes id]
|
||||
(let [up (loop [i id acc #{}]
|
||||
(if (or (nil? i) (contains? acc i))
|
||||
acc
|
||||
(recur (:parent (get nodes i)) (conj acc i))))
|
||||
down (loop [edge #{id} acc #{}]
|
||||
(if (empty? edge)
|
||||
acc
|
||||
(let [acc' (into acc edge)]
|
||||
(recur (set (for [[k n] nodes
|
||||
:when (and (contains? edge (:parent n))
|
||||
(not (contains? acc' k)))]
|
||||
k))
|
||||
acc'))))
|
||||
kept (into up down)]
|
||||
(into kept
|
||||
(for [[k n] nodes
|
||||
:when (and (= :audio (:kind n)) (contains? kept (:linked-to n)))]
|
||||
k))))
|
||||
|
||||
(defn isolate
|
||||
"The timeline with only `id` and its kin kept. `nil` leaves it alone.
|
||||
|
||||
The FRAME SPACE IS UNTOUCHED, which is what makes this different from exporting
|
||||
the symbol a placement plays. Rooting at `:sym/face-8625` renders the drawing in
|
||||
its own time, identically for all seven placements. Isolating one placement
|
||||
renders the STAGE — its length, its rate, the placement's span, drift and scale
|
||||
— with the other six removed. The first is the drawing; the second is that face
|
||||
on the stage, and they are different deliverables."
|
||||
[tl id]
|
||||
(if (and id (get-in tl [:nodes id]))
|
||||
(update tl :nodes select-keys (kin (:nodes tl) id))
|
||||
tl))
|
||||
|
||||
(defn- sampled
|
||||
"The timeline with picture-rate sampling written onto its root node's time map.
|
||||
|
||||
The same transform `subs/render/::timeline` applies for playback, and
|
||||
deliberately the same one: an export that sampled poses differently from the
|
||||
preview would make the preview a lie about the deliverable. Only the root node's
|
||||
time map changes — the dense source track stays at its native rate, and the
|
||||
frame COUNT is untouched, which is what keeps the duration and therefore the
|
||||
audio right."
|
||||
[tl source-fps picture-fps]
|
||||
(if (and picture-fps source-fps (< picture-fps source-fps))
|
||||
(-> tl
|
||||
(assoc-in [:nodes :root :time :source-fps] source-fps)
|
||||
(assoc-in [:nodes :root :time :sample-fps] picture-fps))
|
||||
tl))
|
||||
|
||||
(defn- yield!
|
||||
"Hand the event loop a turn between frames.
|
||||
|
||||
`setTimeout 0` and not a resolved promise: a promise continuation is a
|
||||
microtask, so a chain of them runs to completion without the browser ever
|
||||
painting, and the progress readout would jump from 0 to done. `flow/ingest`'s
|
||||
decode loop pauses for the same reason."
|
||||
[]
|
||||
(js/Promise. (fn [resolve] (js/setTimeout resolve 0))))
|
||||
|
||||
(defn audio!
|
||||
"Promise of the AudioBuffer to export alongside the picture, or nil.
|
||||
|
||||
A timeline's own placed audio tracks win. Failing that, the ROOT timeline — and
|
||||
only the root — falls back to the clip's audio file, which is where a take's
|
||||
sound lives before anyone has placed a track. A symbol exports silence rather
|
||||
than the whole clip's soundtrack, because a symbol's frame space is its own and
|
||||
the clip's audio is not a fact about it."
|
||||
[clip-doc tid store fallback-url]
|
||||
(-> (mix/buffer! clip-doc tid store)
|
||||
(.then (fn [buffer]
|
||||
(cond
|
||||
buffer buffer
|
||||
(and (= tid clip/root-id) fallback-url) (mix/decode! fallback-url)
|
||||
:else nil)))))
|
||||
|
||||
(defn plan
|
||||
"What an export of `tid` will produce, without producing any of it.
|
||||
|
||||
Separate from `run!` so the UI can show the size and length it is about to
|
||||
commit to, and so the arithmetic is assertable without a sink."
|
||||
[{:keys [clip timeline zoom picture-fps] isolate-id :isolate}]
|
||||
(let [tl (some-> (clip/timeline clip timeline) (isolate isolate-id))
|
||||
zoom (max 1 (js/Math.floor (or zoom 1)))]
|
||||
(when tl
|
||||
{:frames (:frames tl)
|
||||
:fps (:fps clip)
|
||||
:zoom zoom
|
||||
:width (* (:width clip) zoom)
|
||||
:height (* (:height clip) zoom)
|
||||
:seconds (/ (:frames tl) (:fps clip))
|
||||
;; The poses the picture actually holds, which is what a lower picture rate
|
||||
;; changes. The frame COUNT does not move — see the namespace docstring.
|
||||
:poses (if (and picture-fps (< picture-fps (:fps clip)))
|
||||
(js/Math.ceil (* (/ (:frames tl) (:fps clip)) picture-fps))
|
||||
(:frames tl))})))
|
||||
|
||||
(defn run!
|
||||
"Render `timeline` into `exporter`. Promise of `{:filename :blob}`.
|
||||
|
||||
`on-progress` is called with `[done total]` as frames complete, and is where a
|
||||
UI hangs its readout."
|
||||
[{:keys [clip timeline store palette ramp zoom picture-fps name audio-url]
|
||||
isolate-id :isolate}
|
||||
exporter on-progress]
|
||||
(let [tl (some-> (clip/timeline clip timeline) (isolate isolate-id))]
|
||||
(when-not tl
|
||||
(throw (ex-info "there is no such timeline to export"
|
||||
{:timeline timeline
|
||||
:timelines (vec (sort-by str (keys (:timelines clip))))})))
|
||||
(let [{:keys [frames fps zoom]} (plan {:clip clip :timeline timeline :zoom zoom
|
||||
:isolate isolate-id})
|
||||
;; Rooted at the chosen timeline, so exporting a symbol is exporting a
|
||||
;; clip whose root that symbol is. Nested symbols inside it still
|
||||
;; resolve — clip/resolver is the function that knows how.
|
||||
doc (assoc-in clip [:timelines timeline]
|
||||
(sampled tl (:fps clip) picture-fps))
|
||||
resolve-frame (clip/resolver doc store palette timeline)
|
||||
ras (raster/make (:width clip) (:height clip))
|
||||
bg (get palette :bg 0)]
|
||||
(-> (audio! doc timeline store audio-url)
|
||||
(.then (fn [audio]
|
||||
(js/Promise.resolve
|
||||
(begin! exporter {:name name :width (:width clip)
|
||||
:height (:height clip) :zoom zoom
|
||||
:fps fps :frames frames :ramp ramp
|
||||
:audio audio}))))
|
||||
(.then (fn [_]
|
||||
;; A fold over the frames as a promise CHAIN rather than a
|
||||
;; doseq: each frame has to wait for the last one's sink to
|
||||
;; drain, and `reduce` building that chain is the shape that
|
||||
;; says so. Nothing here is concurrent on purpose — an encoder
|
||||
;; fed from two places at once is not fast, it is wrong.
|
||||
(reduce
|
||||
(fn [chain i]
|
||||
(.then chain
|
||||
(fn [_]
|
||||
(-> ras
|
||||
(raster/clear! bg)
|
||||
(raster/draw-ops! (resolve-frame i)))
|
||||
(-> (js/Promise.resolve (frame! exporter i ras))
|
||||
(.then (fn [_]
|
||||
(when on-progress
|
||||
(on-progress (inc i) frames))
|
||||
(yield!)))))))
|
||||
(js/Promise.resolve)
|
||||
(range frames))))
|
||||
(.then (fn [_] (finish! exporter)))))))
|
||||
77
frontend/src/arthur/export/frames.cljs
Normal file
77
frontend/src/arthur/export/frames.cljs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
(ns arthur.export.frames
|
||||
"An `Exporter` that writes a numbered PNG sequence and its WAV into one zip.
|
||||
|
||||
THE MASTER FORMAT. Every other export is a re-interpretation of this one: the
|
||||
PNGs hold exactly the bytes `raster/draw-ops!` wrote, expanded through the ramp
|
||||
at an integer zoom, so nothing between the scanline fill and the file resamples,
|
||||
subsamples or smooths. `domain/png` has the argument for why that matters here
|
||||
more than it would in most tools.
|
||||
|
||||
IT IS ALSO SMALLER THAN IT SOUNDS, which is worth saying because \"lossless
|
||||
frame sequence\" reads as gigabytes. That intuition comes from photographic
|
||||
frames — the 1440x1920 source stills this project stopped storing were 112MB for
|
||||
7.6 seconds. This is nine palette colours of flat fill at 320x200, upscaled by
|
||||
an integer: a frame's entropy is on the order of kilobytes, and the zoom is
|
||||
nearly free because a duplicated scanline filters to zeros. The finished master
|
||||
of a take runs comparable to the lossy PROXY of the footage it came from.
|
||||
|
||||
ONE ARCHIVE, PICTURE AND SOUND TOGETHER, rather than two downloads. They have
|
||||
to stay in sync all the way to the cutting room, and a second `<a download>`
|
||||
click is also the one a browser is most likely to block."
|
||||
(:require [arthur.audio.mix :as mix]
|
||||
[arthur.domain.png :as png]
|
||||
[arthur.domain.zip :as zip]
|
||||
[arthur.export :as export]))
|
||||
|
||||
(defn- pad
|
||||
"Frame numbers are ONE-BASED and zero-padded to a fixed width, because that is
|
||||
what an NLE's image-sequence importer looks for: a common stem, a fixed-width
|
||||
counter, one extension. Width comes from the frame count, so a 900-frame export
|
||||
is `0001`..`0900` and nothing sorts `10` before `9`."
|
||||
[i width]
|
||||
(let [s (str i)]
|
||||
(str (.repeat "0" (max 0 (- width (.-length s)))) s)))
|
||||
|
||||
(defn exporter
|
||||
"A frame-sequence `Exporter`.
|
||||
|
||||
`at` is the timestamp stamped on every zip entry, defaulting to now. It is a
|
||||
parameter so that the same frames produce the same archive byte for byte, which
|
||||
is what makes `export.frames-test` able to assert on one."
|
||||
([] (exporter (js/Date.)))
|
||||
([at]
|
||||
(let [state (atom nil)]
|
||||
(reify export/Exporter
|
||||
(begin! [_ {:keys [name width height zoom fps frames ramp audio]}]
|
||||
(reset! state
|
||||
{:name name
|
||||
:ramp ramp
|
||||
:fps fps
|
||||
:frames frames
|
||||
;; Held once. At zoom 6 the scratch inside it is seven
|
||||
;; megabytes, which is not a thing to allocate per frame.
|
||||
:encode (png/encoder width height zoom)
|
||||
:digits (max 4 (.-length (str frames)))
|
||||
:entries (cond-> []
|
||||
audio (conj {:name (str name "/audio.wav")
|
||||
:data (mix/wav-bytes audio)}))})
|
||||
nil)
|
||||
|
||||
(frame! [_ i raster]
|
||||
(let [{:keys [encode ramp name digits]} @state]
|
||||
;; Encoded HERE, inside the frame's turn, because the walk reuses the
|
||||
;; raster: keeping a reference to it and encoding later would encode
|
||||
;; the last frame N times, and every frame would be a valid PNG of the
|
||||
;; wrong picture.
|
||||
(-> (encode raster ramp)
|
||||
(.then (fn [bytes]
|
||||
(swap! state update :entries conj
|
||||
{:name (str name "/" (pad (inc i) digits) ".png")
|
||||
:data bytes})
|
||||
nil)))))
|
||||
|
||||
(finish! [_]
|
||||
(let [{:keys [name entries]} @state]
|
||||
(js/Promise.resolve
|
||||
{:filename (str name ".zip")
|
||||
:blob (zip/blob entries at)})))))))
|
||||
|
|
@ -187,6 +187,40 @@
|
|||
[knob]
|
||||
(get knob-roles knob #{}))
|
||||
|
||||
(def area-roles
|
||||
"Feature area -> the block roles `freeze/part` freezes for it.
|
||||
|
||||
The other half of a question neither table answers alone. `block-knobs` says
|
||||
which knobs reach a ROLE's bytes; this says which roles a FEATURE owns; and what
|
||||
a regeneration actually asks is which knobs reach one feature."
|
||||
{:mouth ["geom"]
|
||||
:eye ["eyes" "iris-pos"]
|
||||
:brow ["brows" "brow-pos"]
|
||||
:teeth ["teeth"]})
|
||||
|
||||
(def framed-knobs
|
||||
"Feature area -> the knobs its TIER 1 channels read.
|
||||
|
||||
What `block-knobs` cannot answer and deliberately does not: a framed radius and a
|
||||
keyed `[:vis]` hold no bytes, so no block key moves when they move — and a
|
||||
re-freeze still has to happen or the knob does nothing at all. This is the half
|
||||
that used to live nowhere, and a regeneration had to guess at with a per-knob
|
||||
special case. `regenerate-test` asserts the biconditional over the union, the
|
||||
same way `address-test` does for `block-knobs`, so it is checked and not believed.
|
||||
|
||||
`:aperture-cut` is here AND in the teeth block: it gates `mouth-in`'s visibility
|
||||
in tier 1 and the interior contour's smoothing in tier 2. One knob, two features,
|
||||
two routes — which is exactly why this cannot be a per-area list of its own."
|
||||
{:mouth #{:aperture-cut}
|
||||
:eye #{:blink-cut :iris-size :pupil-size}
|
||||
:brow #{}
|
||||
:teeth #{}})
|
||||
|
||||
(defn area-knobs
|
||||
"Every knob one feature area's frozen output depends on, across both tiers."
|
||||
[area]
|
||||
(into (get framed-knobs area #{}) (mapcat block-knobs) (get area-roles area)))
|
||||
|
||||
(defn block-descriptor
|
||||
"The canonical text naming one dense block.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@
|
|||
[arthur.flow.freeze :as freeze]
|
||||
[arthur.flow.take :as take]))
|
||||
|
||||
(defn- settings [clip fid]
|
||||
(defn- settings
|
||||
"One feature's MEASUREMENT inputs. The teeth read the mouth's aperture cut
|
||||
because `condition/interior` will not smooth a contour on a frame the mouth is
|
||||
shut on: an input edge between two features, and the only one there is."
|
||||
[clip fid]
|
||||
(let [f (get-in clip [:features fid])
|
||||
mouth (first (for [[id peer] (:features clip)
|
||||
:when (and (= :mouth (:area peer))
|
||||
|
|
@ -17,6 +21,19 @@
|
|||
(and (= :teeth (:area f)) mouth)
|
||||
(assoc :aperture-cut (:aperture-cut (feature/effective-params clip mouth))))))
|
||||
|
||||
(defn- reads
|
||||
"The knob values one feature's frozen channels actually depend on.
|
||||
|
||||
Narrower than its measurement inputs, and that difference IS the dirty-set
|
||||
calculation. `:contour-avg` is a subject setting every feature inherits and the
|
||||
teeth block does not read, so inheriting a knob and being stale because of it are
|
||||
not the same thing. `address/area-knobs` is what knows which is which, over the
|
||||
table `address-test` asserts by biconditional — so there is no second per-knob
|
||||
list here to drift away from the one that is checked."
|
||||
[clip fid]
|
||||
(select-keys (settings clip fid)
|
||||
(address/area-knobs (get-in clip [:features fid :area]))))
|
||||
|
||||
(defn- replace-feature [entry fragment fid]
|
||||
(let [paths (for [id (get-in entry [:clip :features fid :nodes])
|
||||
[prop channel] (get-in fragment [:nodes id :channels])
|
||||
|
|
@ -32,8 +49,9 @@
|
|||
(update :store merge (:store fragment)))))
|
||||
|
||||
(defn plan
|
||||
"The changed document and feature IDs an edit dirties. Also reports tier-2
|
||||
block roles from the existing address table for the debug UI."
|
||||
"The changed document, the feature IDs an edit dirties, and the subject they
|
||||
belong to. Also reports tier-2 block roles from the address table for the
|
||||
debug UI."
|
||||
[clip {:keys [scope id knob value]}]
|
||||
(let [area (get-in params/definitions [knob :area])
|
||||
collection (case scope
|
||||
|
|
@ -49,63 +67,71 @@
|
|||
:knob knob :value value})))
|
||||
(let [changed (assoc-in clip [collection id :params knob] value)
|
||||
subject (if (= scope :subject) id (:subject owner))
|
||||
members (case scope
|
||||
:subject (for [[fid f] (:features clip)
|
||||
:when (= subject (:subject f))] fid)
|
||||
:feature [id]
|
||||
:group (:members owner))
|
||||
members (if (= knob :contour-avg)
|
||||
(remove #(= :teeth (get-in clip [:features % :area])) members)
|
||||
members)
|
||||
members (if (= knob :aperture-cut)
|
||||
(concat members (for [[fid f] (:features clip)
|
||||
:when (and (= subject (:subject f))
|
||||
(= :teeth (:area f)))] fid))
|
||||
members)]
|
||||
;; Every feature of the subject is a candidate, not just the edited
|
||||
;; object's own members, because a knob can reach a feature it does not
|
||||
;; belong to: `:aperture-cut` is a mouth setting that the TEETH read.
|
||||
;; `reads` is what narrows this back down, and it is the only thing
|
||||
;; that does — no per-knob cases here, in either direction.
|
||||
candidates (sort-by str (for [[fid f] (:features clip)
|
||||
:when (= subject (:subject f))] fid))]
|
||||
{:changed changed
|
||||
:features (vec (filter #(not= (settings clip %) (settings changed %))
|
||||
(distinct members)))
|
||||
:subject subject
|
||||
:features (vec (filter #(not= (reads clip %) (reads changed %)) candidates))
|
||||
:roles (address/invalidates knob)})))
|
||||
|
||||
(defn- change-take
|
||||
"One scoped static edit. `source-inputs` contains dense landmarks and, when
|
||||
teeth are dirty, retained pixel measurements. No IO or app-db here."
|
||||
[{:keys [clip source-inputs] :as entry} edit]
|
||||
(let [{changed :changed fids :features} (plan clip edit)]
|
||||
(when-not (:dense source-inputs)
|
||||
(throw (ex-info "regeneration needs retained source landmarks" {})))
|
||||
(if (empty? fids)
|
||||
(assoc entry :clip changed)
|
||||
(let [base-params (merge take/knobs
|
||||
{:fps (:fps clip)
|
||||
:aspect (get-in clip [:analysis :aspect])
|
||||
:analysis (:analysis clip)})
|
||||
base (delay (take/anchor-base
|
||||
(merge base-params (settings changed (first fids)))
|
||||
source-inputs))
|
||||
entry (reduce
|
||||
(fn [entry fid]
|
||||
(let [part-area (get-in changed [:features fid :area])
|
||||
p (merge base-params (settings changed fid))
|
||||
_ (when (and (= part-area :teeth)
|
||||
(nil? (:interior source-inputs)))
|
||||
(defn- regenerate-feature
|
||||
"One dirty feature, re-measured through the shared anchor and re-frozen. A brow
|
||||
reads the eye corners and the teeth read mouth aperture; `take/measure-part`
|
||||
owns those input edges, and neither one is a request to freeze the other
|
||||
feature."
|
||||
[base-params base source-inputs entry fid]
|
||||
(let [area (get-in entry [:clip :features fid :area])
|
||||
params (merge base-params (settings (:clip entry) fid))]
|
||||
(when (and (= :teeth area) (nil? (:interior source-inputs)))
|
||||
(throw (ex-info "teeth regeneration needs retained pixel measurements"
|
||||
{:feature fid})))
|
||||
measured (take/measure-part part-area p source-inputs @base)]
|
||||
(replace-feature entry (freeze/part part-area p measured) fid)))
|
||||
(assoc entry :clip changed) fids)]
|
||||
(if (= :anchor-avg (:knob edit))
|
||||
(let [p (merge base-params
|
||||
(settings changed (first fids)))
|
||||
baked (freeze/head-part p @base)
|
||||
old-head (get-in clip [:timelines :main :nodes :head])
|
||||
(replace-feature entry
|
||||
(freeze/part area params
|
||||
(take/measure-part area params source-inputs @base))
|
||||
fid)))
|
||||
|
||||
(defn- regenerate-head
|
||||
"Re-freeze the head transform, which is a different job from a feature's: its
|
||||
only input is the conditioned anchor, and it owns no channels to replace. The
|
||||
authored `:channels` follow the measurement while they still ARE the
|
||||
measurement, and are left alone once somebody has placed the head by hand."
|
||||
[entry params base]
|
||||
(let [baked (freeze/head-part params @base)
|
||||
at [:clip :timelines clip/root-id :nodes :head]
|
||||
old (get-in entry at)
|
||||
measured (:measured baked)]
|
||||
(cond-> (-> entry
|
||||
(assoc-in [:clip :timelines :main :nodes :head :measured] measured)
|
||||
(assoc-in (conj at :measured) measured)
|
||||
(update :store merge (:store baked)))
|
||||
(= (:channels old-head) (:measured old-head))
|
||||
(assoc-in [:clip :timelines :main :nodes :head :channels] measured)))
|
||||
entry)))))
|
||||
(= (:channels old) (:measured old))
|
||||
(assoc-in (conj at :channels) measured))))
|
||||
|
||||
(defn- change-take
|
||||
"One scoped static edit. `source-inputs` holds dense landmarks and, when the
|
||||
teeth are dirty, retained pixel measurements. No IO or app-db here."
|
||||
[{:keys [clip source-inputs] :as entry} edit]
|
||||
(when-not (:dense source-inputs)
|
||||
(throw (ex-info "regeneration needs retained source landmarks" {})))
|
||||
(let [{:keys [changed subject features]} (plan clip edit)
|
||||
base-params (merge take/knobs
|
||||
{:fps (:fps changed)
|
||||
:aspect (get-in changed [:analysis :aspect])
|
||||
:analysis (:analysis changed)})
|
||||
;; One conditioned anchor for the whole edit, and it is the SUBJECT's.
|
||||
;; `:anchor-avg` is a subject setting, so the shared upstream measurement
|
||||
;; is not read off whichever dirty feature happened to sort first — and
|
||||
;; the head below does not need a feature to exist at all.
|
||||
anchor-params (merge base-params (params/for-area :subject)
|
||||
(get-in changed [:subjects subject :params]))
|
||||
base (delay (take/anchor-base anchor-params source-inputs))]
|
||||
(cond-> (reduce (partial regenerate-feature base-params base source-inputs)
|
||||
(assoc entry :clip changed) features)
|
||||
(= :anchor-avg (:knob edit)) (regenerate-head anchor-params base))))
|
||||
|
||||
(defn change
|
||||
"A take edits its root timeline. A stage edits the shared symbol timeline;
|
||||
|
|
@ -114,12 +140,12 @@
|
|||
(if-let [symbol (some :timeline (vals (:features clip)))]
|
||||
(let [original (:timelines clip)
|
||||
take (assoc clip :timelines
|
||||
{:main (assoc (get original symbol) :id :main)})
|
||||
{clip/root-id (assoc (get original symbol) :id clip/root-id)})
|
||||
changed (change-take (assoc entry :clip take) edit)]
|
||||
(assoc changed :clip
|
||||
(-> (:clip changed)
|
||||
(assoc :timelines
|
||||
(assoc original symbol
|
||||
(assoc (get-in changed [:clip :timelines :main])
|
||||
(assoc (get-in changed [:clip :timelines clip/root-id])
|
||||
:id symbol))))))
|
||||
(change-take entry edit)))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
measurement block. Everything after this boundary can run without PNGs or
|
||||
MediaPipe. Mouth crops retain pixels for settings that change the measurement."
|
||||
(:require [arthur.domain.landmarks :as lm]
|
||||
[arthur.domain.params :as params]
|
||||
[arthur.domain.wire :as wire]
|
||||
[arthur.flow.address :as address]
|
||||
[arthur.flow.measure.interior :as interior]))
|
||||
|
|
@ -90,8 +89,13 @@
|
|||
{:role role :data data}))
|
||||
|
||||
(defn pack
|
||||
"Dense landmarks, mask, crops and optional pixel measurements -> source blocks."
|
||||
[analysis {:keys [dense detected crops interior]}]
|
||||
"Dense landmarks, mask, crops and optional pixel measurements -> source blocks.
|
||||
|
||||
`:interior-settings` is not optional when `:interior` is present: the block is
|
||||
addressed BY those knobs, and defaulting them here would name a block after
|
||||
settings its bytes did not come from — a content address that lies, which is the
|
||||
one failure the key exists to prevent."
|
||||
[analysis {:keys [dense detected crops interior interior-settings]}]
|
||||
(let [frames (count dense)
|
||||
points (count (first dense))]
|
||||
(when-not (and (pos? frames) (pos? points)
|
||||
|
|
@ -119,16 +123,25 @@
|
|||
(aset landmarks (+ base 2) z)))
|
||||
(when-let [crop (nth crops f)]
|
||||
(.set pixels (:data crop) (nth offsets f))))
|
||||
(cond-> {"source/dense" (named "source/dense" analysis ["landmarks"]
|
||||
{:type "float64" :frames frames :points points :stride (* 3 points)}
|
||||
(cond-> {"source/dense"
|
||||
(named "source/dense" analysis ["landmarks"]
|
||||
{:type "float64" :frames frames :points points
|
||||
:stride (* 3 points)}
|
||||
landmarks)
|
||||
"source/detected" (named "source/detected" analysis ["detected"]
|
||||
"source/detected"
|
||||
(named "source/detected" analysis ["detected"]
|
||||
{:type "uint8" :frames frames :stride 1} mask)
|
||||
"source/crops" (named "source/crops" analysis ["rgba"]
|
||||
"source/crops"
|
||||
(named "source/crops" analysis ["rgba"]
|
||||
{:type "uint8" :frames frames :boxes boxes :offsets offsets}
|
||||
pixels)}
|
||||
interior (assoc "source/interior"
|
||||
(interior-block analysis params/defaults interior))))))
|
||||
(interior-block
|
||||
analysis
|
||||
(or interior-settings
|
||||
(throw (ex-info "an interior block needs the settings its measurements were taken at"
|
||||
{:frames (count interior)})))
|
||||
interior))))))
|
||||
|
||||
(defn wire-blocks [blocks]
|
||||
(into-array
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
[arthur.db :as db]
|
||||
[arthur.domain.feature :as feature]
|
||||
[arthur.domain.params :as params]
|
||||
[arthur.events.export :as export]
|
||||
[arthur.events.footage :as footage]
|
||||
[arthur.events.playback :as pb]
|
||||
[arthur.events.project :as project]
|
||||
|
|
@ -22,10 +23,23 @@
|
|||
(defonce ^:private selected-owner (r/atom nil))
|
||||
(defonce ^:private drafts (r/atom {}))
|
||||
|
||||
(defn- instance-label
|
||||
"What to call a symbol instance in the UI.
|
||||
|
||||
Its `:name`, because a placement's id is a UUID and a uuid is not something to
|
||||
show anyone or to sort by. The id is the fallback only so that a node with no
|
||||
name still has a row rather than a blank one."
|
||||
[clip id]
|
||||
(or (get-in clip [:timelines :main :nodes id :name]) (str id)))
|
||||
|
||||
(defn- setting-owners [clip]
|
||||
(let [instances (for [[id node] (get-in clip [:timelines :main :nodes])
|
||||
:when (= :symbol (:kind node))] id)
|
||||
instances (if (seq instances) (sort-by str instances) [nil])]
|
||||
(let [instances (->> (get-in clip [:timelines :main :nodes])
|
||||
(filter (comp #{:symbol} :kind val))
|
||||
;; By LABEL, not by id: ordering by a uuid is ordering at
|
||||
;; random, and this list is read top to bottom by a person.
|
||||
(sort-by (fn [[id _]] [(instance-label clip id) (str id)]))
|
||||
(mapv key))
|
||||
instances (if (seq instances) instances [nil])]
|
||||
(vec (for [instance instances
|
||||
[scope ids] [[:subject (:subjects clip)]
|
||||
[:feature (:features clip)]
|
||||
|
|
@ -70,7 +84,7 @@
|
|||
(doall (for [[i [kind object-id]] (map-indexed vector owners)]
|
||||
^{:key i} [:option {:value i}
|
||||
(str (when-let [instance (nth (nth owners i) 2)]
|
||||
(str (name instance) " / "))
|
||||
(str (instance-label clip instance) " / "))
|
||||
(name kind) " · " (name object-id))]))
|
||||
[:option {:value 0} "no tracked objects"])] ]
|
||||
(when instance
|
||||
|
|
@ -223,6 +237,61 @@
|
|||
(when project-seq (str " r" project-seq)) " · "))
|
||||
project-status])]))
|
||||
|
||||
(defn- exporter []
|
||||
(let [{:keys [timeline zoom isolate busy? done total status]} @(rf/subscribe [::export/state])
|
||||
targets @(rf/subscribe [::export/targets])
|
||||
{:keys [width height frames fps seconds poses]} @(rf/subscribe [::export/plan])]
|
||||
[:section.export
|
||||
[:div.row
|
||||
[:label "export "
|
||||
;; ONE SELECT over everything exportable: the clip, each symbol in its
|
||||
;; library, and each placement on the stage. They are one list because they
|
||||
;; are one kind of request — render this, alone — and a mode switch beside a
|
||||
;; picker would only make the same choice twice.
|
||||
;;
|
||||
;; The option VALUE is `events/export/target-value`, not `(name id)`: a
|
||||
;; symbol timeline is `:sym/face-8625` and a placement is a uuid, and
|
||||
;; writing either through `name` loses what identifies it. That is the bug
|
||||
;; where the id read back as `:face-8625`, matched no timeline, and the
|
||||
;; export died inside re-frame's `:do-fx` with the button stuck on
|
||||
;; "rendering…".
|
||||
[:select {:value (export/target-value {:timeline (or timeline :main)
|
||||
:isolate isolate})
|
||||
:disabled busy?
|
||||
:on-change #(rf/dispatch [::export/set-target
|
||||
(export/target-id (.. % -target -value))])}
|
||||
(doall
|
||||
(for [{:keys [label] :as target} targets]
|
||||
^{:key (export/target-value target)}
|
||||
[:option {:value (export/target-value target)}
|
||||
;; A placement is indented under the library above it, so that "the
|
||||
;; drawing" and "that one on the stage" read as different things.
|
||||
(str (when (:isolate target) "· ") label)]))]]
|
||||
[:span.gap]
|
||||
(doall
|
||||
(for [z export/zooms]
|
||||
^{:key z}
|
||||
[:button {:class (when (= z zoom) "on") :disabled busy?
|
||||
:on-click #(rf/dispatch [::export/set-zoom z])}
|
||||
(str z "x")]))
|
||||
[:button {:disabled busy? :on-click #(rf/dispatch [::export/start])}
|
||||
(if busy? "rendering…" "frames + wav")]]
|
||||
(when (and width frames)
|
||||
[:div.readout
|
||||
[:span (str width "x" height)]
|
||||
[:span (str frames " frames @ " fps)]
|
||||
[:span (str (.toFixed seconds 2) "s")]
|
||||
;; Poses and frames are different numbers at a lower picture rate, and
|
||||
;; showing both is how "exposure holds a drawing" stops being invisible:
|
||||
;; the file is never short, the drawing is just held.
|
||||
(when (not= poses frames) [:span (str poses " poses")])])
|
||||
(when busy?
|
||||
[:div.load-status (str "frame " done " / " total)])
|
||||
(when (and status (not busy?)) [:div.load-status status])
|
||||
[:p.note
|
||||
"A lossless PNG sequence at an integer zoom, with the mixed audio, in one "
|
||||
"zip. Import the sequence and the WAV as separate tracks."]]))
|
||||
|
||||
(defn- stage []
|
||||
;; The canvas is the STAGE's size, and the stage is the clip's — not a constant
|
||||
;; and not the footage's. Reactive, so selecting a clip of another size resizes
|
||||
|
|
@ -241,6 +310,7 @@
|
|||
[stage]
|
||||
[audio]
|
||||
[transport]
|
||||
[exporter]
|
||||
[controls]
|
||||
[:p.note
|
||||
"Upload a video, choose its footage, then load frames. Save the project to "
|
||||
|
|
|
|||
50
frontend/test/arthur/domain/crc32_test.cljs
Normal file
50
frontend/test/arthur/domain/crc32_test.cljs
Normal 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))))
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
227
frontend/test/arthur/domain/png_test.cljs
Normal file
227
frontend/test/arthur/domain/png_test.cljs
Normal 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)))))))
|
||||
|
|
@ -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))))))
|
||||
|
|
|
|||
164
frontend/test/arthur/domain/zip_test.cljs
Normal file
164
frontend/test/arthur/domain/zip_test.cljs
Normal 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)))))
|
||||
91
frontend/test/arthur/events/export_test.cljs
Normal file
91
frontend/test/arthur/events/export_test.cljs
Normal 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)))))
|
||||
214
frontend/test/arthur/export/frames_test.cljs
Normal file
214
frontend/test/arthur/export/frames_test.cljs
Normal 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))))))
|
||||
298
frontend/test/arthur/export_test.cljs
Normal file
298
frontend/test/arthur/export_test.cljs
Normal 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)))))))
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")))))
|
||||
|
|
|
|||
|
|
@ -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)))))))
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
})()`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue