Symbols, not timelines; no symbol is special

Everything that holds nodes is a symbol (domain/timeline -> domain/symbol,
:timelines -> :symbols) and a node that places one is :kind :instance. The
reserved :main root is gone: which symbol is on screen is editor state
([:ui :open]), every domain function that needs a symbol is told which, and
a document opens on the longest symbol nothing else places.

Saved projects move to schema 2 through migration 0007, which rewrites leaf
paths, instance kinds and the feature :symbol key; the client refuses a
schema it does not read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-29 12:46:42 -04:00
parent 179770d7d4
commit 5dff490162
61 changed files with 1587 additions and 1431 deletions

View file

@ -91,18 +91,15 @@
(.setValueAtTime param (* factor v) (/ f fps)))))))
(defn tracks-of
"The audio nodes of one of the clip's timelines.
"The audio nodes of one of the clip's symbols. Any symbol may carry its own
sound, and playback mixes the open one's."
[document sid]
(filter #(= :audio (:kind %)) (vals (:nodes (clip/symbol document sid)))))
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]
(defn- render! [document sid sources store]
(let [fps (:fps document)
frames (:frames (clip/timeline document tid))
tracks (tracks-of document tid)
frames (:frames (clip/symbol document sid))
tracks (tracks-of document sid)
output (js/OfflineAudioContext.
2 (js/Math.ceil (* (/ frames fps) 44100)) 44100)]
(doseq [track tracks]
@ -133,20 +130,20 @@
(.startRendering output)))
(defn buffer!
"Promise of the `AudioBuffer` one timeline's audio tracks mix down to, or nil
"Promise of the `AudioBuffer` one symbol'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)]
([document sid] (buffer! document sid nil))
([document sid store]
(let [tracks (tracks-of document sid)]
(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))))))))
(.then (fn [pairs] (render! document sid (into {} (array-seq pairs)) store))))))))
(defn decode!
"Promise of the `AudioBuffer` behind a URL. What a clip whose audio is a plain
@ -162,9 +159,9 @@
(.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]
(-> (buffer! document clip/root-id store)
(.then (fn [buffer] (if buffer (wav-url buffer) fallback-url))))))
"Promise of a mixed WAV URL for symbol `sid`, or the original URL when it has
no audio tracks. Each track can be trimmed and faded independently of its
linked picture."
[document sid fallback-url store]
(-> (buffer! document sid store)
(.then (fn [buffer] (if buffer (wav-url buffer) fallback-url)))))

View file

@ -20,10 +20,8 @@
Read OFF the clip rather than written again beside it: copying a number by hand
into this table is how it comes to disagree with the document it describes.
`:frames` comes from the ROOT TIMELINE and `:fps` from the clip, which is the
split `arthur.domain.clip` exists to make — a timeline is a frame space, a clip
is a rate — and an earlier version of this docstring noted that they sat on one
map \"only because there is one clip per scene today\". They do not any more."
There is no `:frames` here, because a length belongs to a symbol and which
symbol is open is the editor's state — see `events/playback/frames`."
[label-key label clip store]
(merge {:label label :clip clip :store store
;; A static asset since step 9, and not the repo root's `audio.wav`.
@ -32,8 +30,7 @@
;; that the clock has something to run against with no footage ingested.
:audio "/static/arthur/audio.wav"
:cid (name label-key)
:display-fps (:fps clip)
:frames (domain-clip/frames clip)}
:display-fps (:fps clip)}
(select-keys clip [:fps :width :height])))
(def clips
@ -72,7 +69,7 @@
;; transform on a node, so nothing downstream of the freeze knows the frame
;; size — and it is why ui/player no longer hardcodes 320x200.
:clip (let [c (domain-clip/blank)]
{:fps (:fps c) :frames (domain-clip/frames c)
{:fps (:fps c)
:width (:width c) :height (:height c)
:audio nil :display-fps (:fps c)})
@ -106,11 +103,12 @@
;; machinery that would share it.
;; --- export ---
;;
;; The REQUEST and its progress, never the frames. Which timeline to write and
;; The REQUEST and its progress, never the frames. Which symbol 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
;; `:isolate` is the placement to render alone, or nil for the whole symbol;
;; `:symbol` nil means whichever symbol is open.
:export {:symbol nil :isolate nil :zoom 4 :busy? false :done 0 :total 0
:status nil}
:playback {:frame 0
@ -134,20 +132,28 @@
;; names, so a pane dispatches on it rather than on which of several
;; "selected-x" keys happens to be non-nil:
;;
;; [:node <timeline> <node>] a shape or a placement
;; [:timeline <id>] a timeline, root or library
;; [:node <symbol> <node>] a shape or an instance
;; [:symbol <id>] a symbol
;; [:subject <id>] [:feature <id>] [:group <id>] a tracked object
;;
;; `:draft` is the polygon being clicked out, flat [x y x y …] as geometry is
;; stored everywhere. `:expanded` holds timeline row PATHS — a path and not a
;; node id, because one symbol placed twice is two rows that open separately.
;;
;; `:open` is the symbol on screen — the one the stage draws, the timeline
;; lists, the transport plays and a new shape goes into — and `:tabs` the
;; symbols open beside it. Editor state and not the document's, because no
;; symbol is special to the document: which one you are looking at is a fact
;; about you.
;;
;; `:knobs` holds a generated setting's value WHILE THE REGENERATION IS IN
;; FLIGHT, keyed by [scope id knob]. Moving a slider dispatches a preview that
;; re-freezes blocks asynchronously, so until it lands the clip still reports
;; the old value — and a slider reading from the clip would spring back under
;; the user's finger on every frame of the drag.
:ui {:selection nil
:ui {:open nil
:tabs []
:selection nil
:tone :skin-base
:tool nil
:draft []

View file

@ -6,7 +6,7 @@
validates would not be the one that renders, and the model would be validated
against a scene nobody ever looked at."
(:require [arthur.domain.clip :as domain-clip]
[arthur.domain.timeline :as timeline]
[arthur.domain.symbol :as symbol]
[cljs.reader :as reader]
[shadow.resource :as rc]))
@ -14,15 +14,15 @@
(def clip (reader/read-string source))
(def timeline
"The clip's root timeline: what an evaluator takes. `clip` is the document."
(domain-clip/root clip))
(def main
"The scene's one symbol: what an evaluator takes. `clip` is the document."
(domain-clip/symbol clip :main))
(def fps (:fps clip))
(def frames (domain-clip/frames clip))
(def frames (domain-clip/frames clip :main))
(defn ops-at
"Draw ops for one frame, via the specification path. The page uses
`timeline/resolver` instead; this is here for the REPL."
`symbol/resolver` instead; this is here for the REPL."
[f]
(timeline/eval-frame timeline f))
(symbol/eval-frame main f))

View file

@ -32,7 +32,7 @@
:width 320
:height 200
:timelines
:symbols
{:main
{:id :main
:frames 229

View file

@ -35,7 +35,7 @@
(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])
original (get-in source [:symbols :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.
@ -49,7 +49,7 @@
{:root {:id :root :name "stage" :kind :group :z "a1"}}
(map (fn [{:keys [uuid name z span at in center anchor drift phase]}]
(let [anchor (or anchor default-anchor)]
[uuid {:id uuid :name name :kind :symbol :of symbol
[uuid {:id uuid :name name :kind :instance :of symbol
:parent :root :z z :span span
:time {:mode :map :at at :in in :rate 1}
:channels {[:xform :pos] (if drift
@ -68,6 +68,6 @@
pan (assoc [:audio :pan] pan))}])
audio))]
(assoc source :name name :width width :height height
:timelines (assoc (:timelines source)
:symbols (assoc (:symbols source)
:main {:id :main :frames frames :nodes nodes}
symbol (assoc original :id symbol)))))

View file

@ -153,7 +153,7 @@
:fps fps
:width 320
:height 200
:timelines
:symbols
{:main
{:id :main
:frames frames

View file

@ -12,7 +12,7 @@
│
FREEZE ──▶ channels on nodes
│
timeline/resolver ──▶ raster
symbol/resolver ──▶ raster
— and the order of that diagram is the whole argument for the stage split. The
anchor fit is knob-free. Conditioning smooths its four parameters. The rings are

View file

@ -1,47 +1,43 @@
(ns arthur.domain.clip
"A CLIP: the unit of work, and a library of timelines.
"A CLIP: the unit of work, and a library of symbols.
{:name \"take\"
:fps 30
:width 320 :height 200
:analysis {...}
:subjects {...} :features {...} :groups {...}
:timelines {:main {:id :main :frames 229 :nodes {...}}}}
: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`, and `arthur.db` said of it — correctly — that they \"sit on the scene
map only because there is one clip per scene today\". The cost of leaving them
together was not untidiness. It was that a SYMBOL had nowhere to live: a library
timeline 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, or a second
structure with the same `:nodes` key that every walk had to be taught about.
`: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.timeline` — and a clip holds
a MAP of them. A `:kind :symbol` instance names a timeline in `:timelines`,
and the clip resolver gives each placement its own reading heads.
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.
THE ROOT TIMELINE HAS A RESERVED ID, `:main`, rather than the clip carrying a
pointer to it. A pointer is a field that can be wrong — it can name a timeline
that is not there, and then every reader needs a fallback — where a reserved name
can only be absent, which `problems` reports once. Flash reserves `_root` the
same way and for the same reason. Nothing else about `:main` is special: it is an
ordinary entry in the map, and a symbol is another one.
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 timeline cannot have one of its own — retiming an
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 timeline has its own."
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.timeline :as timeline]))
(def ^:const root-id
"The reserved id of the timeline a clip plays. See the namespace docstring."
:main)
[arthur.domain.symbol :as symbol]))
(def clip-keys
"Every top-level field of a clip, and the reason `arthur.domain.leaf` refuses
@ -50,38 +46,56 @@
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 :timelines})
#{:name :fps :analysis :subjects :features :groups :width :height :symbols})
(defn timeline
"One of the clip's timelines, by id."
[clip id]
(get-in clip [:timelines id]))
(defn root
"The timeline the clip plays."
[clip]
(timeline clip root-id))
(defn symbol
"One of the clip's symbols, by id."
[clip sid]
(get-in clip [:symbols sid]))
(defn frames
"The clip's length, which is its root timeline's frame space and is not written
down twice. Reading it off the root is what stops the two from disagreeing."
"A symbol's length. Read off the symbol, never copied beside it."
[clip sid]
(:frames (symbol clip sid)))
(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]
(:frames (root clip)))
(let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))]
(vec (sort-by str (remove placed (keys (:symbols clip)))))))
(defn update-timeline
"Apply f to one timeline in place."
[clip id f & args]
(apply update-in clip [:timelines id] f args))
(defn update-root [clip f & args]
(apply update-timeline clip root-id f args))
(defn nodes
"The root timeline's nodes. A convenience for the many callers that mean the
root and would otherwise spell it out; anything that could mean a symbol says
which timeline instead."
(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]
(:nodes (root 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,
@ -89,9 +103,9 @@
120)
(defn blank
"A new, empty document.
"A new, empty document: one empty symbol.
`:nodes` is empty rather than seeded with a layer, because an empty timeline is
`: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."
@ -100,39 +114,39 @@
:fps 30
:width 320 :height 200
:subjects {} :features {} :groups {}
:timelines {root-id {:id root-id :frames blank-frames :nodes {}}}})
:symbols {:main {:id :main :frames blank-frames :nodes {}}}})
(defn place-symbol
"An instance of library timeline `tid`, on the root timeline, at `frame`.
"An instance of symbol `sid`, inside symbol `into`, at `frame` of `into`.
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. `demo/stage_8625.edn` authors its
placements' uuids by hand for the same reason, in more words.
called, and this namespace is the pure one.
The instance's own time starts where it was dropped: `:at frame` with `:in 0`
means local frame 0 of the symbol plays on `frame` of the stage, which is what
dragging something onto a playhead is asking for. `:span` runs to the end of
the root's frame space rather than to the symbol's length, because a symbol
shorter than the space it is placed in should hold its last frame rather than
disappear."
[clip tid frame uuid [x y]]
(let [target (timeline clip tid)
end (frames clip)]
(if (or (nil? target) (= root-id tid) (nil? frame) (neg? frame) (>= frame end))
means local frame 0 of the symbol plays on `frame` of `into`, which is what
dragging something onto a playhead is asking for.
Refused, returning the clip unchanged, when it would make a cycle: a symbol
cannot be placed inside itself or inside anything it places."
[clip into sid frame uuid [x y]]
(let [target (symbol clip sid)
end (frames clip into)]
(if (or (nil? target) (nil? end) (nil? frame) (neg? frame) (>= frame end)
(contains-symbol? clip sid into))
clip
(update-root
clip assoc-in [:nodes uuid]
(update-symbol
clip into assoc-in [:nodes uuid]
{:id uuid
:name (name tid)
:kind :symbol
:of tid
:name (name 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 tid))
:span [frame end]
:z (str "z" (js/Date.now) "-" (name sid))
:span [frame (min end (+ frame (:frames target)))]
:time {:mode :map :at frame :in 0 :rate 1}
:channels {[:xform :pos] {:animated? false :value [x y]}}}))))
@ -158,35 +172,30 @@
op)))
(defn resolver
"Resolve a clip, including each library timeline placed by a symbol instance.
"Resolve symbol `sid` of a clip, including every symbol its instances place.
Each instance owns its own timeline resolver, so two offsets never share a
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 timeline/resolver.
frame, as with symbol/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] (resolver clip store palette root nil))
([clip store palette root {:keys [picture-fps] :as opts}]
(letfn [(build [tid chain pose-tracks]
(when (some #{tid} chain)
(throw (ex-info "symbol timeline cycle" {:chain (conj chain tid)})))
(let [tl (or (timeline clip tid)
(throw (ex-info "symbol names a missing timeline" {:timeline tid})))
nodes (:nodes tl)
rank (timeline/draw-rank nodes (timeline/order nodes))
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."
([clip store palette sid] (resolver clip store palette sid nil))
([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 (timeline/resolver tl store palette pose-tracks
own (symbol/resolver sym store palette pose-tracks
(assoc opts :source-fps (:fps clip)))
children (into {}
(for [[id n] nodes :when (= :symbol (:kind n))]
[id (build (:of n) (conj chain tid)
(for [[id n] nodes :when (= :instance (:kind n))]
[id (build (:of n) (conj chain sid)
(get-in n [:playback :tracks]))]))]
(fn [f]
(let [by-id (into {} (map (juxt :node identity)) (own f))]
@ -194,10 +203,10 @@
(mapcat
(fn [id]
(let [n (get nodes id)]
(if (= :symbol (:kind n))
(let [m (timeline/world-of own id)
local (timeline/frame-of own id)
target (timeline clip (:of n))
(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?])
@ -208,7 +217,7 @@
[]))
(when-let [op (get by-id id)] [op]))))
ids))))))]
(build root [] nil))))
(build sid [] nil))))
(defn problems
"Human-readable reasons this clip will not evaluate or save."
@ -217,28 +226,26 @@
(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? (:timelines clip))
[":timelines must be a map of id -> timeline"])
(when (and (map? (:timelines clip)) (nil? (root clip)))
[(str "no " (pr-str root-id) " timeline — a clip plays the one with the reserved id")])
(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 tl] (:timelines clip)
:when (not= id (:id tl))]
(str "timeline under key " (pr-str id) " has :id " (pr-str (:id tl))))
(for [[id tl] (:timelines clip)
p (timeline/problems tl)]
(str "timeline " (pr-str id) ": " p))
(for [[tid tl] (:timelines clip)
[id n] (:nodes tl)
:when (and (= :symbol (:kind n))
(not (contains? (:timelines clip) (:of n))))]
(str "timeline " (pr-str tid) " symbol " (pr-str id)
" names missing timeline " (pr-str (:of n))))
(for [[tid tl] (:timelines clip)
[id n] (:nodes tl)
:when (= :symbol (:kind n))
:let [target (get-in clip [:timelines (:of n)])
(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)))
@ -247,11 +254,11 @@
(map #(vector :node (:id %)) active)))]
p (pose/problems (get-in n [:playback :tracks])
(:frames target) groups)]
(str "timeline " (pr-str tid) " symbol " (pr-str id) ": " p))
(for [[tid tl] (:timelines clip)
[id n] (:nodes tl)
(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 tl) (:linked-to n))))]
(str "timeline " (pr-str tid) " audio " (pr-str id)
(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))))

