Split clips from timelines

This commit is contained in:
Olive Vaughn 2026-09-28 02:33:26 -04:00
parent 27bfe18bee
commit 9778b9023b
31 changed files with 794 additions and 444 deletions

View file

@ -6,34 +6,37 @@
affordable over authored data, and cheap writes, since every edit `assoc`es
into this map and every mounted layer-2 sub compares the result.
So the scene is here (it is a document — a human placed every node) and dense
So the clip is here (it is a document — a human placed every node) and dense
channel blocks are not; they live behind a handle in `store`. The hand-written
demo scene has no dense blocks and its store is empty; the swarm and the take
demo clip has no dense blocks and its store is empty; the swarm and the take
are entirely dense."
(:require [arthur.demo :as demo]
[arthur.domain.clip :as domain-clip]
[arthur.demo.swarm :as swarm]
[arthur.demo.take :as take]))
(defn- clip
"A scene plus the clip-level facts the transport and the stage need.
(defn- entry
"A clip plus what the transport and the stage read off it.
Read OFF the scene rather than written again beside it. `:fps`, `:frames` and the
stage dimensions belong to the CLIP and not to the timeline — a timeline has a
frame space, not a rate and not a size — and they sit on the scene map only
because there is one clip per scene today. Copying them by hand into this table
is how one of them comes to disagree with the scene it describes."
[label-key label scene store]
(merge {:label label :scene scene :store store
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."
[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`.
;; That file is `extract.sh`'s output — tier 3, which the backend now
;; serves by hash — and the synthetic take needs a sound of its own so
;; that the clock has something to run against with no footage ingested.
:audio "/static/arthur/audio.wav"
:cid (name label-key)
:display-fps (:fps scene)}
(select-keys scene [:fps :frames :width :height])))
:display-fps (:fps clip)
:frames (domain-clip/frames clip)}
(select-keys clip [:fps :width :height])))
(def scenes
(def clips
"The hand-made clips, selectable from the transport.
`:swarm` is the load test: a hundred and twenty nodes, entirely dense. The two
@ -41,14 +44,14 @@
`:head` written as a dense track in one and as framed identity in the other, so
the button that switches between them switches a document field and nothing
else."
{:demo (clip :demo "demo" demo/scene nil)
:swarm (clip :swarm "swarm" @swarm/scene @swarm/store)
:take (clip :take "take" @take/scene @take/store)
:take-locked (clip :take-locked "locked" @take/locked @take/store)})
{:demo (entry :demo "demo" demo/clip nil)
:swarm (entry :swarm "swarm" @swarm/clip @swarm/store)
:take (entry :take "take" @take/clip @take/store)
:take-locked (entry :take-locked "locked" @take/locked @take/store)})
(def default
{;; --- the document ---
:scene/current :take
:clip/current :take
:palette :arthur/default ; a NAME; the ramp itself is project data
;; --- the clip ---
@ -57,7 +60,7 @@
;; footage's. That is what deleting `makeXform` buys — the framing became a
;; 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 (select-keys (:take scenes) [:fps :frames :width :height :audio :display-fps])
:clip (select-keys (:take clips) [:fps :frames :width :height :audio :display-fps])
;; Which ingested footage to detect, and what the last load said. The list
;; comes from the server — tier 3 is the backend's since step 9 — so there is

View file

@ -1,23 +1,28 @@
(ns arthur.demo
"The hand-written scene from port-plan step 2, and nothing else.
"The hand-written clip from port-plan step 2, and nothing else.
The EDN is a resource rather than a literal in this file so that the test and
the page read the SAME bytes. If the scene were written twice, the one the test
validates would not be the one that renders, and the model would be validated
against a scene nobody ever looked at."
(:require [arthur.domain.scene :as scene]
(:require [arthur.domain.clip :as domain-clip]
[arthur.domain.timeline :as timeline]
[cljs.reader :as reader]
[shadow.resource :as rc]))
(def source (rc/inline "arthur/demo/scene.edn"))
(def scene (reader/read-string source))
(def clip (reader/read-string source))
(def fps (:fps scene))
(def frames (:frames scene))
(def timeline
"The clip's root timeline: what an evaluator takes. `clip` is the document."
(domain-clip/root clip))
(def fps (:fps clip))
(def frames (domain-clip/frames clip))
(defn ops-at
"Draw ops for one frame, via the specification path. The page uses
`scene/resolver` instead; this is here for the REPL."
`timeline/resolver` instead; this is here for the REPL."
[f]
(scene/eval-frame scene f))
(timeline/eval-frame timeline f))

View file

@ -24,7 +24,6 @@
;; 229 frames at 30fps is 7.63s, which covers audio.wav (7.601s) with a frame to
;; spare. fps belongs to the CLIP rather than to the timeline — a timeline has a
;; frame space, not a rate — and it is here only because there is one clip.
:frames 229
:fps 30
;; The STAGE, in pixels. The project's dimensions, not the footage's — which is
;; what makes `makeXform` deletable: placement is a transform on a node and the
@ -33,7 +32,11 @@
:width 320
:height 200
:nodes
:timelines
{:main
{:id :main
:frames 229
:nodes
{;; The clip root. EXPOSURE LIVES HERE and is inherited, because
;; docs/design.md is emphatic that everything rides one grid: a head cutting on
;; odd frames against a mouth cutting on even ones reads as two performances.
@ -96,4 +99,4 @@
{:id :pupil :name "pupil" :kind :rect :parent :iris :stencil :iris :z "a3"
:channels
{[:geom :size] {:animated? false :value 5.0}
[:style :color] {:animated? false :value :pupil}}}}}
[:style :color] {:animated? false :value :pupil}}}}}}}

View file

@ -147,19 +147,22 @@
(def store
(delay (merge (orbit-blocks) (shape-blocks))))
(def scene
(def clip
(delay
{:name "swarm"
:frames frames
:fps fps
:width 320
:height 200
:nodes
(into {:root {:id :root :kind :group :parent nil :z "a1"
:timelines
{:main
{:id :main
:frames frames
:nodes
(into {:root {:id :root :kind :group :parent nil :z "a1"
;; On 2s, like everything else. A hundred and twenty shapes
;; cutting on one grid reads as animation; the same shapes on
;; their own grids read as a screensaver, which is the whole
;; argument for exposure inheriting strictly.
:time {:mode :map :expose 2}}}
(concat (map (juxt :id identity) (map orbit-node (range n-orbits)))
(map (juxt :id identity) (map shape-node (range n-shapes)))))}))
(concat (map (juxt :id identity) (map orbit-node (range n-orbits)))
(map (juxt :id identity) (map shape-node (range n-shapes)))))}}}))

View file

@ -12,7 +12,7 @@
│
FREEZE ──▶ channels on nodes
│
scene/resolver ──▶ raster
timeline/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
@ -21,7 +21,7 @@
anything that reads a source pixel, because that part takes the landmarks and
the frames and never the transform.
TWO SCENES, ONE STORE. `:take` carries the head as filmed and `:take-locked`
TWO CLIPS, ONE STORE. `:take` carries the head as filmed and `:take-locked`
carries it locked, and they are the same dense blocks with one node's channels
written two ways. That is the claim \"stabilisation is a channel, not a mode\"
made checkable by eye: switching between them is a document edit, tier 1, and
@ -91,7 +91,7 @@
(def store (delay (:store @frozen)))
(def scene (delay (:scene @frozen)))
(def clip (delay (:clip @frozen)))
(def locked
"The same blocks, with `:head` written as framed identity instead."

View file

@ -0,0 +1,110 @@
(ns arthur.domain.clip
"A CLIP: the unit of work, and a library of timelines.
{:name \"take\"
:fps 30
:width 320 :height 200
:analysis {...}
:subjects {...} :features {...} :groups {...}
:timelines {: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.
Now there is one node-holding type — `arthur.domain.timeline` — and a clip holds
a MAP of them. `:kind :symbol` is still unimplemented and this is the shape it
was waiting for: an instance names a timeline in `:timelines`, and the resolver
recurses into a type it already knows how to evaluate.
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.
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
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."
(:require [arthur.domain.feature :as feature]
[arthur.domain.timeline :as timeline]))
(def ^:const root-id
"The reserved id of the timeline a clip plays. See the namespace docstring."
:main)
(def clip-keys
"Every top-level field of a clip, and the reason `arthur.domain.leaf` refuses
one it does not know: a field added to the clip without a leaf to save it in is
a field that saves silently and comes back missing. The failure is a document
that loses something on every round trip, which is the one bug a persistence
layer must not be able to have. Add the field here and to `leaf/leaves` and
`leaf/clip` in the same commit."
#{:name :fps :analysis :subjects :features :groups :width :height :timelines})
(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 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."
[clip]
(:frames (root 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."
[clip]
(:nodes (root clip)))
(defn problems
"Human-readable reasons this clip will not evaluate or will not save. Empty
means it will.
The tracking identities are checked HERE and not in `domain/timeline`, because a
feature names nodes and only the root timeline's nodes were tracked into: a
library symbol is drawn, not detected. So `feature/problems` is asked about the
root, once, rather than about every timeline."
[clip]
(vec
(concat
(for [k (remove clip-keys (keys clip))]
(str "clip has a field with no leaf to save it in: " (pr-str k)))
(when-not (map? (: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 (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))
(when (map? (root clip))
(feature/problems clip (nodes clip))))))

View file

@ -1,22 +1,28 @@
(ns arthur.domain.feature
"Stable tracked identities and explicit eye-pair settings associations.
These maps are document data. Rendering only reads the nodes and channels."
These maps are the CLIP's — `:subjects`, `:features`, `:groups` — and not a
timeline's, because they describe what a camera saw and a library symbol is
drawn rather than detected. Rendering never reads them; it reads nodes and
channels. `problems` therefore takes the clip AND the node map to check
references against, rather than reaching for `(:nodes clip)`: a clip holds
several timelines and only the root one was tracked into."
(:require [arthur.domain.params :as params]))
(defn group-for [scene feature-id]
(defn group-for [clip feature-id]
(first (filter (fn [[_ group]] (some #{feature-id} (:members group)))
(:groups scene))))
(:groups clip))))
(defn effective-params
"Resolve static settings for one feature. A future parameter channel can
replace a scalar at this boundary without changing feature or pair identity."
[scene feature-id]
(let [{:keys [subject area params] :as feature} (get-in scene [:features feature-id])
[_ group] (group-for scene feature-id)]
[clip feature-id]
(let [{:keys [subject area params] :as feature} (get-in clip [:features feature-id])
[_ group] (group-for clip feature-id)]
(when-not feature
(throw (ex-info "unknown feature" {:feature feature-id})))
(merge (params/for-area :subject)
(get-in scene [:subjects subject :params])
(get-in clip [:subjects subject :params])
(params/for-area area)
(:params group)
params)))
@ -24,26 +30,30 @@
(defn remove-from-pair
"Keep the eye's current settings when its association is removed. Empty pairs
are removed; a one-eye pair remains valid and can acquire a partner later."
[scene feature-id]
(if-let [[group-id group] (group-for scene feature-id)]
(let [area (get-in scene [:features feature-id :area])
values (select-keys (effective-params scene feature-id)
[clip feature-id]
(if-let [[group-id group] (group-for clip feature-id)]
(let [area (get-in clip [:features feature-id :area])
values (select-keys (effective-params clip feature-id)
(keys (params/for-area area)))
members (vec (remove #{feature-id} (:members group)))]
(-> scene
(-> clip
(assoc-in [:features feature-id :params] values)
(update :groups (fn [groups]
(if (seq members)
(assoc-in groups [group-id :members] members)
(dissoc groups group-id))))))
scene))
clip))
(defn problems
"Check identity references and pair membership before storing a scene."
[scene]
(let [subjects (:subjects scene)
features (:features scene)
groups (:groups scene)
"Check identity references and pair membership before storing a clip.
`nodes` is the node map a feature's `:nodes` are resolved against — the root
timeline's, passed in rather than looked up, so this namespace does not have to
know which timeline a clip plays."
[clip nodes]
(let [subjects (:subjects clip)
features (:features clip)
groups (:groups clip)
memberships (mapcat (comp :members val) groups)
node-owners (mapcat (comp :nodes val) features)]
(vec
@ -63,7 +73,7 @@
: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 node-id (:nodes f)
:when (not (contains? (:nodes scene) node-id))]
:when (not (contains? 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)]
(str "node " (pr-str id) " belongs to more than one feature"))

View file

@ -8,15 +8,28 @@
once.
clip/<cid>/name a label
clip/<cid>/timing fps, frames
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>/feature/<fid> one feature: area, nodes, params
clip/<cid>/group/<gid> an eye pair and its shared params
clip/<cid>/node/<nid> one node: kind, parent, stencil, z, time
clip/<cid>/channel/<nid>/<prop> one channel
clip/<cid>/measured/<nid> the measured channels a re-freeze owns
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
WHY NODES SIT UNDER A TIMELINE. They did not until the clip and the timeline came
apart, and the flat `clip/<cid>/node/<nid>` was the persistence half of the same
conflation: it could only ever address the nodes of the one bag a clip had. A
library symbol is a timeline, an instance references one, and both need their
nodes addressed — so the timeline id is a segment, the root is `main`, and a
symbol's nodes are reachable by the same path shape as the clip's own. Adding it
later would have meant rewriting every stored path.
`:frames` MOVED OFF `timing` onto the timeline. A timeline 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.
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
@ -43,7 +56,9 @@
— docs/architecture.md draws one as `:eye-r/iris` — is written `eye-r~iris`.
`~` is then refused inside a name, which is the whole of the escaping and is why
it is one character rather than a scheme."
(:require [arthur.domain.sha256 :as sha]
(:require [arthur.domain.clip :as clip]
[arthur.domain.sha256 :as sha]
[arthur.domain.timeline :as timeline]
[clojure.string :as str]))
;; ---------------------------------------------------------------------------
@ -80,69 +95,89 @@
;; ---------------------------------------------------------------------------
;; the split
(def scene-keys
"Every top-level field of a scene, and the reason `leaves` refuses one it does
not know: a field added to the scene without a leaf is a field that saves
silently and comes back missing. The failure is a document that loses something
on every round trip, which is the one bug a persistence layer must not be able
to have. Add the field here and to `leaves` and `scene` in the same commit."
#{:name :frames :fps :analysis :subjects :features :groups :width :height :nodes})
(def ^:private node-channel-keys #{:channels :measured})
(defn leaves
"One clip's scene -> path -> value.
"One clip -> path -> value.
A leaf whose value would be empty is OMITTED rather than written as `{}`, and
that is what makes the round trip exact: the demo scene has no `:fps` and the
root node has no `:channels`, and a codec that invented them would hand back a
scene that is not `=` to the one it was given."
[cid scene]
(let [unknown (remove scene-keys (keys scene))]
that is what makes the round trip exact: the demo clip has no `:fps` and its root
node has no `:channels`, and a codec that invented them would hand back a clip
that is not `=` to the one it was given."
[cid clip]
(let [unknown (remove clip/clip-keys (keys clip))]
(when (seq unknown)
(throw (ex-info "the scene has a field with no leaf to save it in; see arthur.domain.leaf/scene-keys"
(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))]
(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))})))))
(let [at (fn [& parts] (str/join "/" (into ["clip" (segment cid)] parts)))
some-leaf (fn [path v] (when (seq v) {path v}))]
(apply merge
(some-leaf (at "name") (select-keys scene [:name]))
(some-leaf (at "timing") (select-keys scene [:fps :frames]))
(some-leaf (at "stage") (select-keys scene [:width :height]))
(some-leaf (at "source") (:analysis scene))
(some-leaf (at "name") (select-keys clip [:name]))
(some-leaf (at "timing") (select-keys clip [:fps]))
(some-leaf (at "stage") (select-keys clip [:width :height]))
(some-leaf (at "source") (:analysis clip))
(concat
(for [[id v] (:subjects scene)] {(at "subject" (segment id)) v})
(for [[id v] (:features scene)] {(at "feature" (segment id)) v})
(for [[id v] (:groups scene)] {(at "group" (segment id)) v})
(for [[id n] (:nodes scene)] {(at "node" (segment id))
(apply dissoc n node-channel-keys)})
(for [[id n] (:nodes scene)
:when (seq (:measured n))] {(at "measured" (segment id)) (:measured n)})
(for [[id n] (:nodes scene)
(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
;; 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))
(apply dissoc n node-channel-keys)})
(for [[tid tl] (:timelines clip)
[id n] (:nodes tl)
:when (seq (:measured n))]
{(at "timeline" (segment tid) "measured" (segment id)) (:measured n)})
(for [[tid tl] (:timelines clip)
[id n] (:nodes tl)
[prop ch] (:channels n)]
{(at "channel" (segment id) (prop->path prop)) ch})))))
{(at "timeline" (segment tid) "channel" (segment id) (prop->path prop)) ch})))))
(defn scene
(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."
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
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]
(let [want (segment cid)]
(reduce
(fn [acc [path v]]
(let [[kind a b] (drop 2 (str/split path #"/"))]
(if-not (= want (second (str/split path #"/")))
(let [[_ found kind a b c] (str/split path #"/")]
(if-not (= want found)
acc
(case kind
"name" (merge acc v)
"timing" (merge acc v)
"stage" (merge acc v)
"source" (assoc acc :analysis v)
"subject" (assoc-in acc [:subjects (unsegment a)] v)
"feature" (assoc-in acc [:features (unsegment a)] v)
"group" (assoc-in acc [:groups (unsegment a)] v)
"node" (update-in acc [:nodes (unsegment a)] merge v)
"measured" (assoc-in acc [:nodes (unsegment a) :measured] v)
"channel" (assoc-in acc [:nodes (unsegment a) :channels (path->prop b)] v)
(throw (ex-info "not a leaf path" {:path path}))))))
(if (= "timeline" kind)
(let [tid (unsegment a)
acc (assoc-in acc [:timelines tid :id] tid)]
(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)
:channels (path->prop (nth (str/split path #"/") 6))]
v)
(throw (ex-info "not a leaf path" {:path path}))))
(case kind
"name" (merge acc v)
"timing" (merge acc v)
"stage" (merge acc v)
"source" (assoc acc :analysis v)
"subject" (assoc-in acc [:subjects (unsegment a)] v)
"feature" (assoc-in acc [:features (unsegment a)] v)
"group" (assoc-in acc [:groups (unsegment a)] v)
(throw (ex-info "not a leaf path" {:path path})))))))
{}
;; Sorted, so `node` lands before `channel` and `measured` under one id and
;; the node map is merged INTO rather than over. `update-in ... merge` makes
@ -159,25 +194,42 @@
on the machine that produced it, and the whole point of tier 2 being
content-addressed is that it does not have to travel with tier 1 to be found."
[leaves]
(let [nodes (into #{} (keep (fn [path]
(let [[_ cid kind id] (str/split path #"/")]
(when (= "node" kind) [cid id]))))
(keys 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.
nodes (into #{} (keep (fn [[_ p]]
(when (and (= 6 (count p)) (= "timeline" (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))
(case (count p)
4 true ; the timeline itself
6 (#{"node" "measured"} (nth p 4))
7 (= "channel" (nth p 4))
false)
(case (count p)
;; The clip's own facts carry no id.
3 (#{"name" "timing" "stage" "source"} (nth p 2))
4 (#{"subject" "feature" "group"} (nth p 2))
false))))]
(vec
(concat
(for [[path v] (sort-by key leaves)
:let [[root cid kind id prop] (str/split path #"/")]
:when (or (not= "clip" root) (nil? cid)
(not (#{"name" "timing" "stage" "source" "subject" "feature"
"group" "node" "channel" "measured"} kind)))]
(for [[path p] (sort-by key parts)
:when (not (legal? p))]
(str (pr-str path) " is not a leaf path"))
(for [[path _] (sort-by key leaves)
:let [[_ cid kind id] (str/split path #"/")]
:when (and (#{"channel" "measured"} kind) (not (contains? nodes [cid id])))]
(for [[path p] (sort-by key parts)
:when (and (legal? p) (= "timeline" (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 v] (sort-by key leaves)
:let [[_ _ kind] (str/split path #"/")]
:when (and (= "channel" kind) (:dense v)
(not (sha/key? (:store (:dense v)))))]
(for [[path p] (sort-by key parts)
:let [v (get leaves path)]
:when (and (legal? p) (= "timeline" (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

@ -109,7 +109,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 nesting chain, outermost first, by scene/eval-frame. Two
Composed along the parent chain, outermost first, by timeline/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

View file

@ -1,12 +1,12 @@
(ns arthur.domain.project
"A clip <-> the document that travels. The tier split, as a pair of functions.
`save` takes what `flow/freeze` produced — `{:scene ... :store ...}` — and
`save` takes what `flow/freeze` produced — `{:clip ... :store ...}` — and
returns two things that are allowed on the wire for different reasons:
:leaves TIER 1. The document. Nodes, channels, subjects, features, groups,
time maps, the analysis record. Kilobytes, and every byte of it
authored or authorable.
:leaves TIER 1. The document. Timelines, nodes, channels, subjects,
features, groups, time maps, the analysis record. Kilobytes, and
every byte of it authored or authorable.
:blocks TIER 2. The dense blocks the document NAMES, each with the
descriptor its key is the hash of. Megabytes, content-addressed,
@ -57,11 +57,11 @@
\"clip/\" would be lost on the way back in.
Refuses a document `domain/leaf` calls unaddressable, which is where a hand-made
scene with placeholder store keys — `demo/swarm`'s \"swarm/pos\" — stops rather
clip with placeholder store keys — `demo/swarm`'s \"swarm/pos\" — stops rather
than being uploaded as a project that means something only on the machine that
made it."
[cid {:keys [scene store]}]
(let [leaves (leaf/leaves cid scene)
[cid {:keys [clip store]}]
(let [leaves (leaf/leaves cid clip)
ps (leaf/problems leaves)]
(when (seq ps)
(throw (ex-info (str "this clip cannot be saved: " (first ps))
@ -83,13 +83,13 @@
(block-keys leaves)))})))
(defn load
"The parsed response -> `{:scene :store}`, which is what `flow/freeze` returns
"The parsed response -> `{:clip :store}`, which is what `flow/freeze` returns
and therefore what the player already knows how to play."
[cid ^js doc]
(let [leaves (.-leaves doc)
tier1 (into {} (map (fn [path] [path (wire/decode-json (aget leaves path))]))
(js-keys leaves))]
{:scene (leaf/scene cid tier1)
{:clip (leaf/clip cid tier1)
:store (into {}
(map (fn [^js b]
[(.-key b)

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 scene/resolver hands the same
a node's vertex count is known at freeze time, so timeline/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,12 +1,38 @@
(ns arthur.domain.scene
"The scene: a flat map of id -> node, and the two ways to evaluate it at a
frame.
(ns arthur.domain.timeline
"A TIMELINE: an ordered bag of nodes in its own frame space, and the two ways to
evaluate it at a frame.
(eval-frame scene f store) THE SPECIFICATION. Allocating, order-free,
{: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.
The clip-level facts are in `arthur.domain.clip`. A timeline 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.
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 —
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,
obviously correct. Use it in tests and for a
one-off render.
(resolver scene store) -> (fn [f] ops). What playback uses. Caches the
(resolver tl 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
@ -16,8 +42,8 @@
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 scene-test asserts they
agree frame for frame in forward, backward and random order.
between them is exactly the part that can be wrong, and timeline-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:
ops carry palette indices and raster-space points, and the rasteriser knows
@ -28,7 +54,6 @@
vector of numbers, and they read the same way, which is what makes freezing
fill in the same channel rather than convert into a second format."
(:require [arthur.domain.channel :as ch]
[arthur.domain.feature :as feature]
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]))
@ -50,12 +75,12 @@
(when-let [p (:parent (get nodes i))]
(if (contains? nodes p)
p
(throw (ex-info "node's :parent is not in the scene"
(throw (ex-info "node's :parent is not in the timeline"
{: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 scene" {:node id :chain chain})))
(throw (ex-info "parent cycle in timeline" {:node id :chain chain})))
chain))
(defn depth
@ -103,9 +128,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 scene changes and never because the playhead moved — so
they change when the timeline 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 scene is, and a frame sorts small integers.
second. Here it is derived when the timeline 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."
@ -203,7 +228,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 scene.
instead of blanking the timeline.
Absence is not a boolean and is not an error: a subject that is not on the
frame has nothing to show."
@ -291,6 +316,23 @@
(throw (ex-info "node kind is not implemented"
{:node (:id n) :kind (:kind n)})))))
(defn- nodes-of
"The timeline'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
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)]
(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)))})))
nodes))
(defn- eval-into
"One frame, as a fold over the nodes in topological order.
@ -327,13 +369,17 @@
;; the specification
(defn eval-frame
"Scene at clip frame f -> draw ops in z order. Pure, and allocates freely.
"Timeline 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
the only place the space changes.
This is the definition of what a frame means. `resolver` is what plays it."
([scene f] (eval-frame scene f nil pal/index-of))
([scene f store] (eval-frame scene f store pal/index-of))
([scene f store palette]
(let [nodes (:nodes scene)
([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]
(let [nodes (nodes-of tl)
ord (order nodes)]
(eval-into {:read (fn [_id _path c lf] (ch/value-at c lf store))
:palette palette
@ -385,10 +431,10 @@
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."
([scene] (resolver scene nil pal/index-of))
([scene store] (resolver scene store pal/index-of))
([scene store palette]
(let [nodes (:nodes scene)
([tl] (resolver tl nil pal/index-of))
([tl store] (resolver tl store pal/index-of))
([tl store palette]
(let [nodes (nodes-of tl)
ord (order nodes)
rank (draw-rank nodes ord)
cursors (into {}
@ -427,14 +473,31 @@
;; ---------------------------------------------------------------------------
(def timeline-keys
"Every field a timeline 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 —
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 scene will not evaluate. Empty means it will.
"Human-readable reasons this timeline 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
layering nicety: a feature names nodes, and a library symbol's nodes are not
the ones a face was tracked into.
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."
[scene]
(let [nodes (:nodes scene)]
[tl]
(let [nodes (:nodes tl)]
(if-not (map? nodes)
[":nodes must be a map of id -> node"]
(-> []
@ -444,15 +507,19 @@
(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 scene")))
" which is not in the timeline")))
(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 scene")))
" which is not in the timeline")))
(into (for [[id n] nodes
p (node/problems n)]
(str "node " (pr-str id) ": " p)))
(into (feature/problems scene))
(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 (try
(doall (map #(depth nodes %) (keys nodes)))
nil

View file

@ -4,7 +4,8 @@
The frames come from the server by URL since step 9 — see `flow/ingest` — and the
detector's identity comes from the server too, because it goes into the content
address of every block this produces."
(:require [arthur.events.playback :as pb]
(:require [arthur.domain.clip :as clip]
[arthur.events.playback :as pb]
[arthur.flow.detect :as detect]
[arthur.flow.ingest :as ingest]
[arthur.flow.measure.interior :as interior]
@ -65,10 +66,11 @@
:dimensions dimensions :interior interior
:presence (:presence manifest)
:detector detector})
scene (:scene frozen)]
(assoc (select-keys scene [:fps :frames :width :height])
:display-fps (:fps scene)
:scene scene :store (:store frozen)
built (:clip frozen)]
(assoc (select-keys built [:fps :width :height])
:frames (clip/frames built)
:display-fps (:fps built)
:clip built :store (:store frozen)
;; No cache-buster. The audio is a blob named by the hash of its own
;; bytes, so re-extracting gives it a different URL rather than
;; overwriting this one — which is what the `?v=` here used to work
@ -156,7 +158,7 @@
(fn [{:keys [db]} [_ id summary]]
(let [clip (store/entry id)]
{:db (-> db
(assoc :scene/current id
(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))

View file

@ -94,14 +94,14 @@
{:db (assoc-in db [:playback :muted?] on?) ::mute! on?})))
(rf/reg-event-fx
::select-scene
::select-clip
(fn [{:keys [db]} [_ id]]
;; 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 :scene/current id)
(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.

View file

@ -21,7 +21,8 @@
Nothing here touches app-db except through events. The promise chain lives in an
fx, which is the only thing in this namespace that is not pure."
(:require [arthur.domain.project :as project]
(:require [arthur.domain.clip :as clip]
[arthur.domain.project :as project]
[arthur.events.playback :as pb]
[arthur.footage.store :as store]
[arthur.flow.address :as address]
@ -67,7 +68,7 @@
(rf/reg-fx
::save!
(fn [{:keys [id cid label clip]}]
(let [analysis (:analysis (:scene clip))
(let [analysis (:analysis (:clip clip))
doc (project/save cid clip)]
(-> (ensure-project! id label)
(.then (fn [pid]
@ -112,20 +113,21 @@
(into-array (map #(http/GET (str "/api/blocks/" %))
(array-seq (.-blocks clip-json)))))
(.then (fn [blocks]
(let [doc #js {:leaves (.-leaves clip-json) :blocks blocks}
cid (.-cid clip-json)
clip (project/load cid doc)
scene (:scene clip)
entry (merge
(select-keys scene [:fps :frames :width :height])
(let [doc #js {:leaves (.-leaves clip-json) :blocks blocks}
cid (.-cid clip-json)
loaded' (project/load cid doc)
built (:clip loaded')
entry (merge
(select-keys built [:fps :width :height])
{:label (str (or (.-name clip-json) cid) " (saved)")
:cid cid
:display-fps (:fps scene)
:scene scene
:store (:store clip)
:frames (clip/frames built)
:display-fps (:fps built)
:clip built
:store (:store loaded')
;; The audio is the clip's, and a
;; document does not carry it: tier 3
;; is by hash and the scene names the
;; is by hash and the clip names the
;; analysis, not the sound. Until the
;; footage id is in the document, the
;; synthetic take's is the one that
@ -146,7 +148,7 @@
(rf/reg-event-fx
::save
(fn [{:keys [db]} _]
(let [id (:scene/current db)
(let [id (:clip/current db)
clip (store/entry id)]
(if (or (:busy? (:project db)) (nil? clip))
{}
@ -179,7 +181,7 @@
(fn [{:keys [db]} [_ clip-id project-id name seq]]
(let [clip (store/entry clip-id)]
{:db (-> db
(assoc :scene/current clip-id
(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)

View file

@ -33,6 +33,7 @@
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.geom :as geom]
[arthur.domain.ring :as ring]
[arthur.flow.address :as address]))
@ -276,11 +277,11 @@
vectors. It may not store what `value-at` handed it: a wide dense read is a
VIEW into the block, and a view into tier 2 sitting in the document is a value
that changes when a re-freeze rewrites the array under it."
[{:keys [mode kept]} {:keys [scene store]}]
[{:keys [mode kept]} {:keys [clip store]}]
(when-not (contains? head-modes mode)
(throw (ex-info "head mode is not one of the three channel shapes"
{:mode mode :modes head-modes})))
(let [base (get-in scene [:nodes :head :measured])
(let [base (get-in (clip/root clip) [:nodes :head :measured])
at (fn [path f]
(let [v (ch/value-at (get base path) f store)
n (:stride (:dense (get base path)))]
@ -292,7 +293,7 @@
(when (and (= mode :per-plate) (empty? kept))
(throw (ex-info "the per-plate head mode needs a kept-frame set; it is the plate strip's, not measurement's"
{:mode mode})))
(assoc-in scene [:nodes :head :channels]
(assoc-in clip [:timelines clip/root-id :nodes :head :channels]
(case mode
;; No `:generated` on the locked shape, and that is not an
;; oversight: nothing generated this identity. It is a decision,
@ -655,8 +656,7 @@
roto (fn [by] (prov by {:verts verts :contour-avg contour-avg}))
features (when (and eyes brows) (feature-parts absent? obs params inputs))
interior (when teeth (interior-part params absent? obs teeth))
scene {:name name
:frames nf
built {:name name
:fps fps
;; Tier 1 says which analysis its channels came out of, in full.
;; The id alone would make the document unreadable the first time
@ -688,8 +688,16 @@
;; face-placement: nothing below here knows the frame size.
:width (first stage)
:height (second stage)
:nodes
(merge
;; ONE TIMELINE, and the frame count is ITS. A clip is a rate and
;; a timeline is a frame space — see arthur.domain.clip — so `nf`
;; lands here and `:fps` above, and the library a symbol will live
;; in is this same map with a second entry.
:timelines
{clip/root-id
{:id clip/root-id
:frames nf
:nodes
(merge
{:root
{:id :root :name "clip" :kind :group :parent nil :z "a1"
:time {:mode :map :expose expose}}
@ -721,7 +729,7 @@
[:vis] (visibility params inputs
(prov :roto/mouth-aperture
{:aperture-cut aperture-cut}))}}}
(:nodes features) (:nodes interior))}
(:nodes features) (:nodes interior))}}}
;; Tier 2, behind a handle, and now behind a content address: every key is
;; a sha256 over the analysis, the settings and the absence data that
;; produced the bytes under it. Nothing above this line changed when they
@ -729,8 +737,8 @@
store (merge (stored rings pos rot scale)
(:store features) (:store interior))]
(doseq [id (keys presence)]
(when-not (contains? (:features scene) id)
(throw (ex-info "presence track names no feature in this scene"
{:feature id :features (keys (:features scene))}))))
(when-not (contains? (:features built) id)
(throw (ex-info "presence track names no feature in this clip"
{:feature id :features (keys (:features built))}))))
{:store store
:scene (head-mode {:mode head :kept kept} {:scene scene :store store})}))
:clip (head-mode {:mode head :kept kept} {:clip built :store store})}))

View file

@ -3,9 +3,9 @@
Two things arrive this way and they are the same kind of thing: footage that has
been detected and frozen, and a project opened from the server. Both are
`{:scene ... :store ...}` — which is what `flow/freeze` returns — plus the clip
facts the transport needs, and both hold typed arrays that have no business being
in a map every mounted subscription compares."
`{:clip ... :store ...}` — which is what `flow/freeze` returns — plus what the
transport reads off the clip, and both hold typed arrays that have no business
being in a map every mounted subscription compares."
(:require [arthur.db :as db]))
(defonce ^:private loaded (atom nil))
@ -27,4 +27,4 @@
(defn entry [id]
(if (= id (:id @loaded))
@loaded
(get db/scenes id)))
(get db/clips id)))

View file

@ -7,47 +7,55 @@
CLOSURE that produces geometry at a frame. So a scene edit costs one
recomputation here and a frame costs a lookup and a blit — and, crucially, the
playhead is not an input, so moving it cannot invalidate this."
(:require [arthur.domain.palette :as pal]
[arthur.domain.scene :as scene]
(: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]))
(rf/reg-sub ::scene-id (fn [db _] (:scene/current db)))
(rf/reg-sub ::clip-id (fn [db _] (:clip/current db)))
(rf/reg-sub
::base-scene
:<- [::scene-id]
(fn [id _] (:scene (footage/entry id))))
::clip
:<- [::clip-id]
(fn [id _] (:clip (footage/entry id))))
(rf/reg-sub
::scene
:<- [::base-scene]
::timeline
:<- [::clip]
:<- [::playback/display-fps]
(fn [[scene picture-fps] _]
;; This changes only the scene's root time map. The dense source track stays
;; at its native rate, and the audio clock still advances through source time.
(if (and scene picture-fps (< picture-fps (:fps scene)))
(-> scene
(assoc-in [:nodes :root :time :source-fps] (:fps scene))
(assoc-in [:nodes :root :time :sample-fps] picture-fps))
scene)))
(fn [[clip picture-fps] _]
;; The ROOT timeline, with picture sampling written onto its root node's time
;; map. Only that node's time map changes: the dense source track stays at its
;; native rate and the audio clock still advances through source time.
;;
;; `:fps` is read off the CLIP and `:frames` off the timeline, which is the
;; whole reason the two came apart — the source cadence is a fact about how fast
;; the clip plays against its audio, and a nested timeline will not have one.
(when-let [tl (clip/root clip)]
(if (and picture-fps (< picture-fps (:fps clip)))
(-> tl
(assoc-in [:nodes :root :time :source-fps] (:fps clip))
(assoc-in [:nodes :root :time :sample-fps] picture-fps))
tl))))
(rf/reg-sub
::exposure
:<- [::scene]
(fn [scene _]
;; Exposure lives on the clip root 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 scene.
(or (get-in scene [:nodes :root :time :expose]) 1)))
:<- [::timeline]
(fn [tl _]
;; Exposure lives on the timeline'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)))
(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/scene takes the palette as a parameter rather
;; scope, which is why domain/timeline takes the palette as a parameter rather
;; than reaching for a global.
(get {:arthur/default pal/index-of} (:palette db) pal/index-of)))
@ -62,7 +70,7 @@
(rf/reg-sub
::store
:<- [::scene-id]
:<- [::clip-id]
(fn [id _]
;; Tier 2, behind a handle, and never in app-db itself — what is in the db is
;; the id of the clip whose blocks these are. The hand-written demo has none;
@ -71,8 +79,8 @@
(rf/reg-sub
::resolver
:<- [::scene]
:<- [::timeline]
:<- [::store]
:<- [::palette]
(fn [[scene store palette] _]
(scene/resolver scene store palette)))
(fn [[tl store palette] _]
(when tl (timeline/resolver tl store palette))))

View file

@ -10,7 +10,7 @@
canvas from an animation frame; it is a sink, not a view that re-renders, and
the actual content of the folklore about re-frame and canvas is that expensive
work must not live in a layer-2 sub. The resolver is a layer-3 sub over the
scene, so the playhead moving cannot invalidate it.
timeline, so the playhead moving cannot invalidate it.
The one dispatch is `::playback/tick`, and it is deliberately NOT what the
picture waits on: the frame is painted from the clock directly, and the tick

View file

@ -37,7 +37,7 @@
frames @(rf/subscribe [::sub/frames])
fps @(rf/subscribe [::sub/fps])
picture-fps @(rf/subscribe [::sub/display-fps])
current @(rf/subscribe [::render/scene-id])
current @(rf/subscribe [::render/clip-id])
expose @(rf/subscribe [::render/exposure])
{:keys [id label loading? status available chosen]} @(rf/subscribe [::sub/footage])
{project-name :name :keys [busy?] project-status :status
@ -55,14 +55,14 @@
:on-click #(rf/dispatch [::pb/toggle-mute])} "mute"]
[:span.gap]
(doall
(for [[id {:keys [label]}] db/scenes]
(for [[id {:keys [label]}] db/clips]
^{:key id}
[:button {:class (when (= id @(rf/subscribe [::render/scene-id])) "on")
:on-click #(rf/dispatch [::pb/select-scene id])}
[:button {:class (when (= id @(rf/subscribe [::render/clip-id])) "on")
:on-click #(rf/dispatch [::pb/select-clip id])}
label]))
(when id
[:button {:class (when (= id @(rf/subscribe [::render/scene-id])) "on")
:on-click #(rf/dispatch [::pb/select-scene id])}
[:button {:class (when (= id @(rf/subscribe [::render/clip-id])) "on")
:on-click #(rf/dispatch [::pb/select-clip id])}
(or label "footage")])
[:button {:disabled (or loading? (nil? chosen))
:on-click #(rf/dispatch [::footage/load])}