Pre-alpha. Nothing here is owed a call shape it used to have.
Twelve convenience arities deleted, and the only reason to single any of
them out is that one of them was a live bug: `channel/value-at`'s `([ch f])`
filled in a nil tier-2 store, so a caller could omit it, read correctly for
every channel that happened not to be dense, and throw the first time one
was. That is the iris crash, and threading the store through `gesture/values`
last commit fixed the symptom while leaving the trapdoor open. Deleting the
arity found `node/toggle-key` standing on it too — the inspector's stopwatch
on a measured channel, the same throw, never reported.
Gone, and what the compiler then made explicit at each site:
channel/value-at, cursor, dense-at the store, and `nil` where a caller
genuinely has none and means it
channel/keyed `:hold`, which is a cut rather than a
tween and not a thing to leave implied
symbol/resolver (4), eval-frame (3) store, palette, pose-tracks, opts
clip/resolver opts
mix/buffer!, store/install! dead: no caller used the short form
`pick/local-bounds` goes the same way — it was `bounds-of` with the closure
thrown away, so callers build the closure and call it.
Every site was found by shadow-cljs `:fn-arity` rather than by grep, which is
the argument for the change: 90-odd call sites, and the compiler listed all of
them. BUILD BOTH TARGETS — the last three only appear in `:app`, since `:test`
compiles what the tests reach and the inspector, the pool drag and the vertex
overlay are not that.
Left alone, because an argument with a default is not the same thing as a
shim: genuine optionality like `fx/http`'s body, `geom`'s iteration count,
`zip`'s injected clock.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
399 lines
19 KiB
Clojure
399 lines
19 KiB
Clojure
(ns arthur.domain.clip
|
|
"A CLIP: the unit of work, and a library of symbols.
|
|
|
|
{:name \"take\"
|
|
:fps 30
|
|
:width 320 :height 200
|
|
:analysis {...}
|
|
:subjects {...} :features {...} :groups {...}
|
|
:symbols {:main {:id :main :frames 229 :nodes {...}}}}
|
|
|
|
Every field here is a fact about the clip and NOT about a bag of nodes, which is
|
|
the cut this namespace exists to make. Before it, one map carried both: `:fps`,
|
|
the stage dimensions, the analysis record and the tracking identities sat beside
|
|
`:nodes`. The cost of leaving them together was not untidiness. It was that a
|
|
SYMBOL had nowhere to live: a symbol is a bag of nodes with a frame space and
|
|
nothing else, so under the old shape it would have had to be a clip with seven
|
|
meaningless fields.
|
|
|
|
Now there is one node-holding type — `arthur.domain.symbol` — and a clip holds
|
|
a MAP of them. A `:kind :instance` node places one symbol inside another, and
|
|
the clip resolver gives each instance its own reading heads.
|
|
|
|
WHAT IS NOT HERE: how nested symbols' frames and coordinates relate, and
|
|
moving nodes between them, are `arthur.domain.nest`; bringing symbols in from
|
|
another clip is `arthur.domain.bring`. This namespace is the document and the
|
|
operations that only need the document.
|
|
|
|
NO SYMBOL IS SPECIAL. There is no reserved root and no pointer to one: which
|
|
symbol is on screen is the editor's state, not the document's, and every
|
|
function here that needs a symbol is told which. A new document has one symbol
|
|
called `:main` because it has to be called something, and that is all the name
|
|
means — it can be renamed, placed inside another symbol or deleted like any of
|
|
them. `unplaced` answers the question a reserved root used to: which symbols
|
|
nothing else places, and so which ones a person opening the document wants.
|
|
|
|
WHY :fps IS HERE AND :frames IS NOT. A rate is how fast the whole clip plays
|
|
against its audio, and a nested symbol cannot have one of its own — retiming an
|
|
instance is `:rate` on its `:time` map, which is a factor and not a rate. A
|
|
frame COUNT is a property of a frame space, so every symbol has its own."
|
|
(:refer-clojure :exclude [symbol])
|
|
(:require [arthur.domain.feature :as feature]
|
|
[arthur.domain.node :as node]
|
|
[arthur.domain.palette :as pal]
|
|
[arthur.domain.pose :as pose]
|
|
[arthur.domain.symbol :as symbol]))
|
|
|
|
(def clip-keys
|
|
"Every top-level field of a clip, and the reason `arthur.domain.leaf` refuses
|
|
one it does not know: a field added to the clip without a leaf to save it in is
|
|
a field that saves silently and comes back missing. The failure is a document
|
|
that loses something on every round trip, which is the one bug a persistence
|
|
layer must not be able to have. Add the field here and to `leaf/leaves` and
|
|
`leaf/clip` in the same commit."
|
|
#{:name :fps :analysis :subjects :features :groups :width :height :symbols})
|
|
|
|
(defn symbol
|
|
"One of the clip's symbols, by id."
|
|
[clip sid]
|
|
(get-in clip [:symbols sid]))
|
|
|
|
(defn symbol-name
|
|
"What to call a symbol: its `:name`, or its id when it has none."
|
|
[clip sid]
|
|
(or (:name (symbol clip sid)) (name sid)))
|
|
|
|
(defn frames
|
|
"A symbol's length. Read off the symbol, never copied beside it."
|
|
[clip sid]
|
|
(:frames (symbol clip sid)))
|
|
|
|
(defn stage
|
|
"A symbol's stage as `[width height]`: its own, or the clip's where it has none.
|
|
Absent rather than copied in at creation, so a symbol nobody has sized follows
|
|
the project's size when that changes."
|
|
[clip sid]
|
|
(let [sym (symbol clip sid)]
|
|
[(or (:width sym) (:width clip)) (or (:height sym) (:height clip))]))
|
|
|
|
(defn update-symbol
|
|
"Apply f to one symbol in place."
|
|
[clip sid f & args]
|
|
(apply update-in clip [:symbols sid] f args))
|
|
|
|
(defn places
|
|
"The ids of the symbols `sid` places, directly."
|
|
[clip sid]
|
|
(into #{} (keep (fn [n] (when (= :instance (:kind n)) (:of n))))
|
|
(vals (:nodes (symbol clip sid)))))
|
|
|
|
(defn contains-symbol?
|
|
"Whether `inner` is `outer` or is placed anywhere inside it. Placing `outer`
|
|
into `inner` when this is true is a cycle."
|
|
[clip outer inner]
|
|
(let [seen (volatile! #{})]
|
|
(letfn [(walk [sid]
|
|
(or (= sid inner)
|
|
(when-not (@seen sid)
|
|
(vswap! seen conj sid)
|
|
(some walk (places clip sid)))))]
|
|
(boolean (walk outer)))))
|
|
|
|
(defn unplaced
|
|
"The symbols no other symbol places, sorted by id. What to open when a
|
|
document is opened."
|
|
[clip]
|
|
(let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))]
|
|
(vec (sort-by str (remove placed (keys (:symbols clip)))))))
|
|
|
|
(defn opens-on
|
|
"The symbol a document opens on: the longest one nothing else places, ties
|
|
broken by id. The symbol that contains everything else is the longest of the
|
|
unplaced ones in every document made so far, and a reserved name is what this
|
|
replaces."
|
|
[clip]
|
|
(first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)])
|
|
(unplaced clip))))
|
|
|
|
(def ^:const blank-frames
|
|
"How long a new document is before anything says otherwise. Four seconds at 30,
|
|
which is long enough to key something into and short enough to scrub by hand."
|
|
120)
|
|
|
|
(defn blank
|
|
"A new, empty document: one empty symbol.
|
|
|
|
`:nodes` is empty rather than seeded with a layer, because an empty symbol is
|
|
a true statement and a layer nobody asked for is one more thing to delete. The
|
|
tracking maps are present and empty for the same reason `clip-keys` exists: a
|
|
field that is sometimes absent is a field every reader needs a fallback for."
|
|
[]
|
|
{:name "untitled"
|
|
:fps 30
|
|
:width 320 :height 200
|
|
:subjects {} :features {} :groups {}
|
|
:symbols {:main {:id :main :frames blank-frames :nodes {}}}})
|
|
|
|
(defn- transform-op
|
|
"Put a symbol's already resolved mark into its instance's parent space. Its
|
|
name becomes its path of instances down to it, the path its timeline row has."
|
|
[op m path]
|
|
(let [at (fn [x y] [(+ (* (aget m 0) x) (* (aget m 2) y) (aget m 4))
|
|
(+ (* (aget m 1) x) (* (aget m 3) y) (aget m 5))])
|
|
scale (node/mean-scale m)
|
|
n (:node op)
|
|
op (assoc op :node (if (vector? n) (into path n) (conj path n)))]
|
|
(case (:kind op)
|
|
:poly (let [out (js/Float64Array. (.-length (:pts op)))]
|
|
(dotimes [i (:n op)]
|
|
(let [[x y] (at (aget (:pts op) (* 2 i))
|
|
(aget (:pts op) (inc (* 2 i))))]
|
|
(aset out (* 2 i) x)
|
|
(aset out (inc (* 2 i)) y)))
|
|
(assoc op :pts out))
|
|
:disc (let [[x y] (at (:cx op) (:cy op))]
|
|
(assoc op :cx x :cy y :r (* scale (:r op))))
|
|
:rect (let [[x y] (at (:cx op) (:cy op))]
|
|
(assoc op :cx x :cy y :size (* scale (:size op))))
|
|
op)))
|
|
|
|
(defn resolver
|
|
"Resolve symbol `sid` of a clip, including every symbol its instances place.
|
|
|
|
Each instance owns its own symbol resolver, so two offsets never share a
|
|
channel cursor or point buffer. The returned ops must be drawn before the next
|
|
frame, as with symbol/resolver.
|
|
|
|
Any symbol can be resolved and none is the default: the frame space is the
|
|
resolved symbol's own `:frames`, and nested instances inside it still resolve,
|
|
because this is the function that knows how to do that.
|
|
|
|
It answers `symbol/world-of` and `symbol/frame-of` for a ROW PATH — the ids
|
|
from `sid` down through instances, as a timeline row names a node — as of the
|
|
frame it last resolved: the matrix into `sid`'s coordinates, and the node's
|
|
own frame. Nil for a node that was not on that frame. It is how something
|
|
drawn beside the picture, like a tracing photo, rides a node inside it without
|
|
resolving anything a second time."
|
|
[clip store palette sid {:keys [picture-fps] :as opts}]
|
|
(letfn [(build [sid chain pose-tracks]
|
|
(when (some #{sid} chain)
|
|
(throw (ex-info "symbol cycle" {:chain (conj chain sid)})))
|
|
(let [sym (or (symbol clip sid)
|
|
(throw (ex-info "an instance names a missing symbol" {:symbol sid})))
|
|
nodes (:nodes sym)
|
|
rank (symbol/draw-rank nodes (symbol/order nodes))
|
|
ids (sort-by rank (keys nodes))
|
|
own (symbol/resolver sym store palette pose-tracks
|
|
(assoc opts :source-fps (:fps clip)))
|
|
children (into {}
|
|
(for [[id n] nodes :when (= :instance (:kind n))]
|
|
[id (build (:of n) (conj chain sid)
|
|
(get-in n [:playback :tracks]))]))
|
|
;; The instances that were on the last frame. Their resolvers
|
|
;; still hold the frame before whenever they were not.
|
|
entered (volatile! #{})
|
|
step (fn [f]
|
|
(vreset! entered #{})
|
|
(let [by-id (into {} (map (juxt :node identity)) (own f))]
|
|
(into []
|
|
(mapcat
|
|
(fn [id]
|
|
(let [n (get nodes id)]
|
|
(if (= :instance (:kind n))
|
|
(let [m (symbol/world-of own id)
|
|
local (symbol/frame-of own id)
|
|
target (symbol clip (:of n))
|
|
length (:frames target)
|
|
frame (when (and m (number? local))
|
|
(if (get-in n [:time :loop?])
|
|
(mod local length)
|
|
local))]
|
|
(if (and frame (<= 0 frame) (< frame length))
|
|
(do (vswap! entered conj id)
|
|
(map #(transform-op % m [id]) ((get children id) frame)))
|
|
[]))
|
|
(when-let [op (get by-id id)] [op]))))
|
|
ids))))]
|
|
(reify
|
|
IFn
|
|
(-invoke [_ f] (step f))
|
|
symbol/IResolver
|
|
(world-of [_ [id & more]]
|
|
(if more
|
|
(when-let [w (and (contains? @entered id)
|
|
(symbol/world-of (get children id) (vec more)))]
|
|
(node/mul! (node/mat) (symbol/world-of own id) w))
|
|
(symbol/world-of own id)))
|
|
(frame-of [_ [id & more]]
|
|
(if more
|
|
(when (contains? @entered id)
|
|
(symbol/frame-of (get children id) (vec more)))
|
|
(symbol/frame-of own id))))))]
|
|
(build sid [] nil)))
|
|
|
|
(defn center
|
|
"The middle of everything symbol `sid` draws, over all its frames, in its own
|
|
coordinates. ALL frames rather than the first, so a symbol whose drawing
|
|
enters late, or travels, still has its middle where the drawing is. A symbol
|
|
that draws nothing gets the STAGE's middle, which is where a drawing made into
|
|
it will be, because drawings are made on the stage.
|
|
|
|
What this feeds is a DEFAULT: `place-symbol` copies it into a new instance's
|
|
anchor and nothing ever updates it, as Flash's transformation point and After
|
|
Effects' anchor point are set once and left. A symbol that grows later keeps
|
|
its instances' pivots where they were, so nothing on screen moves."
|
|
[clip store sid]
|
|
(let [resolve (resolver clip store pal/index-of sid nil)
|
|
bounds (fn [[x0 y0 x1 y1 :as b] x y]
|
|
(if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y]))
|
|
[x0 y0 x1 y1]
|
|
(reduce
|
|
(fn [b {:keys [kind pts n cx cy r size]}]
|
|
(case kind
|
|
:poly (reduce (fn [b i] (bounds b (aget pts (* 2 i)) (aget pts (inc (* 2 i)))))
|
|
b (range n))
|
|
:disc (-> b (bounds (- cx r) (- cy r)) (bounds (+ cx r) (+ cy r)))
|
|
:rect (let [h (/ size 2)] (-> b (bounds (- cx h) (- cy h)) (bounds (+ cx h) (+ cy h))))
|
|
b))
|
|
nil
|
|
(mapcat resolve (range (frames clip sid))))]
|
|
(if x0
|
|
[(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)]
|
|
(mapv #(/ % 2) (stage clip sid)))))
|
|
|
|
(defn place-symbol
|
|
"An instance of symbol `sid`, inside symbol `host`, at `frame` of `host`.
|
|
|
|
THE ANCHOR IS THE MIDDLE. Every instance pivots about the centre of what it
|
|
draws — see `center` — so rotating or scaling one turns it in place rather than
|
|
swinging it about a corner. At the identity transform the anchor moves nothing,
|
|
so where the drawing lands is `pos` alone: with `point`, a stage pixel, the
|
|
middle goes there; without one — a drop on the timeline — the drawing stays
|
|
where it was drawn.
|
|
|
|
THE UUID IS AN ARGUMENT. A placement's identity is the key it has in the node
|
|
map — it is what `:linked-to`, an export target and a saved leaf all name — so
|
|
generating one in here would make this function's result depend on when it was
|
|
called, and this namespace is the pure one.
|
|
|
|
The instance's own time starts where it was dropped: `:at frame` means frame 0
|
|
of the symbol plays on `frame` of `host`, which is what dragging something onto
|
|
a playhead is asking for. Its `:span` is in its OWN frames — the whole symbol,
|
|
0 to its length — wherever it was dropped; see `node/placed-span`.
|
|
|
|
Refused, returning the clip unchanged, when it would make a cycle: a symbol
|
|
cannot be placed inside itself or inside anything it places."
|
|
[clip store host sid frame uuid point]
|
|
(let [target (symbol clip sid)
|
|
end (frames clip host)]
|
|
(if (or (nil? target) (nil? end) (nil? frame) (neg? frame) (>= frame end)
|
|
(contains-symbol? clip sid host))
|
|
clip
|
|
(let [middle (center clip store sid)]
|
|
(update-symbol
|
|
clip host assoc-in [:nodes uuid]
|
|
{:id uuid
|
|
:name (symbol-name clip sid)
|
|
:kind :instance
|
|
:of sid
|
|
:parent nil
|
|
;; Lexicographic draw order, as `domain/paint` does it: a placement made
|
|
;; later sits above one made earlier, and neither has to renumber.
|
|
:z (str "z" (js/Date.now) "-" (name sid))
|
|
:span [0 (:frames target)]
|
|
:time {:mode :map :at frame :rate 1}
|
|
:channels {[:xform :pos] {:animated? false
|
|
:value (if point (mapv - point middle) [0 0])}
|
|
[:xform :anchor] {:animated? false :value middle}}})))))
|
|
|
|
(defn place-sound
|
|
"An audio node playing `source` — `{:sound id}`, an uploaded file, or
|
|
`{:footage id}`, a video's own sound — inside symbol `host` from `frame` of it.
|
|
Timed as an instance is: `:at frame`, its span `length` of its OWN frames, and
|
|
`rate` of those to one of `host`'s, which is what keeps a 30fps video's sound
|
|
its own length in a 12fps project. Nothing else: a sound is not in the picture."
|
|
[clip host source label length rate frame uuid]
|
|
(let [end (frames clip host)]
|
|
(if (or (nil? end) (nil? frame) (neg? frame) (>= frame end))
|
|
clip
|
|
(update-symbol
|
|
clip host assoc-in [:nodes uuid]
|
|
{:id uuid
|
|
:name label
|
|
:kind :audio
|
|
:parent nil
|
|
:z (str "z" (js/Date.now) "-sound")
|
|
:source source
|
|
:span [0 (max 1 length)]
|
|
:time {:mode :map :at frame :rate rate}}))))
|
|
|
|
(defn fresh-id
|
|
"The first `:symbol-N` the clip does not already hold. Readable because an id
|
|
shows up in saved leaf paths, and deterministic because this namespace is pure."
|
|
[clip]
|
|
(first (remove (:symbols clip) (map #(keyword (str "symbol-" %)) (iterate inc 1)))))
|
|
|
|
(defn new-symbol
|
|
"A new, empty symbol `sid`, placed inside `host` at `frame` and running to the
|
|
end of it. Placed at the origin, so whatever is drawn into it lands where it was
|
|
drawn until the instance is moved."
|
|
[clip host sid frame uuid]
|
|
(let [end (frames clip host)]
|
|
(if (or (nil? end) (symbol clip sid) (nil? frame) (neg? frame) (>= frame end))
|
|
clip
|
|
(-> clip
|
|
(assoc-in [:symbols sid] {:id sid :name (name sid) :frames (- end frame) :nodes {}})
|
|
(place-symbol nil host sid frame uuid nil)))))
|
|
|
|
(defn free-id
|
|
"`wanted`, or the first `wanted-2`, `wanted-3`… `taken?` does not claim.
|
|
Keeps the namespace, so `:sym/face` becomes `:sym/face-2`."
|
|
[taken? wanted]
|
|
(first (remove taken?
|
|
(cons wanted
|
|
(map #(keyword (namespace wanted) (str (name wanted) "-" %))
|
|
(iterate inc 2))))))
|
|
|
|
(defn problems
|
|
"Human-readable reasons this clip will not evaluate or save."
|
|
[clip]
|
|
(vec
|
|
(concat
|
|
(for [k (remove clip-keys (keys clip))]
|
|
(str "clip has a field with no leaf to save it in: " (pr-str k)))
|
|
(when-not (map? (:symbols clip))
|
|
[":symbols must be a map of id -> symbol"])
|
|
(when-not (or (nil? (:fps clip)) (and (number? (:fps clip)) (pos? (:fps clip))))
|
|
[(str ":fps is " (pr-str (:fps clip)) " — a rate is a positive number")])
|
|
(for [[id sym] (:symbols clip)
|
|
:when (not= id (:id sym))]
|
|
(str "symbol under key " (pr-str id) " has :id " (pr-str (:id sym))))
|
|
(for [[id sym] (:symbols clip)
|
|
p (symbol/problems sym)]
|
|
(str "symbol " (pr-str id) ": " p))
|
|
(for [[sid sym] (:symbols clip)
|
|
[id n] (:nodes sym)
|
|
:when (and (= :instance (:kind n))
|
|
(not (contains? (:symbols clip) (:of n))))]
|
|
(str "symbol " (pr-str sid) " instance " (pr-str id)
|
|
" names missing symbol " (pr-str (:of n))))
|
|
(for [[sid sym] (:symbols clip)
|
|
[id n] (:nodes sym)
|
|
:when (= :instance (:kind n))
|
|
:let [target (get-in clip [:symbols (:of n)])
|
|
active (filter (fn [node]
|
|
(some :pose-sampled? (vals (:channels node))))
|
|
(vals (:nodes target)))
|
|
groups (set (concat
|
|
(map #(or (:pose-group %) (:id %)) active)
|
|
(map #(vector :node (:id %)) active)))]
|
|
p (pose/problems (get-in n [:playback :tracks])
|
|
(:frames target) groups)]
|
|
(str "symbol " (pr-str sid) " instance " (pr-str id) ": " p))
|
|
(for [[sid sym] (:symbols clip)
|
|
[id n] (:nodes sym)
|
|
:when (and (= :audio (:kind n)) (:linked-to n)
|
|
(not (contains? (:nodes sym) (:linked-to n))))]
|
|
(str "symbol " (pr-str sid) " audio " (pr-str id)
|
|
" links to missing node " (pr-str (:linked-to n))))
|
|
(feature/problems clip))))
|