View file

@ -1,6 +1,6 @@
(ns arthur.domain.feature
"Tracked subjects, feature ownership, and eye-pair settings.
Features name their timeline explicitly; node ids are local to that timeline."
Features name their symbol explicitly; node ids are local to that symbol."
(:require [arthur.domain.params :as params]))
(defn owned
@ -44,14 +44,14 @@
clip))
(defn problems
"Check tracked identities and timeline-local node ownership."
"Check tracked identities and symbol-local node ownership."
[clip]
(let [subjects (:subjects clip)
features (:features clip)
groups (:groups clip)
memberships (mapcat (comp :members val) groups)
node-owners (for [[_ f] features n (:nodes f)]
[(:timeline f) n])]
[(:symbol f) n])]
(vec
(concat
(for [[id s] subjects :when (not= id (:id s))]
@ -60,8 +60,8 @@
:when (not (params/valid-settings? :subject (or (:params s) {})))]
(str "subject " (pr-str id) " has invalid settings"))
(for [[id _] subjects
:when (not (seq (get-in clip [:timelines id :nodes :head :measured])))]
(str "subject " (pr-str id) " has no measured head in its timeline"))
:when (not (seq (get-in clip [:symbols id :nodes :head :measured])))]
(str "subject " (pr-str id) " has no measured head in its symbol"))
(for [[id f] features :when (not= id (:id f))]
(str "feature " (pr-str id) " has a different :id"))
(for [[id f] features :when (not (contains? subjects (:subject f)))]
@ -72,10 +72,10 @@
:when (not (params/valid-settings? (:area f) (or (:params f) {})))]
(str "feature " (pr-str id) " has invalid settings for " (pr-str (:area f))))
(for [[id f] features
:when (not (contains? (:timelines clip) (:timeline f)))]
(str "feature " (pr-str id) " names a missing timeline"))
:when (not (contains? (:symbols clip) (:symbol f)))]
(str "feature " (pr-str id) " names a missing symbol"))
(for [[id f] features node-id (:nodes f)
:let [owned-nodes (get-in clip [:timelines (:timeline f) :nodes])]
:let [owned-nodes (get-in clip [:symbols (:symbol f) :nodes])]
:when (not (contains? owned-nodes node-id))]
(str "feature " (pr-str id) " refers to missing node " (pr-str node-id)))
(for [[id n] (frequencies node-owners) :when (> n 1)]

View file

@ -11,21 +11,21 @@
clip/<cid>/timing fps
clip/<cid>/stage width, height
clip/<cid>/source the analysis record this came out of
clip/<cid>/subject/<sid> a tracked subject and its params
clip/<cid>/subject/<subj> a tracked subject and its params
clip/<cid>/feature/<fid> one feature: area, nodes, params
clip/<cid>/group/<gid> an eye pair and its shared params
clip/<cid>/timeline/<tid> frames, and a palette one day
clip/<cid>/timeline/<tid>/node/<nid> kind, parent, stencil, z, time
clip/<cid>/timeline/<tid>/channel/<nid>/<prop>
clip/<cid>/timeline/<tid>/measured/<nid> the channels a re-freeze owns
clip/<cid>/symbol/<sid> frames, and a palette one day
clip/<cid>/symbol/<sid>/node/<nid> kind, parent, stencil, z, time
clip/<cid>/symbol/<sid>/channel/<nid>/<prop>
clip/<cid>/symbol/<sid>/measured/<nid> the channels a re-freeze owns
WHY NODES SIT UNDER A TIMELINE. A clip holds a library of timelines. Its root
and each symbol have their own nodes, so the timeline id is a path segment.
The root is `main`, and a symbol's nodes use the same path shape.
WHY NODES SIT UNDER A SYMBOL. A clip holds a library of symbols and each has
its own nodes, so the symbol id is a path segment. No symbol has a reserved
segment: `main` in a path is an id like any other.
`:frames` MOVED OFF `timing` onto the timeline. A timeline is a frame space and a
`:frames` MOVED OFF `timing` onto the symbol. A symbol is a frame space and a
clip is a rate, so `timing` holds `:fps` alone. Both used to be in one leaf, which
is how a nested timeline's length would have had nowhere to go.
is how a nested symbol's length would have had nowhere to go.
WHY THESE BOUNDARIES. Last-writer-wins only clobbers when its unit is too big,
so the cut is chosen so that the things people do simultaneously land on
@ -56,7 +56,7 @@
it is one character rather than a scheme."
(:require [arthur.domain.clip :as clip]
[arthur.domain.sha256 :as sha]
[arthur.domain.timeline :as timeline]
[arthur.domain.symbol :as symbol]
[clojure.string :as str]))
;; ---------------------------------------------------------------------------
@ -124,11 +124,11 @@
(when (seq unknown)
(throw (ex-info "the clip has a field with no leaf to save it in; see arthur.domain.clip/clip-keys"
{:unknown (vec (sort-by str unknown))}))))
(doseq [[id tl] (:timelines clip)]
(let [unknown (remove timeline/timeline-keys (keys tl))]
(doseq [[id sym] (:symbols clip)]
(let [unknown (remove symbol/symbol-keys (keys sym))]
(when (seq unknown)
(throw (ex-info "a timeline has a field with no leaf to save it in; see arthur.domain.timeline/timeline-keys"
{:timeline id :unknown (vec (sort-by str unknown))})))))
(throw (ex-info "a symbol has a field with no leaf to save it in; see arthur.domain.symbol/symbol-keys"
{:symbol id :unknown (vec (sort-by str unknown))})))))
(let [at (fn [& parts] (str/join "/" (into ["clip" (segment cid)] parts)))
some-leaf (fn [path v] (when (seq v) {path v}))]
(apply merge
@ -140,30 +140,30 @@
(for [[id v] (:subjects clip)] {(at "subject" (segment id)) v})
(for [[id v] (:features clip)] {(at "feature" (segment id)) v})
(for [[id v] (:groups clip)] {(at "group" (segment id)) v})
;; The timeline's own facts. `:id` is the path segment, so writing it
;; The symbol's own facts. `:id` is the path segment, so writing it
;; into the value as well would be the one field a rename could
;; disagree with itself about; `clip` puts it back.
(for [[tid tl] (:timelines clip)]
{(at "timeline" (segment tid))
(select-keys tl [:frames :palette])})
(for [[tid tl] (:timelines clip)
[id n] (:nodes tl)]
{(at "timeline" (segment tid) "node" (segment id))
(for [[sid sym] (:symbols clip)]
{(at "symbol" (segment sid))
(select-keys sym [:frames :palette])})
(for [[sid sym] (:symbols clip)
[id n] (:nodes sym)]
{(at "symbol" (segment sid) "node" (segment id))
(apply dissoc n node-channel-keys)})
(for [[tid tl] (:timelines clip)
[id n] (:nodes tl)
(for [[sid sym] (:symbols clip)
[id n] (:nodes sym)
:when (seq (:measured n))]
{(at "timeline" (segment tid) "measured" (segment id)) (:measured n)})
(for [[tid tl] (:timelines clip)
[id n] (:nodes tl)
{(at "symbol" (segment sid) "measured" (segment id)) (:measured n)})
(for [[sid sym] (:symbols clip)
[id n] (:nodes sym)
[prop ch] (:channels n)]
{(at "timeline" (segment tid) "channel" (segment id) (prop->path prop)) ch})))))
{(at "symbol" (segment sid) "channel" (segment id) (prop->path prop)) ch})))))
(defn clip
"The inverse of `leaves`, for one clip. Paths belonging to another clip are
ignored, so a project's whole leaf map can be handed straight in.
A timeline's `:id` is restored from its path segment rather than read out of the
A symbol's `:id` is restored from its path segment rather than read out of the
value, which is why `leaves` does not write it: a segment and a field that both
claim to be the id are two places for one fact."
[cid leaves]
@ -173,14 +173,14 @@
(let [[_ found kind a b c] (str/split path #"/")]
(if-not (= want found)
acc
(if (= "timeline" kind)
(let [tid (unsegment a)
acc (assoc-in acc [:timelines tid :id] tid)]
(if (= "symbol" kind)
(let [sid (unsegment a)
acc (assoc-in acc [:symbols sid :id] sid)]
(case b
nil (update-in acc [:timelines tid] merge v)
"node" (update-in acc [:timelines tid :nodes (unsegment c)] merge v)
"measured" (assoc-in acc [:timelines tid :nodes (unsegment c) :measured] v)
"channel" (assoc-in acc [:timelines tid :nodes (unsegment c)
nil (update-in acc [:symbols sid] merge v)
"node" (update-in acc [:symbols sid :nodes (unsegment c)] merge v)
"measured" (assoc-in acc [:symbols sid :nodes (unsegment c) :measured] v)
"channel" (assoc-in acc [:symbols sid :nodes (unsegment c)
:channels (path->prop (nth (str/split path #"/") 6))]
v)
(throw (ex-info "not a leaf path" {:path path}))))
@ -210,20 +210,20 @@
content-addressed is that it does not have to travel with tier 1 to be found."
[leaves]
(let [parts (into {} (map (juxt identity #(vec (str/split % #"/")))) (keys leaves))
;; A node leaf, by (clip, timeline, node). Under a timeline id, because a
;; symbol and the root may both hold a `:mouth` and a channel of one is not
;; a channel of the other.
;; A node leaf, by (clip, symbol, node). Under a symbol id, because two
;; symbols may both hold a `:mouth` and a channel of one is not a channel
;; of the other.
nodes (into #{} (keep (fn [[_ p]]
(when (and (= 6 (count p)) (= "timeline" (nth p 2))
(when (and (= 6 (count p)) (= "symbol" (nth p 2))
(= "node" (nth p 4)))
[(nth p 1) (nth p 3) (nth p 5)])))
parts)
;; Which segment index holds the kind, and what shapes are legal.
legal? (fn [p]
(and (= "clip" (first p)) (second p)
(if (= "timeline" (nth p 2 nil))
(if (= "symbol" (nth p 2 nil))
(case (count p)
4 true ; the timeline itself
4 true ; the symbol itself
6 (#{"node" "measured"} (nth p 4))
7 (= "channel" (nth p 4))
false)
@ -238,13 +238,13 @@
:when (not (legal? p))]
(str (pr-str path) " is not a leaf path"))
(for [[path p] (sort-by key parts)
:when (and (legal? p) (= "timeline" (nth p 2 nil)) (>= (count p) 6)
:when (and (legal? p) (= "symbol" (nth p 2 nil)) (>= (count p) 6)
(#{"channel" "measured"} (nth p 4))
(not (contains? nodes [(nth p 1) (nth p 3) (nth p 5)])))]
(str (pr-str path) " addresses a node with no node leaf"))
(for [[path p] (sort-by key parts)
:let [v (get leaves path)]
:when (and (legal? p) (= "timeline" (nth p 2 nil)) (= 7 (count p))
:when (and (legal? p) (= "symbol" (nth p 2 nil)) (= 7 (count p))
(:dense v) (not (sha/key? (:store (:dense v)))))]
(str (pr-str path) " names tier 2 as " (pr-str (:store (:dense v)))
" — a dense channel in a saved document names a content address"))))))

View file

@ -21,9 +21,9 @@
"`:bitmap` is in the vocabulary and not implemented; it is
here so that a scene that names one fails as \"not implemented\" rather than as
\"not a kind\"."
#{:poly :disc :rect :group :bitmap :symbol :audio})
#{:poly :disc :rect :group :bitmap :instance :audio})
(def implemented-kinds #{:poly :disc :rect :group :symbol :audio})
(def implemented-kinds #{:poly :disc :rect :group :instance :audio})
(def xform-paths
"In composition order, which is also the order they have to be sampled in.
@ -45,7 +45,7 @@
change to this spec silently change what gets drawn."
(let [base (into #{[:vis]} xform-paths)]
{:group base
:symbol base
:instance base
:audio (into base [[:audio :gain] [:audio :pan] [:audio :rate]])
:poly (into base [[:geom :pts] [:style :color]])
;; A disc's radius is framed in practice — iris size is a knob, not a
@ -111,7 +111,7 @@
it on most frames, so the lead slider reads as doing nothing at exposures above
1, which is indistinguishable from the slider being unwired.
Composed along the parent chain, outermost first, by timeline/eval-frame. Two
Composed along the parent chain, outermost first, by symbol/eval-frame. Two
rules fall out and they are different rules: exposure INHERITS STRICTLY,
because a head cutting on odd frames against a mouth cutting on even ones reads
as two performances; offset is PER-NODE by design, because mouth lead applies
@ -122,13 +122,13 @@
(if (= mode :inherit)
f
(do
(when (and (not (#{:symbol :audio} (:kind n))) rate (not= rate 1.0) (not= rate 1))
(throw (ex-info "time map :rate belongs to a symbol or audio instance"
(when (and (not (#{:instance :audio} (:kind n))) rate (not= rate 1.0) (not= rate 1))
(throw (ex-info "time map :rate belongs to an instance or an audio node"
{:node (:id n) :time (:time n)})))
(when (and sample-fps (not (and source-fps (pos? source-fps))))
(throw (ex-info "picture sampling needs a positive source fps"
{:node (:id n) :time (:time n)})))
(cond-> (if (#{:symbol :audio} (:kind n))
(cond-> (if (#{:instance :audio} (:kind n))
(+ (or in 0) (* (or rate 1) (- f (or at 0))))
f)
sample-fps (sample-frame source-fps sample-fps)
@ -265,10 +265,10 @@
(not (contains? implemented-kinds k)))
(conj (str ":kind " k " is in the vocabulary but not implemented"))
(and (= k :symbol) (nil? (:of n))) (conj "a symbol instance needs :of")
(and (= k :instance) (nil? (:of n))) (conj "an instance needs :of")
(and (= k :audio) (nil? (get-in n [:source :footage])))
(conj "an audio instance needs :source :footage")
(and (#{:symbol :audio} k) (some? (get-in n [:time :rate]))
(and (#{:instance :audio} k) (some? (get-in n [:time :rate]))
(not (pos? (get-in n [:time :rate]))))
(conj "an instance's :rate must be positive")
(nil? (:z n)) (conj "no :z — draw order is authored per scene, not implied by the tree")

View file

@ -1,12 +1,13 @@
(ns arthur.domain.paint
"Small authored polygon operations. Paint nodes read timeline frames directly;
the roto root's exposure and picture sampling must not quantise a hand edit."
"Small authored polygon operations, each on a named symbol. Paint nodes read
their symbol's frames directly; a roto instance's exposure and picture sampling
must not quantise a hand edit."
(:require [arthur.domain.channel :as channel]))
(def geometry [:geom :pts])
(defn shapes [clip]
(->> (get-in clip [:timelines :main :nodes])
(defn shapes [clip sid]
(->> (get-in clip [:symbols sid :nodes])
(filter (fn [[_ node]] (:paint? node)))
(sort-by (comp :z val))
vec))
@ -15,21 +16,21 @@
(let [frames (sort (keys (:keys ch)))]
(or (last (take-while #(<= % frame) frames)) (first frames))))
(defn new-shape [clip id frame points color]
(let [end (get-in clip [:timelines :main :frames])
(defn new-shape [clip sid id frame points color]
(let [end (get-in clip [:symbols sid :frames])
z (str "z" (js/Date.now) "-" (name id))]
(if (and (<= 0 frame) (< frame end) (>= (count points) 6)
(even? (count points)))
(assoc-in clip [:timelines :main :nodes id]
{:id id :name (str "shape " (inc (count (shapes clip))))
(assoc-in clip [:symbols sid :nodes id]
{:id id :name (str "shape " (inc (count (shapes clip sid))))
:kind :poly :paint? true :parent nil :z z
:span [frame end]
:channels {geometry (channel/keyed {frame points})
[:style :color] (channel/framed color)}})
clip)))
(defn add-key [clip id frame]
(let [path [:timelines :main :nodes id]
(defn add-key [clip sid id frame]
(let [path [:symbols sid :nodes id]
node (get-in clip path)
ch (get-in node [:channels geometry])
[start end] (:span node)]
@ -38,20 +39,20 @@
(vec (channel/value-at ch frame)))
clip)))
(defn set-vertex [clip id key-frame vertex [x y]]
(let [path [:timelines :main :nodes id :channels geometry :keys key-frame]
(defn set-vertex [clip sid id key-frame vertex [x y]]
(let [path [:symbols sid :nodes id :channels geometry :keys key-frame]
points (get-in clip path)
i (* 2 vertex)]
(if (and points (< (inc i) (count points)))
(assoc-in clip path (-> points (assoc i x) (assoc (inc i) y)))
clip)))
(defn set-segment-interp [clip id key-frame interp]
(let [node (get-in clip [:timelines :main :nodes id])
(defn set-segment-interp [clip sid id key-frame interp]
(let [node (get-in clip [:symbols sid :nodes id])
keys (get-in node [:channels geometry :keys])]
(if (and (:paint? node) (contains? keys key-frame)
(some #(< key-frame %) (clojure.core/keys keys))
(#{:hold :linear} interp))
(assoc-in clip [:timelines :main :nodes id :channels geometry
(assoc-in clip [:symbols sid :nodes id :channels geometry
:segments key-frame] interp)
clip)))

View file

@ -88,7 +88,7 @@
(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`
Built once per export rather than per frame, in the shape `symbol/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

View file

@ -32,16 +32,17 @@
default-frame))
(defn put-cut
"Set one held pose on a symbol instance. Earlier motion stays untouched."
[clip instance group at source]
(let [node (get-in clip [:timelines :main :nodes instance])
symbol (get-in clip [:timelines (:of node)])
length (:frames symbol)
"Set one held pose on an instance inside symbol `sid`. Earlier motion stays
untouched."
[clip sid instance group at source]
(let [node (get-in clip [:symbols sid :nodes instance])
placed (get-in clip [:symbols (:of node)])
length (:frames placed)
active (filter (fn [n] (some :pose-sampled? (vals (:channels n))))
(vals (:nodes symbol)))
(vals (:nodes placed)))
groups (set (map #(or (:pose-group %) (:id %)) active))
ids (set (map :id active))]
(when-not (and (= :symbol (:kind node))
(when-not (and (= :instance (:kind node))
(or (contains? groups group)
(and (vector? group) (= 2 (count group))
(= :node (first group))
@ -50,17 +51,17 @@
(integer? source) (<= 0 source) (< source length))
(throw (ex-info "invalid stage pose cut"
{:instance instance :group group :at at :source source})))
(update-in clip [:timelines :main :nodes instance :playback :tracks group]
(update-in clip [:symbols sid :nodes instance :playback :tracks group]
#(assoc (or % {}) at source))))
(defn remove-cut
"Remove a cut; an empty track again follows the normal generated motion."
[clip instance group at]
(let [path [:timelines :main :nodes instance :playback :tracks group]]
[clip sid instance group at]
(let [path [:symbols sid :nodes instance :playback :tracks group]]
(if-let [entries (get-in clip path)]
(if-let [remaining (not-empty (dissoc entries at))]
(assoc-in clip path remaining)
(update-in clip [:timelines :main :nodes instance :playback :tracks]
(update-in clip [:symbols sid :nodes instance :playback :tracks]
dissoc group))
clip)))

View file

@ -29,6 +29,12 @@
(:require [arthur.domain.leaf :as leaf]
[arthur.domain.wire :as wire]))
(def schema-version
"The stored document format this client reads and writes. 2 is symbols: leaf
paths say `symbol`, a placing node is `:kind :instance`, and no symbol id is
reserved. `clips/migrations/0007` moved every saved project from 1."
2)
(defn block-keys
"Every tier-2 key a leaf map names, in a stable order."
[leaves]
@ -53,8 +59,8 @@
round-trip a clip through `JSON.parse(JSON.stringify(...))` and be running the
same conversion the network runs, rather than a CLJS-shaped rehearsal of it. The
one thing a keywordising `js->clj` would quietly break is the leaf paths —
`:clip/c1/timeline/main/node/mouth` is a keyword whose `name` is
\"c1/timeline/main/node/mouth\", so the
`:clip/c1/symbol/main/node/mouth` is a keyword whose `name` is
\"c1/symbol/main/node/mouth\", so the
\"clip/\" would be lost on the way back in.
Refuses a document `domain/leaf` calls unaddressable, which is where a hand-made

View file

@ -30,7 +30,7 @@
edge landing exactly on a pixel boundary resolves consistently.
Flat and preallocated because this is the per-frame path: fixed topology means
a node's vertex count is known at freeze time, so timeline/resolver hands the same
a node's vertex count is known at freeze time, so symbol/resolver hands the same
buffer back every frame and a frame allocates nothing. At 30fps per-frame
allocation is the only thing that will make this stutter.

View file

@ -1,38 +1,36 @@
(ns arthur.domain.timeline
"A TIMELINE: an ordered bag of nodes in its own frame space, and the two ways to
(ns arthur.domain.symbol
"A SYMBOL: an ordered bag of nodes in its own frame space, and the two ways to
evaluate it at a frame.
{:id :main :frames 229 :nodes {id -> node} :palette nil}
That is the whole type, and EVERYTHING THAT HOLDS NODES IS ONE OF THESE. A
clip's root timeline is one; a symbol in the library is one; a `:kind :symbol`
node is an INSTANCE of one. An earlier arrangement had the clip's node tree and
a library symbol as two structures with the same fields and never said they were
the same thing — the clip map carried `:fps`, `:width`, `:height`, `:analysis`
and the tracking identities alongside `:nodes`, so a symbol had nowhere to live
that was not a clip with seven meaningless fields. Flash's `_root` is a
MovieClip and After Effects' pre-comp is just a layer; collapsing them is what
makes nesting arbitrary and free rather than a feature to be added.
That is the whole type, and EVERYTHING THAT HOLDS NODES IS ONE OF THESE. What
a document opens on is a symbol; what a `:kind :instance` node places is a
symbol; there is no second structure. An earlier arrangement had a root node
tree and a library entry as two structures with the same fields and never said
they were the same thing. Flash's `_root` is a MovieClip and After Effects'
pre-comp is just a layer; collapsing them is what makes nesting arbitrary and
free rather than a feature to be added.
The clip-level facts are in `arthur.domain.clip`. A timeline has a FRAME SPACE,
The clip-level facts are in `arthur.domain.clip`. A symbol has a FRAME SPACE,
not a rate and not a size: `:fps` is the clip's, because a rate is a fact about
how fast the whole thing plays, and a nested timeline cannot have its own.
how fast the whole thing plays, and a nested symbol cannot have its own.
TWO AXES OF NESTING, and conflating them is why \"nested\" and \"flat with parent
pointers\" sound contradictory when they are not. Parent/child is transform
composition WITHIN one timeline and is stored flat with pointers. Instance is a
timeline inside another timeline and is stored by reference into the library.
Each timeline is flat; timelines nest. Every argument for flat storage —
composition WITHIN one symbol and is stored flat with pointers. Instance is a
symbol inside another symbol and is stored by reference into the library.
Each symbol is flat; symbols nest. Every argument for flat storage —
addressability, one-field reparenting, structural sharing, per-node sync leaves —
is about the first axis and is untouched by the second.
Two ways to evaluate one at a frame:
(eval-frame tl f store) THE SPECIFICATION. Allocating, order-free,
(eval-frame sym f store) THE SPECIFICATION. Allocating, order-free,
obviously correct. Use it in tests and for a
one-off render.
(resolver tl store) -> (fn [f] ops). What playback uses. Caches the
(resolver sym store) -> (fn [f] ops). What playback uses. Caches the
topological order and the z paths, holds one
CURSOR per channel and one PREALLOCATED point
buffer per node, so a frame allocates the op
@ -42,7 +40,7 @@
read and where points are written. That is deliberate: two independent
implementations of frame evaluation would drift, and the drift would look like
a rendering bug rather than like two functions disagreeing. What differs
between them is exactly the part that can be wrong, and timeline-test asserts
between them is exactly the part that can be wrong, and symbol-test asserts
they agree frame for frame in forward, backward and random order.
The output is a list of DRAW OPS, and it is the boundary with the rasteriser:
@ -76,12 +74,12 @@
(when-let [p (:parent (get nodes i))]
(if (contains? nodes p)
p
(throw (ex-info "node's :parent is not in the timeline"
(throw (ex-info "node's :parent is not in the symbol"
{:node i :parent p})))))
chain (into [] (comp (take-while some?) (take (inc (count nodes))))
(iterate up id))]
(when (> (count chain) (count nodes))
(throw (ex-info "parent cycle in timeline" {:node id :chain chain})))
(throw (ex-info "parent cycle in symbol" {:node id :chain chain})))
chain))
(defn depth
@ -129,9 +127,9 @@
"id -> its position in draw order.
Computed ONCE. Draw order is a function of the z paths, which are structural —
they change when the timeline changes and never because the playhead moved — so
they change when the symbol changes and never because the playhead moved — so
sorting ops by z on every frame was re-deriving a constant thirty times a
second. Here it is derived when the timeline is, and a frame sorts small integers.
second. Here it is derived when the symbol is, and a frame sorts small integers.
`sort-by` is stable and `ord` is topological, so nodes sharing a z path keep
parent-before-child order without a tiebreak field on every op."
@ -146,9 +144,9 @@
"Tone keyword -> the index the raster writes, in a given palette.
`palette` is a map of tone -> index. It is a PARAMETER, not a global: a tone
names which mark this is, and which ramp it is read in belongs to the timeline
names which mark this is, and which ramp it is read in belongs to the symbol
the node sits in, so resolution cannot reach for one ambient answer. Today
there is one palette and it is passed in anyway; when timelines carry a
there is one palette and it is passed in anyway; when symbols carry a
`:palette` channel, the walk carries the palette in scope exactly as it already
carries the parent transform and the local frame.
@ -229,7 +227,7 @@
flow/freeze writes it KEYED, because a threshold crossing is a handful of
transitions and hold is the default, and because a human has to be able to fix
one frame of it. When something does want a dense one it will land here loudly
instead of blanking the timeline.
instead of blanking the symbol.
Absence is not a boolean and is not an error: a subject that is not on the
frame has nothing to show."
@ -271,13 +269,13 @@
:rd rd})))))))
(defn- emit
"Emit geometry in the timeline's space. Rect sizes stay fractional until
"Emit geometry in the symbol's space. Rect sizes stay fractional until
rasterization, so enclosing symbol transforms can still scale them."
[{:keys [palette buf-for]} n {:keys [m rd]} base]
(let [colour #(colour-index palette (rd [:style :color]))]
(case (:kind n)
:group nil
:symbol nil
:instance nil
:audio nil
:poly
@ -311,20 +309,20 @@
{:node (:id n) :kind (:kind n)})))))
(defn- nodes-of
"The timeline's node map, REFUSING a map that has none.
"The symbol's node map, REFUSING a map that has none.
A clip and a timeline both have an `:id` and both are maps, so handing a CLIP to
A clip and a symbol both have an `:id` and both are maps, so handing a CLIP to
an evaluator is the one mistake this type split makes easy — and the result is
not an error, it is `(:nodes clip)` being nil and a frame resolving to no ops at
all. That reads as a black stage, or, in a benchmark, as \"0 nodes\" and a
flattering number. It happened once while the split was being made, which is why
this is a guard and not a comment."
[tl]
(let [nodes (:nodes tl)]
[sym]
(let [nodes (:nodes sym)]
(when-not (map? nodes)
(throw (ex-info (str "not a timeline: :nodes is " (pr-str nodes)
" — a clip is not a timeline, its `:timelines` hold them")
{:keys (vec (sort-by str (keys tl)))})))
(throw (ex-info (str "not a symbol: :nodes is " (pr-str nodes)
" — a clip is not a symbol, its `:symbols` hold them")
{:keys (vec (sort-by str (keys sym)))})))
nodes))
(defn- channel-frame
@ -386,18 +384,18 @@
;; the specification
(defn eval-frame
"Timeline at frame f -> draw ops in z order. Pure, and allocates freely.
"Symbol at frame f -> draw ops in z order. Pure, and allocates freely.
`f` is in THIS timeline's frame space. At the clip's root that is clip frames;
inside an instance it is the instance's own space, and the instance boundary is
`f` is in THIS symbol's frame space. For the symbol on screen that is the
transport's frame; inside an instance it is the instance's own space, and the instance boundary is
the only place the space changes.
This is the definition of what a frame means. `resolver` is what plays it."
([tl f] (eval-frame tl f nil pal/index-of))
([tl f store] (eval-frame tl f store pal/index-of))
([tl f store palette] (eval-frame tl f store palette nil nil))
([tl f store palette pose-tracks opts]
(let [nodes (nodes-of tl)
([sym f] (eval-frame sym f nil pal/index-of))
([sym f store] (eval-frame sym f store pal/index-of))
([sym f store palette] (eval-frame sym f store palette nil nil))
([sym f store palette pose-tracks opts]
(let [nodes (nodes-of sym)
choices (pose/prepare pose-tracks)
anchors (prepared-anchors nodes)
{:keys [source-fps picture-fps]} opts
@ -456,12 +454,12 @@
The op maps themselves are allocated fresh, and deliberately: there are a dozen
of them per frame against hundreds of points, so pooling them would buy
nothing and cost the ability to hand an op list around as plain data."
([tl] (resolver tl nil pal/index-of nil nil))
([tl store] (resolver tl store pal/index-of nil nil))
([tl store palette] (resolver tl store palette nil nil))
([tl store palette pose-tracks] (resolver tl store palette pose-tracks nil))
([tl store palette pose-tracks {:keys [source-fps picture-fps]}]
(let [nodes (nodes-of tl)
([sym] (resolver sym nil pal/index-of nil nil))
([sym store] (resolver sym store pal/index-of nil nil))
([sym store palette] (resolver sym store palette nil nil))
([sym store palette pose-tracks] (resolver sym store palette pose-tracks nil))
([sym store palette pose-tracks {:keys [source-fps picture-fps]}]
(let [nodes (nodes-of sym)
choices (pose/prepare pose-tracks)
anchors (prepared-anchors nodes)
ord (order nodes)
@ -507,20 +505,20 @@
;; ---------------------------------------------------------------------------
(def timeline-keys
"Every field a timeline may carry, and the reason `arthur.domain.leaf` refuses
(def symbol-keys
"Every field a symbol may carry, and the reason `arthur.domain.leaf` refuses
one it does not know: a field added without a leaf to save it in is a field that
saves silently and comes back missing.
`:palette` is in the vocabulary and nothing writes one yet. A timeline is where
a ramp belongs — `domain/timeline` takes the palette as a PARAMETER rather than
reaching for a global precisely so that a nested timeline can carry its own —
`:palette` is in the vocabulary and nothing writes one yet. A symbol is where
a ramp belongs — `domain/symbol` takes the palette as a PARAMETER rather than
reaching for a global precisely so that a nested symbol can carry its own —
and leaving the field out would make the first one a migration instead of a
write."
#{:id :frames :nodes :palette})
(defn problems
"Human-readable reasons this timeline will not evaluate. Empty means it will.
"Human-readable reasons this symbol will not evaluate. Empty means it will.
Node structure only. The tracking identities — subjects, features, groups — are
the CLIP's and are checked by `arthur.domain.clip/problems`, which is not a
@ -530,8 +528,8 @@
Total by construction — it reports a cycle rather than looping on one — because
its whole job is to be safe to run over authored data before that data is
trusted."
[tl]
(let [nodes (:nodes tl)]
[sym]
(let [nodes (:nodes sym)]
(if-not (map? nodes)
[":nodes must be a map of id -> node"]
(-> []
@ -541,11 +539,11 @@
(into (for [[id n] nodes
:when (and (:parent n) (not (contains? nodes (:parent n))))]
(str "node " (pr-str id) " has :parent " (pr-str (:parent n))
" which is not in the timeline")))
" which is not in the symbol")))
(into (for [[id n] nodes
:when (and (:stencil n) (not (contains? nodes (:stencil n))))]
(str "node " (pr-str id) " has :stencil " (pr-str (:stencil n))
" which is not in the timeline")))
" which is not in the symbol")))
(into (for [[id n] nodes
p (node/problems n)]
(str "node " (pr-str id) ": " p)))
@ -554,19 +552,19 @@
:let [anchors (:anchors n)]
:when (some? anchors)
:when (not (and (map? anchors) (contains? anchors 0)
(integer? (:frames tl))
(integer? (:frames sym))
(every? #(and (integer? %) (<= 0 %)
(< % (:frames tl)))
(< % (:frames sym)))
(concat (keys anchors) (vals anchors)))
(seq (:measured n))
(= (:channels n) (:measured n))))]
(str "node " (pr-str id)
": :anchors must start at frame 0, name valid measured frames, and read that node's own measured channels")))
(into (for [k (remove timeline-keys (keys tl))]
(str "timeline has a field with no leaf to save it in: " (pr-str k))))
(into (when-not (or (nil? (:frames tl)) (and (integer? (:frames tl)) (pos? (:frames tl))))
[(str ":frames is " (pr-str (:frames tl))
" — a timeline is a frame SPACE, so its length is a positive integer")]))
(into (for [k (remove symbol-keys (keys sym))]
(str "symbol has a field with no leaf to save it in: " (pr-str k))))
(into (when-not (or (nil? (:frames sym)) (and (integer? (:frames sym)) (pos? (:frames sym))))
[(str ":frames is " (pr-str (:frames sym))
" — a symbol is a frame SPACE, so its length is a positive integer")]))
(into (try
(doall (map #(depth nodes %) (keys nodes)))
nil

View file

@ -2,7 +2,7 @@
"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
runs for as long as the symbol 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.
@ -44,84 +44,88 @@
(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.
Two kinds, told apart by a leading letter: `s:<symbol>` is a whole symbol,
`n:<symbol>:<node>` is one instance inside one. The parts are joined with `:`
because neither a symbol 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
is not a key in `:symbols`, so the plan silently becomes nil and the export
throws \"there is no such symbol\" 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))))
[{sid :symbol isolate :isolate}]
(let [s (subs (str sid) 1)]
(if isolate (str "n:" s ":" isolate) (str "s:" s))))
(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
namespaced symbol id survives; an instance 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)}
(let [[kind s node] (str/split v #":")]
(cond-> {:symbol (keyword s)}
(= "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.
TWO KINDS, and the distinction is the point. A symbol is the DRAWING — one file
however many times it is placed, in its own frame space. An instance is that
drawing WHERE IT SITS in the open symbol: that symbol's length and rate, with
the other instances 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
Instances 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)
[clip open]
(let [instances (->> (get-in clip [:symbols open :nodes])
(filter (comp #{:instance} :kind val))
(sort-by (fn [[id n]] [(or (:name n) "") (str id)])))]
(into (mapv (fn [sid] {:symbol sid :label (name sid)})
(sort-by str (keys (:symbols clip))))
(mapv (fn [[id n]]
{:timeline :main :isolate id
{:symbol open :isolate id
:label (or (:name n) (str id))})
placements))))
instances))))
(defn target
"What the export is pointed at. A nil symbol is whichever one is open, so a
new document exports what is on screen without anyone choosing."
[db]
(let [{sid :symbol isolate :isolate} (:export db)]
{:symbol (or sid (get-in db [:ui :open])) :isolate isolate}))
(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)})))
[clip open {sid :symbol isolate :isolate}]
(:label (or (first (filter #(and (= sid (:symbol %)) (= isolate (:isolate %)))
(targets clip open)))
{:label (some-> sid name)})))
(rf/reg-sub ::state (fn [db _] (:export db)))
(rf/reg-sub ::state (fn [db _] (assoc (:export db) :target (target db))))
(rf/reg-sub
::targets
(fn [db _]
(targets (:clip (store/entry (:clip/current db))))))
(targets (:clip (store/entry (:clip/current db))) (get-in db [:ui :open]))))
(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
{sid :symbol isolate :isolate} (target db)]
(export/plan {:clip clip :symbol sid :zoom (get-in db [:export :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
;; Both keys always, so switching from an instance back to a whole symbol
;; 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})))
(fn [db [_ {sid :symbol isolate :isolate}]]
(update db :export merge {:symbol sid :isolate isolate})))
(rf/reg-event-db
::set-zoom
@ -134,16 +138,17 @@
{}
(let [id (:clip/current db)
entry (store/entry id)
{:keys [timeline zoom isolate]} (:export db)]
{sid :symbol isolate :isolate} (target db)
zoom (get-in db [:export :zoom])]
{:db (update db :export merge {:busy? true :done 0
:total (:frames (export/plan
{:clip (:clip entry)
:timeline timeline
:symbol sid
:isolate isolate
:zoom zoom}))
:status "rendering…"})
::run! {:clip (:clip entry)
:timeline timeline
:symbol sid
:isolate isolate
:store (:store entry)
;; The same palette and ramp the preview resolves and blits
@ -156,8 +161,8 @@
:picture-fps (get-in db [:clip :display-fps])
:audio-url (:audio entry)
:name (stem (:label entry)
(label-of (:clip entry)
{:timeline timeline :isolate isolate}))}}))))
(label-of (:clip entry) (get-in db [:ui :open])
{:symbol sid :isolate isolate}))}}))))
(rf/reg-event-db
::progress
@ -197,7 +202,7 @@
::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
;; returns a promise, so a bad symbol 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 /\"

View file

@ -142,7 +142,6 @@
source-blocks (source/pack-subjects (:id (:analysis built)) subjects)
_ (mark! "build-clip: pack source blocks")]
(assoc (select-keys built [:fps :width :height])
:frames (clip/frames built)
:display-fps (:fps built)
:clip built :store (:store frozen)
:source-blocks source-blocks
@ -389,11 +388,7 @@
::loaded
(fn [{:keys [db]} [_ id summary]]
(let [clip (store/entry id)]
{:db (-> db
(assoc :clip/current id
:clip (select-keys clip [:fps :frames :width :height :audio :display-fps])
:footage (assoc (:footage db) :id id :label (:label clip)
:loading? false :status summary))
(assoc-in [:playback :frame] 0)
(assoc-in [:playback :playing?] false))
{:db (-> (pb/show db id)
(assoc :footage (assoc (:footage db) :id id :label (:label clip)
:loading? false :status summary)))
::pb/pause! nil})))

View file

@ -7,20 +7,20 @@
(rf/reg-event-db
::new-shape
(fn [db [_ id points color]]
(edit/edit db #(paint/new-shape % id (get-in db [:playback :frame]) points color))))
(fn [db [_ sid id points color]]
(edit/edit db #(paint/new-shape % sid id (get-in db [:playback :frame]) points color))))
(rf/reg-event-db
::add-key
(fn [db [_ id]]
(edit/edit db #(paint/add-key % id (get-in db [:playback :frame])))))
(fn [db [_ sid id]]
(edit/edit db #(paint/add-key % sid id (get-in db [:playback :frame])))))
(rf/reg-event-db
::set-vertex
(fn [db [_ id key-frame vertex point]]
(edit/edit db #(paint/set-vertex % id key-frame vertex point))))
(fn [db [_ sid id key-frame vertex point]]
(edit/edit db #(paint/set-vertex % sid id key-frame vertex point))))
(rf/reg-event-db
::set-segment-interp
(fn [db [_ id key-frame interp]]
(edit/edit db #(paint/set-segment-interp % id key-frame interp))))
(fn [db [_ sid id key-frame interp]]
(edit/edit db #(paint/set-segment-interp % sid id key-frame interp))))

View file

@ -11,11 +11,33 @@
a small app-db. If global interceptors are added later they are added to a
chain these events are excluded from, not to `reg-global-interceptor`."
(:require [arthur.clock :as clock]
[arthur.domain.clip :as clip]
[arthur.footage.store :as footage]
[re-frame.core :as rf]))
(defn- fps [db] (get-in db [:clip :fps]))
(defn- frames [db] (get-in db [:clip :frames]))
(defn frames
"The open symbol's length. Derived from the document every time, never kept in
the db beside it, because an edit can change it."
[db]
(or (some-> (footage/entry (:clip/current db)) :clip
(clip/frames (get-in db [:ui :open])))
1))
(defn show
"Put loaded clip `id` on screen, open on the symbol it opens on, with the
playhead home. What every way of loading a document ends in, so that none of
them can forget which symbol is open."
[db id]
(let [entry (footage/entry id)
sid (clip/opens-on (:clip entry))]
(-> db
(assoc :clip/current id
:clip (select-keys entry [:fps :width :height :audio :display-fps]))
(update :ui merge {:open sid :tabs (if sid [sid] [])})
(assoc-in [:playback :frame] 0)
(assoc-in [:playback :playing?] false))))
(rf/reg-event-db
::tick
@ -99,21 +121,15 @@
;; Changing the clip changes the resolver, the frame count and the rate all
;; at once, so the playhead goes home rather than being left pointing at a
;; frame the new clip may not have.
(let [{:keys [fps frames] :as clip} (footage/entry id)]
{:db (-> db
(assoc :clip/current id)
;; The stage travels with the clip: two clips may be different
;; sizes, and the raster the loop paints into is the clip's, not
;; the app's.
(assoc :clip (select-keys clip [:fps :frames :width :height :audio :display-fps]))
;; The document's identity goes with it. A built-in scene has no
;; project on the server, so this CLEARS the id rather than keeping
;; the last one — saving a fixture must create a document of its
;; own, not overwrite whatever happened to be open before it.
(assoc :project {:id nil :cid (:cid clip) :name (:label clip)
:seq nil :busy? false
:status "built-in example · not a saved project"})
(assoc-in [:playback :frame] 0)
(assoc-in [:playback :playing?] false))
(let [{:keys [label cid]} (footage/entry id)
db (-> (show db id)
;; The document's identity goes with it. A built-in scene has no
;; project on the server, so this CLEARS the id rather than
;; keeping the last one — saving a fixture must create a
;; document of its own, not overwrite whatever was open before.
(assoc :project {:id nil :cid cid :name label
:seq nil :busy? false
:status "built-in example · not a saved project"}))]
{:db db
::pause! nil
::seek! [fps frames 0]})))
::seek! [(fps db) (frames db) 0]})))

View file

@ -109,13 +109,13 @@
built (:clip loaded)]
(let [entry (merge (select-keys built [:fps :width :height])
{:label (str (or (.-name clip-json) cid) " (saved)")
:cid cid :frames (clip/frames built)
:cid cid
:display-fps (:fps built)
:clip built :store (:store loaded)
:footage-id footage-id
:audio (if footage (.-audio footage)
"/static/arthur/audio.wav")})]
(-> (mix/mix! built (:audio entry) (:store entry))
(-> (mix/mix! built (clip/opens-on built) (:audio entry) (:store entry))
(.then (fn [audio] (assoc entry :audio audio)))))))))))
(rf/reg-fx
@ -188,6 +188,11 @@
(.then (fn [^js row] (http/GET (str "/api/projects/" (.-id row)))))
(.then (fn [^js loaded]
(let [^js clip-json (first (array-seq (.-clips loaded)))]
(when (not= project/schema-version (.-schema_version loaded))
(throw (ex-info (str "that project is stored as schema "
(.-schema_version loaded) " and this client reads "
project/schema-version)
{})))
(when-not clip-json
(throw (ex-info "that project has no clips" {})))
(-> (opened-entry! clip-json)
@ -213,9 +218,9 @@
(.then (fn [entry]
(let [built (stage/compose (:clip entry))
entry (assoc entry :clip built :label (:name built)
:cid "stage-8625" :frames (clip/frames built)
:cid "stage-8625"
:width (:width built) :height (:height built))]
(-> (mix/mix! built (:audio entry) (:store entry))
(-> (mix/mix! built (clip/opens-on built) (:audio entry) (:store entry))
(.then (fn [audio]
(rf/dispatch
[::stage-opened
@ -379,7 +384,6 @@
;; it, and two untitled documents saved from two tabs are two documents.
:cid (str (random-uuid))
:display-fps (:fps c)
:frames (clip/frames c)
:fps (:fps c) :width (:width c) :height (:height c)}))
(rf/reg-event-fx
@ -390,14 +394,10 @@
;; for, and the built-in scenes are rows in the media pool like anything else.
(let [entry (blank-entry)
id (store/install! entry "new")]
{:db (-> db
(assoc :clip/current id
:clip (select-keys entry [:fps :frames :width :height :audio :display-fps]))
{:db (-> (assoc db :ui (:ui db/default))
(pb/show id)
(assoc :project {:id nil :cid (:cid entry) :name nil :seq nil
:busy? false :status "new document"})
(assoc :ui (:ui db/default))
(assoc-in [:playback :frame] 0)
(assoc-in [:playback :playing?] false))
:busy? false :status "new document"}))
::pb/pause! nil})))
(rf/reg-event-fx
@ -447,15 +447,11 @@
(rf/reg-event-fx
::stage-opened
(fn [{:keys [db]} [_ clip-id]]
(let [entry (store/entry clip-id)]
{:db (-> db
(assoc :clip/current clip-id
:clip (select-keys entry [:fps :frames :width :height :audio :display-fps]))
(assoc :project {:id nil :cid nil :name nil :seq nil
:busy? false :status "loaded 8625 stage study"})
(assoc-in [:playback :frame] 0)
(assoc-in [:playback :playing?] false))
::pb/seek! [(:fps entry) (:frames entry) 0]})))
(let [db (-> (pb/show db clip-id)
(assoc :project {:id nil :cid nil :name nil :seq nil
:busy? false :status "loaded 8625 stage study"}))]
{:db db
::pb/seek! [(get-in db [:clip :fps]) (pb/frames db) 0]})))
(rf/reg-event-db
::saved
@ -469,17 +465,13 @@
(rf/reg-event-fx
::opened
(fn [{:keys [db]} [_ clip-id project-id name seq]]
(let [clip (store/entry clip-id)]
{:db (-> db
(assoc :clip/current clip-id
:clip (select-keys clip [:fps :frames :width :height :audio :display-fps]))
(update :project merge
{:id project-id :name name :seq seq :cid (:cid clip)
:busy? false
:status (str "opened " name " r" seq)})
(assoc-in [:playback :frame] 0)
(assoc-in [:playback :playing?] false))
::pb/pause! nil})))
{:db (-> (pb/show db clip-id)
(update :project merge
{:id project-id :name name :seq seq
:cid (:cid (store/entry clip-id))
:busy? false
:status (str "opened " name " r" seq)}))
::pb/pause! nil}))
(rf/reg-event-db
::failed

View file

@ -58,9 +58,10 @@
;; out: a node's id is its identity in the saved document, so the pure
;; layer must be handed one rather than invent one. If replaying the event
;; log ever has to reproduce a document exactly, this becomes a cofx.
(let [id (keyword (str "paint-" (random-uuid)))]
{:db (update db :ui merge {:tool nil :draft [] :selection [:node :main id]})
:dispatch [::paint/new-shape id draft (get-in db [:ui :tone])]})))))
(let [id (keyword (str "paint-" (random-uuid)))
sid (get-in db [:ui :open])]
{:db (update db :ui merge {:tool nil :draft [] :selection [:node sid id]})
:dispatch [::paint/new-shape sid id draft (get-in db [:ui :tone])]})))))
(rf/reg-event-db
::set-knob
@ -72,8 +73,9 @@
(rf/reg-event-db
::place-symbol
(fn [db [_ tid [x y]]]
(let [uuid (random-uuid)]
(fn [db [_ sid [x y]]]
(let [uuid (random-uuid)
into (get-in db [:ui :open])]
(-> db
(edit/edit #(clip/place-symbol % tid (get-in db [:playback :frame]) uuid [x y]))
(assoc-in [:ui :selection] [:node :main uuid])))))
(edit/edit #(clip/place-symbol % into sid (get-in db [:playback :frame]) uuid [x y]))
(assoc-in [:ui :selection] [:node into uuid])))))

View file

@ -12,7 +12,7 @@
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, generated-channel picture
work: rooting the resolver at the chosen symbol, generated-channel picture
sampling,
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
@ -20,7 +20,7 @@
TWO RULES THE WALK ENFORCES, both about sync:
Every frame of the timeline's frame space is emitted, at the CLIP's rate. A
Every frame of the symbol'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
@ -37,14 +37,14 @@
[arthur.domain.raster :as raster]))
(defprotocol Exporter
"A sink for a rendered timeline. Implementations live under `arthur.export.*`.
"A sink for a rendered symbol. 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
`Exporter` rather than `IExporter`, which is what `domain/symbol`'s
`IResolver` would suggest, because it names a role a thing plays rather than a
capability a value has."
@ -60,7 +60,7 @@
: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
:audio an AudioBuffer, or nil when the symbol 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
@ -71,7 +71,7 @@
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
for the same reason `symbol/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.")
@ -92,7 +92,7 @@
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."
sits, in the symbol it sits in."
[nodes id]
(let [up (loop [i id acc #{}]
(if (or (nil? i) (contains? acc i))
@ -114,7 +114,7 @@
k))))
(defn isolate
"The timeline with only `id` and its kin kept. `nil` leaves it alone.
"The symbol 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
@ -122,10 +122,10 @@
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))
[sym id]
(if (and id (get-in sym [:nodes id]))
(update sym :nodes select-keys (kin (:nodes sym) id))
sym))
(defn- yield!
"Hand the event loop a turn between frames.
@ -140,64 +140,64 @@
(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)
A symbol's own placed audio tracks win. Failing that, the symbol the document
OPENS ON — and only that one — falls back to the clip's audio file, which is
where a take's sound lives before anyone has placed a track. Any other symbol
exports silence rather than the whole clip's soundtrack, because its frame
space is its own and the clip's audio is not a fact about it."
[clip-doc sid store fallback-url]
(-> (mix/buffer! clip-doc sid store)
(.then (fn [buffer]
(cond
buffer buffer
(and (= tid clip/root-id) fallback-url) (mix/decode! fallback-url)
(and (= sid (clip/opens-on clip-doc)) fallback-url) (mix/decode! fallback-url)
:else nil)))))
(defn plan
"What an export of `tid` will produce, without producing any of it.
"What an export of `sid` 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))
[{:keys [clip zoom picture-fps] sid :symbol isolate-id :isolate}]
(let [sym (some-> (clip/symbol clip sid) (isolate isolate-id))
zoom (max 1 (js/Math.floor (or zoom 1)))]
(when tl
{:frames (:frames tl)
(when sym
{:frames (:frames sym)
:fps (:fps clip)
:zoom zoom
:width (* (:width clip) zoom)
:height (* (:height clip) zoom)
:seconds (/ (:frames tl) (:fps clip))
:seconds (/ (:frames sym) (:fps clip))
;; The unedited picture-grid count. A per-instance pose track can add or
;; remove changes, so this is only the grid's nominal count.
:poses (if (and picture-fps (< picture-fps (:fps clip)))
(js/Math.ceil (* (/ (:frames tl) (:fps clip)) picture-fps))
(:frames tl))})))
(js/Math.ceil (* (/ (:frames sym) (:fps clip)) picture-fps))
(:frames sym))})))
(defn run!
"Render `timeline` into `exporter`. Promise of `{:filename :blob}`.
"Render symbol `sid` 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}
[{:keys [clip store palette ramp zoom picture-fps name audio-url]
sid :symbol 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
(let [sym (some-> (clip/symbol clip sid) (isolate isolate-id))]
(when-not sym
(throw (ex-info "there is no such symbol to export"
{:symbol sid
:symbols (vec (sort-by str (keys (:symbols clip))))})))
(let [{:keys [frames fps zoom]} (plan {:clip clip :symbol sid :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] tl)
resolve-frame (clip/resolver doc store palette timeline
;; Rooted at the chosen symbol, as the stage is. Nested instances
;; inside it still resolve — clip/resolver is the function that knows
;; how.
doc (assoc-in clip [:symbols sid] sym)
resolve-frame (clip/resolver doc store palette sid
{:picture-fps picture-fps})
ras (raster/make (:width clip) (:height clip))
bg (get palette :bg 0)]
(-> (audio! doc timeline store audio-url)
(-> (audio! doc sid store audio-url)
(.then (fn [audio]
(js/Promise.resolve
(begin! exporter {:name name :width (:width clip)

View file

@ -33,7 +33,6 @@
plate, which a human draws, is worth decimating. Sparse visibility keys capture
decisions about the mouth cavity, blink and teeth without thinning geometry."
(:require [arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.feature :as feature]
[arthur.domain.geom :as geom]
[arthur.domain.ring :as ring]
@ -262,7 +261,7 @@
not. Two faces in one shot were filmed together and are posed apart: choosing
frame 12 for the second face must leave the first one running, and it does,
because an anchor map lives on that subject's own head node and
`domain/timeline` reads anchors off whatever node carries them."
`domain/symbol` reads anchors off whatever node carries them."
[{:keys [subject mode anchors]} {:keys [clip]}]
(when-not (contains? head-modes mode)
(throw (ex-info "head mode must be free or anchored"
@ -274,14 +273,14 @@
{:subject subject :subjects (vec (sort-by str (keys (:subjects clip))))})))
(reduce
(fn [c sid]
(let [frames (get-in c [:timelines sid :frames])]
(let [frames (get-in c [:symbols sid :frames])]
(when (and (= mode :anchored)
(not (and (map? anchors) (contains? anchors 0)
(every? #(and (integer? %) (<= 0 %) (< % frames))
(concat (keys anchors) (vals anchors))))))
(throw (ex-info "anchored head needs a frame-zero key and valid source frames"
{:subject sid :anchors anchors :frames frames})))
(update-in c [:timelines sid :nodes :head]
(update-in c [:symbols sid :nodes :head]
(fn [n]
(cond-> (assoc n :channels (:measured n))
(= mode :anchored) (assoc :anchors anchors)
@ -654,7 +653,7 @@
;; the clip
(defn- subject-part
"A subject's drawing, metadata and blocks. Node names are timeline-local."
"A subject's drawing, metadata and blocks. Node names are symbol-local."
[params subject {:keys [outer eyes brows teeth] :as inputs}]
(let [own (partial feature/owned subject)
areas (cond-> [:mouth] (and eyes brows) (into [:eye :brow]) teeth (conj :teeth))
@ -666,13 +665,13 @@
:eye-l [:eye [:eye-l :eye-l-in :iris-l :pupil-l]]
:brow-r [:brow [:brow-r]] :brow-l [:brow [:brow-l]]})
teeth (assoc :teeth [:teeth [:teeth]]))]
{:timeline {:id subject :frames (count outer)
{:symbol {:id subject :frames (count outer)
:nodes (into {:head {:id :head :name "head" :kind :group :z "a1"
:measured (:measured head)}}
(mapcat :nodes) parts)}
:features (into {} (map (fn [[role [area nodes]]]
[(own role) {:id (own role) :subject subject
:timeline subject :area area
:symbol subject :area area
:nodes nodes :params {}}])) features)
:groups (if (and eyes brows)
{(own :eyes) {:id (own :eyes) :kind :eye-pair :subject subject
@ -681,12 +680,13 @@
:store (into (:store head) (mapcat :store) parts)}))
(defn clip
"Subject-id -> conditioned measurements becomes a library of face timelines.
"Subject-id -> conditioned measurements becomes one symbol per face, and a
symbol called :main that places them.
:main holds exposure and a shared source-to-stage placement. Each subject is
placed by an ordinary symbol instance, so pose choices and transforms have
their existing instance scope. Features name local nodes in that subject's
timeline; block descriptors still name globally distinct features.
placed by an ordinary instance, so pose choices and transforms have their
existing instance scope. Features name local nodes in that subject's symbol;
block descriptors still name globally distinct features.
Subjects share a source frame space. :head and :anchors may be overridden
per subject; all other freeze settings come from params."
@ -697,7 +697,7 @@
(throw (ex-info "a freeze needs subjects with ids distinct from :main, :root and :face" {})))
(let [ordered (sort-by (comp str key) subjects)
parts (mapv (fn [[id inputs]] [id (subject-part params id inputs)]) ordered)
lengths (distinct (map #(get-in % [1 :timeline :frames]) parts))
lengths (distinct (map #(get-in % [1 :symbol :frames]) parts))
_ (when-not (and (= 1 (count lengths)) (pos? (first lengths)))
(throw (ex-info "subjects need the same positive frame count"
{:frames (vec lengths)})))
@ -707,9 +707,9 @@
:width (first stage) :height (second stage)
:subjects (into {} (map (fn [[id _]] [id {:id id :params {}}])) ordered)
:features (merged :features) :groups (merged :groups)
:timelines
(into {clip/root-id
{:id clip/root-id :frames nf
:symbols
(into {:main
{:id :main :frames nf
:nodes (into {:root {:id :root :name "clip" :kind :group :z "a1"
:time {:mode :map :expose expose}}
:face {:id :face :name "source placement" :kind :group
@ -717,10 +717,10 @@
:channels (face-placement params subjects)}}
(map-indexed
(fn [i [id _]]
[id {:id id :kind :symbol :of id :parent :face
[id {:id id :kind :instance :of id :parent :face
:z (str "a" i)}]))
ordered)}}
(map (fn [[id part]] [id (:timeline part)])) parts)}]
(map (fn [[id part]] [id (:symbol part)])) parts)}]
(doseq [[subject inputs] ordered
[id track] (:presence inputs)]
(when-not (and (= nf (count track))

View file

@ -39,7 +39,7 @@
:when (:generated channel)]
[id prop channel])]
(-> (reduce (fn [entry [id prop channel]]
(let [at [:clip :timelines (get-in entry [:clip :features fid :timeline])
(let [at [:clip :symbols (get-in entry [:clip :features fid :symbol])
:nodes id :channels prop]
old (get-in entry at)]
(assoc-in entry at
@ -103,7 +103,7 @@
by hand."
[entry params base subject]
(let [baked (freeze/head-part subject params @base)
at [:clip :timelines subject :nodes :head]
at [:clip :symbols subject :nodes :head]
old (get-in entry at)
measured (:measured baked)]
(cond-> (-> entry

View file

@ -4,8 +4,8 @@
Everything here returns a promise of a PARSED JS VALUE, not of CLJS data, and
that is deliberate: a leaf is transit, and `domain/project` reads it straight out
of the response object. A keywordising `js->clj` on the way past would turn the
leaf path \"clip/c1/timeline/main/node/mouth\" into a keyword whose name is
\"c1/timeline/main/node/mouth\",
leaf path \"clip/c1/symbol/main/node/mouth\" into a keyword whose name is
\"c1/symbol/main/node/mouth\",
losing the prefix — a corruption that only shows up on the way back in.
CSRF IS NOT EXEMPTED. The page renders `{% csrf_token %}`, so Django sets its

View file

@ -11,7 +11,6 @@
(rf/reg-sub ::muted? (fn [db _] (get-in db [:playback :muted?])))
(rf/reg-sub ::fps (fn [db _] (get-in db [:clip :fps])))
(rf/reg-sub ::display-fps (fn [db _] (get-in db [:clip :display-fps])))
(rf/reg-sub ::frames (fn [db _] (get-in db [:clip :frames])))
;; The stage, in pixels. On the clip because project dimensions are independent
;; of the footage — see flow/freeze/face-placement — so the canvas and the raster
;; take their size from the document rather than from a constant.

View file

@ -9,7 +9,6 @@
playhead is not an input, so moving it cannot invalidate this."
(:require [arthur.domain.clip :as clip]
[arthur.domain.palette :as pal]
[arthur.domain.timeline :as timeline]
[arthur.footage.store :as footage]
[arthur.subs.playback :as playback]
[re-frame.core :as rf]))
@ -23,27 +22,38 @@
:<- [::paint-revision]
(fn [[id _] _] (:clip (footage/entry id))))
(rf/reg-sub ::open (fn [db _] (get-in db [:ui :open])))
(rf/reg-sub
::timeline
::symbol
:<- [::clip]
(fn [clip _] (some-> clip clip/root)))
:<- [::open]
(fn [[clip sid] _] (some-> clip (clip/symbol sid))))
(rf/reg-sub
::frames
:<- [::symbol]
(fn [sym _]
;; The open symbol's length, read off it rather than copied into the db, so a
;; drop that lengthens it lengthens the transport with it.
(:frames sym)))
(rf/reg-sub
::exposure
:<- [::timeline]
(fn [tl _]
;; Exposure lives on the timeline's root node and is INHERITED, so reading it
:<- [::symbol]
(fn [sym _]
;; Exposure lives on the symbol's root node and is INHERITED, so reading it
;; there is reading it everywhere. The transport shows it so that `exposure 2`
;; is visibly doing something at the transport rather than only inside the
;; document.
(or (get-in tl [:nodes :root :time :expose]) 1)))
(or (get-in sym [:nodes :root :time :expose]) 1)))
(rf/reg-sub
::palette
(fn [db _]
;; A NAME resolves to a ramp. One today; when timelines carry a `:palette`
;; channel this becomes the project's table and the walk carries the ramp in
;; scope, which is why domain/timeline takes the palette as a parameter rather
;; scope, which is why domain/symbol takes the palette as a parameter rather
;; than reaching for a global.
(get {:arthur/default pal/index-of} (:palette db) pal/index-of)))
@ -68,11 +78,10 @@
(rf/reg-sub
::resolver
:<- [::clip]
:<- [::timeline]
:<- [::open]
:<- [::store]
:<- [::palette]
:<- [::playback/display-fps]
(fn [[document tl store palette picture-fps] _]
(when tl (clip/resolver (assoc-in document [:timelines :main] tl)
store palette clip/root-id
{:picture-fps picture-fps}))))
(fn [[document sid store palette picture-fps] _]
(when (and document (clip/symbol document sid))
(clip/resolver document store palette sid {:picture-fps picture-fps}))))

View file

@ -23,6 +23,6 @@
;; the node alone because every caller that wants the node also wants to know
;; where it lives — an edit names the timeline, and a bare node has forgotten.
(when (and clip (= :node (first selection)))
(let [[_ tid id] selection]
(when-let [n (get-in clip [:timelines tid :nodes id])]
[tid id n])))))
(let [[_ sid id] selection]
(when-let [n (get-in clip [:symbols sid :nodes id])]
[sid id n])))))

View file

@ -2,7 +2,7 @@
"The right pane: what the selection is, and what can be changed about it.
Sections rather than a mode switch. The clip's facts are always true, so the
clip section is always there; the node and timeline sections appear when
clip section is always there; the node and symbol sections appear when
something of that kind is selected; the tracking section appears when the clip
has analysis in it. Nothing here computes — every control dispatches an intent
and every readout comes off a subscription."
@ -45,12 +45,14 @@
(let [clip @(rf/subscribe [::render/clip])
fps @(rf/subscribe [::playback/fps])
picture @(rf/subscribe [::playback/display-fps])
frames @(rf/subscribe [::playback/frames])
open @(rf/subscribe [::render/open])
frames @(rf/subscribe [::render/frames])
expose @(rf/subscribe [::render/exposure])]
[section "clip"
[facts
"name" (:name clip)
"stage" (str (:width clip) "×" (:height clip))
"open" (some-> open name)
"length" (str frames " frames")
"source" (str fps " fps")
"expose" (str "on " expose "s")]
@ -84,7 +86,7 @@
gap after the current one does. Lifted out of the old stage toolbar unchanged —
a drawing key is a parameter of the shape, and this is where the shape's
parameters are."
[id n frame]
[sid id n frame]
(let [geom (get-in n [:channels paint/geometry])
active (when geom (paint/active-frame geom frame))
ks (when geom (sort (keys (:keys geom))))
@ -94,7 +96,7 @@
[:div.row {:style {:margin "5px 0"}}
[:button {:disabled (or (< frame start) (>= frame end)
(contains? (:keys geom) frame))
:on-click #(rf/dispatch [::paint-events/add-key id])}
:on-click #(rf/dispatch [::paint-events/add-key sid id])}
"drawing key here"]]
(when (seq ks)
[:div.row
@ -110,22 +112,22 @@
[:label.dim (str "key " active " → " next-k " ")
[:select {:value (name (or (channel/segment-interp geom active) :hold))
:on-change #(rf/dispatch [::paint-events/set-segment-interp
id active (keyword (.. % -target -value))])}
sid id active (keyword (.. % -target -value))])}
[:option {:value "hold"} "hold"]
[:option {:value "linear"} "tween"]]]])]))
(defn- node-section [[tid id n]]
(defn- node-section [[sid id n]]
(let [frame @(rf/subscribe [::playback/frame])
[start end] (:span n)]
[section (str (name (:kind n)) (when (not= :main tid) (str " · " (name tid))))
[section (str (name (:kind n)) " · in " (name sid))
[facts
"name" (or (:name n) (brief id))
"id" (brief id)
;; Which library timeline a placement plays. The one fact that makes a
;; symbol instance legible as an instance rather than as a node.
"of" (when (= :symbol (:kind n)) (str (:of n)))
;; Which symbol an instance places. The one fact that makes an instance
;; legible as an instance rather than as a node.
"of" (when (= :instance (:kind n)) (str (:of n)))
"span" (when start (str start " … " end))]
(when (:paint? n) [drawing-keys id n frame])
(when (:paint? n) [drawing-keys sid id n frame])
[:div.row {:style {:margin-top "6px"}} [:span.dim "channels"]]
[:dl.facts
(doall
@ -136,16 +138,15 @@
[:dd (channel-state ch)]]))]]))
;; ---------------------------------------------------------------------------
;; a timeline
;; a symbol
(defn- timeline-section [tid]
(let [tl @(rf/subscribe [::render/clip])
tl (get-in tl [:timelines tid])]
[section "timeline"
(defn- symbol-section [sid]
(let [sym (get-in @(rf/subscribe [::render/clip]) [:symbols sid])]
[section "symbol"
[facts
"id" (str tid)
"length" (str (:frames tl) " frames")
"nodes" (str (count (:nodes tl)))]]))
"id" (str sid)
"length" (str (:frames sym) " frames")
"nodes" (str (count (:nodes sym)))]]))
;; ---------------------------------------------------------------------------
;; tracked objects
@ -245,5 +246,5 @@
[:div {:style {:min-height 0}}
[clip-section]
(when node [node-section node])
(when (= :timeline (first selection)) [timeline-section (second selection)])
(when (= :symbol (first selection)) [symbol-section (second selection)])
(when tracked? [tracking-section])]]))

View file

@ -106,7 +106,7 @@
:palette @(rf/subscribe [::render/palette])
:ramp @(rf/subscribe [::render/ramp])
:fps @(rf/subscribe [::sub/fps])
:frames @(rf/subscribe [::sub/frames])
:frames @(rf/subscribe [::render/frames])
:width @(rf/subscribe [::sub/width])
:height @(rf/subscribe [::sub/height])
:frame @(rf/subscribe [::sub/frame])

View file

@ -8,14 +8,14 @@
the confusion this pane should be ending.
A SYMBOL is one timeline out of the open document's library — the thing a
`:kind :symbol` node is an instance of. Dragging one onto the stage places an
`:kind :instance` node is an instance of. Dragging one onto the stage places an
instance. Open a different project and this is a different list, because a
library belongs to the document that holds it.
FOOTAGE is source media on the server: uploads that have been probed,
transcoded and cut into frames. Running one through detection produces a
document whose roto IS a symbol — `flow/freeze/clip` gives each tracked subject
its own library timeline and places it on `:main` with one instance — so
its own symbol and places one instance of each in `:main` — so
footage becomes a row in the group above it by way of being detected.
THE WHOLE PANE IS THE DROP TARGET. A video dropped anywhere in it uploads and
@ -53,25 +53,25 @@
(defn- symbols []
(let [clip @(rf/subscribe [::render/clip])
selection @(rf/subscribe [::sub/selection])
library (sort-by str (remove #{:main} (keys (:timelines clip))))]
library (sort-by str (keys (:symbols clip)))]
(group "symbols"
[:div.dim "timelines in this document"]
[:div.dim "in this document"]
(if (empty? library)
[:div.dim "none yet"]
(doall
(for [tid library
:let [tl (get-in clip [:timelines tid])]]
^{:key tid}
[item {:label (name tid)
:sub (str (:frames tl) "f · " (count (:nodes tl)) " nodes")
:on? (= selection [:timeline tid])
(for [sid library
:let [sym (get-in clip [:symbols sid])]]
^{:key sid}
[item {:label (name sid)
:sub (str (:frames sym) "f · " (count (:nodes sym)) " nodes")
:on? (= selection [:symbol sid])
:draggable true
:on-drag-start
(fn [^js event]
(.setData (.-dataTransfer event) "text/plain"
(str "symbol:" (subs (str tid) 1)))
(str "symbol:" (subs (str sid) 1)))
(set! (.. event -dataTransfer -effectAllowed) "copy"))
:on-click #(rf/dispatch [::ui/select [:timeline tid]])}])))
:on-click #(rf/dispatch [::ui/select [:symbol sid]])}])))
(when (seq library)
[:div.dim "drag onto the stage to place"]))))

View file

@ -47,15 +47,17 @@
(defonce ^:private dragging (atom nil))
(defn- editing
"The selected node when it is a polygon on the root timeline, as
`[id node geom active-key editable?]`. Nothing else is vertex-editable yet."
[clip frame]
(let [[tid id n] @(rf/subscribe [::sub/selected-node])]
(when (and (= :main tid) (:paint? n))
"The selected node when it is a polygon in the open symbol, as
`[sid id node geom active-key editable?]`. Nothing else is vertex-editable yet:
a shape inside an instance is drawn through that instance's transform, and
handles in the open symbol's space would be in the wrong place."
[frame]
(let [[sid id n] @(rf/subscribe [::sub/selected-node])]
(when (and (= @(rf/subscribe [::render/open]) sid) (:paint? n))
(let [geom (get-in n [:channels paint/geometry])
active (when geom (paint/active-frame geom frame))
[start end] (:span n)]
[id n geom active
[sid id n geom active
;; A frame between two drawing keys with a tween running has no vertices
;; of its own to move: what is on screen there is interpolated, and
;; dragging it would silently edit the key behind it instead.
@ -69,7 +71,7 @@
tool @(rf/subscribe [::sub/tool])
draft @(rf/subscribe [::sub/draft])
drawing? (= :polygon tool)
[id _ geom active editable?] (editing clip frame)
[sid id _ geom active editable?] (editing frame)
pts (when geom (channel/value-at geom frame))]
[:svg {:class (str "paint-overlay" (when drawing? " drawing"))
:width (* zoom w) :height (* zoom h)
@ -79,9 +81,9 @@
(let [[x y] (stage-point event w h)]
(rf/dispatch [::ui/add-draft-point x y]))))
:on-pointer-move (fn [event]
(when-let [[node key-frame vertex] @dragging]
(when-let [[sid node key-frame vertex] @dragging]
(rf/dispatch [::paint-events/set-vertex
node key-frame vertex
sid node key-frame vertex
(stage-point event w h)])))
:on-pointer-up (fn [_] (reset! dragging nil))
:on-pointer-cancel (fn [_] (reset! dragging nil))}
@ -104,10 +106,10 @@
(.preventDefault event)
(.setPointerCapture (.-currentTarget event)
(.-pointerId event))
(reset! dragging [id active i]))}])))])]))
(reset! dragging [sid id active i]))}])))])]))
(defn- dropped-symbol
"The library timeline a drag out of the media pool is carrying, or nil.
"The symbol a drag out of the media pool is carrying, or nil.
`text/plain` with a prefix rather than a custom MIME type: the payload is one
short string, every browser agrees about this type, and a drag that arrives
@ -130,8 +132,8 @@
(.preventDefault event)))
:on-drop (fn [^js event]
(.preventDefault event)
(when-let [tid (dropped-symbol event)]
(rf/dispatch [::ui/place-symbol tid (stage-point event w h)])))}
(when-let [sid (dropped-symbol event)]
(rf/dispatch [::ui/place-symbol sid (stage-point event w h)])))}
[:canvas.stage {:ref #(player/set-canvas! %)
:width w :height h
:style {:width (str (* zoom w) "px")

View file

@ -1,22 +1,24 @@
(ns arthur.ui.timeline
"The bottom pane: the transport, a ruler, and a row per node.
"The bottom pane: the transport, a ruler, and a row per node of the open symbol.
`rows` is the whole of the interesting part and it is a PURE function of the
clip and the set of open paths. It flattens the document's two axes of nesting
— parent/child within a timeline, and instance into a library timeline — into
clip, the open symbol and the set of open paths. It flattens the document's two
axes of nesting — parent/child within a symbol, and instance of another
symbol — into
one depth-tagged list, which is what lets the labels column and the tracks
column render from the same vector and therefore stay aligned without measuring
anything.
EVERY FRAME NUMBER A ROW CARRIES IS IN ROOT FRAME SPACE. A symbol's keys are
its own timeline's, and drawing them against the stage's ruler unmapped would
EVERY FRAME NUMBER A ROW CARRIES IS IN THE OPEN SYMBOL'S FRAME SPACE. An
instance's keys are in its own symbol's, and drawing them against the ruler
unmapped would
put a key under the wrong frame — silently, and most convincingly when the
instance starts at 0. So the walk carries a `->root` function and composes one
instance starts at 0. So the walk carries a `->open` function and composes one
more mapping into it at each instance. The mapping is the inverse of
`node/local-frame`: `local = in + rate·(parent - at)`, so
`parent = at + (local - in)/rate`.
WHAT IT DOES NOT DO YET: a looping instance repeats its timeline, and only the
WHAT IT DOES NOT DO YET: a looping instance repeats its symbol, and only the
first pass is drawn. An expanded loop therefore shows keys where they first
happen and not where they happen again."
(:require [clojure.string :as str]
@ -35,7 +37,7 @@
(defn- local->parent
"The inverse of `node/local-frame`: where a frame of this node's OWN time sits
on the timeline it lives in.
in the symbol it lives in.
Two frame spaces meet at every node and mixing them up is the bug this exists
to prevent. A node's `:span` is checked against the frame its PARENT hands it,
@ -52,7 +54,7 @@
identity
(fn [f]
(let [f (- f (or offset 0))]
(if (and (#{:symbol :audio} (:kind n)) (not (zero? rate)))
(if (and (#{:instance :audio} (:kind n)) (not (zero? rate)))
(js/Math.round (+ at (/ (- f in) rate)))
f))))))
@ -69,7 +71,7 @@
(or (:name n)
(if (keyword? id) (subs (str id) 1) (subs (str id) 0 8))))
(defn- channel-rows [n path depth ->root span]
(defn- channel-rows [n path depth ->open span]
(for [[cpath ch] (sort-by (comp str key) (node/channels n))]
{:path (conj path cpath)
:depth depth
@ -77,15 +79,16 @@
:kind :channel
:select nil
:span (when (or (:dense ch) (seq (:keys ch))) span)
:keys (mapv ->root (keyed-frames ch))
:keys (mapv ->open (keyed-frames ch))
:dense? (boolean (:dense ch))}))
(defn rows
"The visible rows, outermost first. `expanded` is a set of row paths."
[clip expanded]
(letfn [(walk [tid path depth ->root]
(let [tl (get-in clip [:timelines tid])
ordered (->> (:nodes tl)
"The visible rows of symbol `sid`, outermost first. `expanded` is a set of row
paths."
[clip sid expanded]
(letfn [(walk [sid path depth ->open]
(let [sym (get-in clip [:symbols sid])
ordered (->> (:nodes sym)
;; Front-most at the top, as a layer list is drawn
;; everywhere. `:z` is the lexicographic draw key;
;; the id breaks ties so the order is stable.
@ -99,18 +102,18 @@
channels (node/channels n)
;; The span is in the PARENT's space and the channels
;; are in the node's own, so they take different
;; mappings. `self` is also what the symbol's target
;; timeline is resolved in — `clip/resolver` roots the
;; mappings. `self` is also what the instance's
;; symbol is resolved in — `clip/resolver` roots the
;; child at this node's local frame — so the nested
;; walk carries it down unchanged.
self (comp ->root (local->parent n))
span (mapv ->root (or (:span n) [0 (:frames tl)]))
self (comp ->open (local->parent n))
span (mapv ->open (or (:span n) [0 (:frames sym)]))
row {:path rpath
:depth depth
:label (node-label id n)
:kind :node
:node-kind (:kind n)
:select [:node tid id]
:select [:node sid id]
:expandable? true
:expanded? open?
:span span
@ -123,11 +126,11 @@
[row]
(-> [row]
(into (channel-rows n rpath (inc depth) self span))
(into (when (= :symbol (:kind n))
(into (when (= :instance (:kind n))
(walk (:of n) rpath (inc depth) self)))))))
ordered))))]
(if (get-in clip [:timelines :main])
(walk :main [] 0 identity)
(if (get-in clip [:symbols sid])
(walk sid [] 0 identity)
[])))
;; ---------------------------------------------------------------------------
@ -153,7 +156,7 @@
(let [playing? @(rf/subscribe [::playback/playing?])
rate @(rf/subscribe [::playback/rate])
frame @(rf/subscribe [::playback/frame])
frames @(rf/subscribe [::playback/frames])
frames @(rf/subscribe [::render/frames])
{:keys [fps drop]} @player/meter]
[:div.pane-head
[:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")]
@ -214,11 +217,11 @@
(defn view []
(r/with-let [scrubbing (r/atom false)]
(let [clip @(rf/subscribe [::render/clip])
frames (max 1 (or @(rf/subscribe [::playback/frames]) 1))
frames (max 1 (or @(rf/subscribe [::render/frames]) 1))
frame @(rf/subscribe [::playback/frame])
selection @(rf/subscribe [::sub/selection])
expanded @(rf/subscribe [::sub/expanded])
visible (rows clip expanded)
visible (rows clip @(rf/subscribe [::render/open]) expanded)
;; Roughly ten labels, on a round number of frames.
step (* 10 (js/Math.ceil (/ frames 100)))]
[:section.pane.time
@ -262,5 +265,5 @@
(if (seq visible)
(doall (for [row visible]
^{:key (str (:path row))} [track-cell row frames]))
[:div.tl-empty "nothing on this timeline"])
[:div.tl-empty "nothing in this symbol"])
[:div.tl-playhead {:style {:left (at% frame frames)}}]]]])))

View file

@ -12,17 +12,17 @@
[re-frame.core :as rf]))
(defn- exporter []
(let [{:keys [timeline zoom isolate busy? done total]} @(rf/subscribe [::export/state])
(let [{:keys [target zoom busy? done total]} @(rf/subscribe [::export/state])
targets @(rf/subscribe [::export/targets])]
[:<>
;; ONE SELECT over everything exportable: the clip, each symbol in its
;; library, and each placement on the stage. They are one list because they
;; ONE SELECT over everything exportable: each symbol, and each instance
;; in the open one. They are one list because they
;; are one kind of request — render this, alone.
;;
;; The option VALUE goes through `export/target-value`, never `name`: a
;; symbol timeline is `:sym/face-8625` and a placement is a uuid, and writing
;; symbol id is `:sym/face-8625` and an instance is a uuid, and writing
;; either through `name` loses what identifies it.
[:select {:value (export/target-value {:timeline (or timeline :main) :isolate isolate})
[:select {:value (export/target-value target)
:disabled busy?
:title "what to render"
:on-change #(rf/dispatch [::export/set-target