diff --git a/docs/architecture.md b/docs/architecture.md index 1b1e9fe..35d1c21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -239,7 +239,7 @@ src/arthur/ ring.cljs ordered traversal: subsample, offset, simplicity geom.cljs similarity fit, procrustes, moving average node.cljs scene node: source, parent, stencil, z - scene.cljs node tree: topo order, transform composition + timeline.cljs node tree: topo order, transform composition channel.cljs keyframe stream: active-key-at, hold semantics clip.cljs clip entity; the frame-space conversions cel.cljs painted vector layers diff --git a/frontend/README.md b/frontend/README.md index 9717139..3fa1f21 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -244,12 +244,12 @@ them is `clips/templates/clips/index.html`. ## Two evaluators, on purpose -`domain/scene` has both `eval-frame` and `resolver`, and they are not +`domain/timeline` has both `eval-frame` and `resolver`, and they are not alternatives: -- **`(eval-frame scene f store)`** is the specification. Allocating, order-free, +- **`(eval-frame timeline f store)`** is the specification. Allocating, order-free, obviously correct. Tests and one-off renders use it. -- **`(resolver scene store)` -> `(fn [f] ops)`** is what playback uses. It caches +- **`(resolver timeline store)` -> `(fn [f] ops)`** is what playback uses. It caches the topological order and the z paths, holds a cursor per channel and reuses one point buffer per node, so a frame allocates the op maps and nothing else. diff --git a/frontend/src/arthur/db.cljs b/frontend/src/arthur/db.cljs index ce5a591..ad1d85f 100644 --- a/frontend/src/arthur/db.cljs +++ b/frontend/src/arthur/db.cljs @@ -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 diff --git a/frontend/src/arthur/demo.cljs b/frontend/src/arthur/demo.cljs index cb60c9c..3d89a1e 100644 --- a/frontend/src/arthur/demo.cljs +++ b/frontend/src/arthur/demo.cljs @@ -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)) diff --git a/frontend/src/arthur/demo/scene.edn b/frontend/src/arthur/demo/scene.edn index 840b23a..f445462 100644 --- a/frontend/src/arthur/demo/scene.edn +++ b/frontend/src/arthur/demo/scene.edn @@ -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}}}}}}} diff --git a/frontend/src/arthur/demo/swarm.cljs b/frontend/src/arthur/demo/swarm.cljs index bf53b0f..0ef0010 100644 --- a/frontend/src/arthur/demo/swarm.cljs +++ b/frontend/src/arthur/demo/swarm.cljs @@ -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)))))}}})) diff --git a/frontend/src/arthur/demo/take.cljs b/frontend/src/arthur/demo/take.cljs index e368eec..59d63cf 100644 --- a/frontend/src/arthur/demo/take.cljs +++ b/frontend/src/arthur/demo/take.cljs @@ -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." diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs new file mode 100644 index 0000000..7f88b1d --- /dev/null +++ b/frontend/src/arthur/domain/clip.cljs @@ -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)))))) diff --git a/frontend/src/arthur/domain/feature.cljs b/frontend/src/arthur/domain/feature.cljs index ecd2b96..b56e84f 100644 --- a/frontend/src/arthur/domain/feature.cljs +++ b/frontend/src/arthur/domain/feature.cljs @@ -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")) diff --git a/frontend/src/arthur/domain/leaf.cljs b/frontend/src/arthur/domain/leaf.cljs index 7f13381..0cf8aef 100644 --- a/frontend/src/arthur/domain/leaf.cljs +++ b/frontend/src/arthur/domain/leaf.cljs @@ -8,15 +8,28 @@ once. clip//name a label - clip//timing fps, frames + clip//timing fps clip//stage width, height clip//source the analysis record this came out of clip//subject/ a tracked subject and its params clip//feature/ one feature: area, nodes, params clip//group/ an eye pair and its shared params - clip//node/ one node: kind, parent, stencil, z, time - clip//channel// one channel - clip//measured/ the measured channels a re-freeze owns + clip//timeline/ frames, and a palette one day + clip//timeline//node/ kind, parent, stencil, z, time + clip//timeline//channel// + clip//timeline//measured/ 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//node/` 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")))))) diff --git a/frontend/src/arthur/domain/node.cljs b/frontend/src/arthur/domain/node.cljs index 7603f8c..248791b 100644 --- a/frontend/src/arthur/domain/node.cljs +++ b/frontend/src/arthur/domain/node.cljs @@ -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 diff --git a/frontend/src/arthur/domain/project.cljs b/frontend/src/arthur/domain/project.cljs index abedd4a..4763d54 100644 --- a/frontend/src/arthur/domain/project.cljs +++ b/frontend/src/arthur/domain/project.cljs @@ -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) diff --git a/frontend/src/arthur/domain/raster.cljs b/frontend/src/arthur/domain/raster.cljs index 17af149..f321f80 100644 --- a/frontend/src/arthur/domain/raster.cljs +++ b/frontend/src/arthur/domain/raster.cljs @@ -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. diff --git a/frontend/src/arthur/domain/scene.cljs b/frontend/src/arthur/domain/timeline.cljs similarity index 78% rename from frontend/src/arthur/domain/scene.cljs rename to frontend/src/arthur/domain/timeline.cljs index 8eaf326..1cd47b7 100644 --- a/frontend/src/arthur/domain/scene.cljs +++ b/frontend/src/arthur/domain/timeline.cljs @@ -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 diff --git a/frontend/src/arthur/events/footage.cljs b/frontend/src/arthur/events/footage.cljs index c1a5d7f..d6771b0 100644 --- a/frontend/src/arthur/events/footage.cljs +++ b/frontend/src/arthur/events/footage.cljs @@ -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)) diff --git a/frontend/src/arthur/events/playback.cljs b/frontend/src/arthur/events/playback.cljs index af32d8e..b0afca2 100644 --- a/frontend/src/arthur/events/playback.cljs +++ b/frontend/src/arthur/events/playback.cljs @@ -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. diff --git a/frontend/src/arthur/events/project.cljs b/frontend/src/arthur/events/project.cljs index 1ab6262..d6a683f 100644 --- a/frontend/src/arthur/events/project.cljs +++ b/frontend/src/arthur/events/project.cljs @@ -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) diff --git a/frontend/src/arthur/flow/freeze.cljs b/frontend/src/arthur/flow/freeze.cljs index 0ac8a9b..b35cf8d 100644 --- a/frontend/src/arthur/flow/freeze.cljs +++ b/frontend/src/arthur/flow/freeze.cljs @@ -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})})) diff --git a/frontend/src/arthur/footage/store.cljs b/frontend/src/arthur/footage/store.cljs index a11b74b..f9f7035 100644 --- a/frontend/src/arthur/footage/store.cljs +++ b/frontend/src/arthur/footage/store.cljs @@ -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))) diff --git a/frontend/src/arthur/subs/render.cljs b/frontend/src/arthur/subs/render.cljs index 5a54881..ba3b0ce 100644 --- a/frontend/src/arthur/subs/render.cljs +++ b/frontend/src/arthur/subs/render.cljs @@ -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)))) diff --git a/frontend/src/arthur/ui/player.cljs b/frontend/src/arthur/ui/player.cljs index 0c67c0e..27efe72 100644 --- a/frontend/src/arthur/ui/player.cljs +++ b/frontend/src/arthur/ui/player.cljs @@ -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 diff --git a/frontend/src/arthur/ui/shell.cljs b/frontend/src/arthur/ui/shell.cljs index a38667a..d239f74 100644 --- a/frontend/src/arthur/ui/shell.cljs +++ b/frontend/src/arthur/ui/shell.cljs @@ -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])} diff --git a/frontend/test/arthur/bench_test.cljs b/frontend/test/arthur/bench_test.cljs index 87c6b5f..fbf5ab7 100644 --- a/frontend/test/arthur/bench_test.cljs +++ b/frontend/test/arthur/bench_test.cljs @@ -10,9 +10,10 @@ here would fail on a loaded CI box and teach everyone to ignore it." (:require [cljs.test :refer [deftest is]] [arthur.demo.swarm :as swarm] + [arthur.domain.clip :as clip] [arthur.domain.palette :as pal] [arthur.domain.raster :as raster] - [arthur.domain.scene :as scene])) + [arthur.domain.timeline :as timeline])) (defn- ms [label n f] (let [t0 (js/Date.now)] @@ -23,11 +24,11 @@ (/ dt n)))) (deftest bench - (let [res (scene/resolver @swarm/scene @swarm/store pal/index-of) + (let [res (timeline/resolver (clip/root @swarm/clip) @swarm/store pal/index-of) ras (raster/make 320 200) dest (js/Uint8ClampedArray. (* 320 200 4)) n 120] - (println "\nswarm:" (count (:nodes @swarm/scene)) "nodes") + (println "\nswarm:" (count (clip/nodes @swarm/clip)) "nodes") (let [a (ms "resolve " n (fn [i] (res (mod i 229)))) b (ms "resolve+draw " n (fn [i] (raster/clear! ras 0) diff --git a/frontend/test/arthur/domain/leaf_test.cljs b/frontend/test/arthur/domain/leaf_test.cljs index d26c0de..5a8a59f 100644 --- a/frontend/test/arthur/domain/leaf_test.cljs +++ b/frontend/test/arthur/domain/leaf_test.cljs @@ -4,74 +4,97 @@ subtly smaller, and the loss is discovered later, by somebody whose work is already gone. - So the assertion is exact equality on the real scenes — the frozen take in both + So the assertion is exact equality on the real clips — the frozen take in both head modes, the hand-written demo, the swarm — rather than on a fixture, and - `scene-keys` makes a field added without a leaf fail loudly instead." + `clip/clip-keys` plus `timeline/timeline-keys` make a field added without a leaf + fail loudly instead." (:require [cljs.test :refer [deftest is testing]] [arthur.demo :as demo] [arthur.demo.swarm :as swarm] [arthur.demo.take :as take] [arthur.domain.channel :as ch] + [arthur.domain.clip :as clip] [arthur.domain.leaf :as leaf])) -(deftest every-real-scene-survives-the-split-exactly - (doseq [[label scene] [["the frozen take" @take/scene] - ["the locked take" @take/locked] - ["the hand-written demo" demo/scene] - ["the swarm" @swarm/scene]]] +(defn- one-timeline + "A minimal clip holding one timeline of these nodes, for the cases that are + about a path rather than about a take." + [nodes] + {:timelines {:main {:id :main :frames 1 :nodes nodes}}}) + +(deftest every-real-clip-survives-the-split-exactly + (doseq [[label c] [["the frozen take" @take/clip] + ["the locked take" @take/locked] + ["the hand-written demo" demo/clip] + ["the swarm" @swarm/clip]]] (testing label - (is (= scene (leaf/scene :c1 (leaf/leaves :c1 scene))))))) + (is (= c (leaf/clip :c1 (leaf/leaves :c1 c))))))) (deftest the-leaves-are-the-paths-the-sync-design-names - (let [ls (leaf/leaves :c7 @take/scene)] + (let [ls (leaf/leaves :c7 @take/clip)] (is (contains? ls "clip/c7/timing")) (is (contains? ls "clip/c7/stage")) (is (contains? ls "clip/c7/source")) - (is (contains? ls "clip/c7/node/mouth")) - (is (contains? ls "clip/c7/channel/mouth/geom.pts")) - (is (contains? ls "clip/c7/channel/mouth-in/vis")) + ;; A TIMELINE ID IS A SEGMENT, which is what lets a symbol's nodes be + ;; addressed by the same path shape as the clip's own. `main` is the root. + (is (contains? ls "clip/c7/timeline/main")) + (is (= {:frames 229} (get ls "clip/c7/timeline/main")) + "a timeline's leaf is its frame space; :fps is the clip's") + (is (contains? ls "clip/c7/timeline/main/node/mouth")) + (is (contains? ls "clip/c7/timeline/main/channel/mouth/geom.pts")) + (is (contains? ls "clip/c7/timeline/main/channel/mouth-in/vis")) (is (contains? ls "clip/c7/feature/eye-r")) (is (contains? ls "clip/c7/group/eyes-1")) (is (contains? ls "clip/c7/subject/face-1")) ;; `:head`'s measured channels are written together by a freeze and replaced ;; together by a re-freeze, so they are one leaf and not three. - (is (contains? ls "clip/c7/measured/head")) - (is (= 3 (count (get ls "clip/c7/measured/head")))))) + (is (contains? ls "clip/c7/timeline/main/measured/head")) + (is (= 3 (count (get ls "clip/c7/timeline/main/measured/head")))) + ;; :frames is NOT in `timing` any more. A timeline is a frame space and a clip + ;; is a rate, so the one leaf that held both was the persistence half of the + ;; conflation `domain/clip` exists to undo. + (is (= {:fps 30} (get ls "clip/c7/timing"))))) (deftest a-node-and-its-channels-are-different-leaves ;; The boundary that lets two people key different parts without meeting. A node ;; leaf carries structure and no geometry. - (let [ls (leaf/leaves :c1 @take/scene) - n (get ls "clip/c1/node/mouth")] + (let [ls (leaf/leaves :c1 @take/clip) + n (get ls "clip/c1/timeline/main/node/mouth")] (is (= {:id :mouth :name "mouth" :kind :poly :parent :head :z "a1"} n)) (is (nil? (:channels n))) - (is (:animated? (get ls "clip/c1/channel/mouth/geom.pts"))))) + (is (:animated? (get ls "clip/c1/timeline/main/channel/mouth/geom.pts"))))) (deftest a-field-with-no-leaf-is-refused-rather-than-dropped - ;; The invariant that keeps the round trip exact as the model grows: a scene - ;; field nobody gave a leaf to would save silently and come back missing. + ;; The invariant that keeps the round trip exact as the model grows: a field + ;; nobody gave a leaf to would save silently and come back missing. Asserted at + ;; BOTH levels now, because there are two types that can grow one. (is (thrown-with-msg? ExceptionInfo #"no leaf to save it in" - (leaf/leaves :c1 (assoc @take/scene :sequences [])))) - (is (= leaf/scene-keys (set (keys (assoc @take/scene :name "x")))) - "scene-keys has drifted from what a frozen scene actually holds")) + (leaf/leaves :c1 (assoc @take/clip :sequences [])))) + (is (thrown-with-msg? ExceptionInfo #"no leaf to save it in" + (leaf/leaves :c1 (assoc-in @take/clip + [:timelines :main :markers] [])))) + (is (= clip/clip-keys (set (keys (assoc @take/clip :name "x")))) + "clip-keys has drifted from what a frozen clip actually holds")) (deftest an-absent-field-stays-absent - ;; A scene with no fps must not come back with `:fps nil`. `=` is the test, and - ;; the demo scene is the case: it has no analysis record and its root has no + ;; A clip with no analysis must not come back with `:analysis nil`. `=` is the + ;; test, and the demo is the case: no analysis record, and its root node has no ;; channels. - (let [ls (leaf/leaves :c1 demo/scene)] + (let [ls (leaf/leaves :c1 demo/clip)] (is (not (contains? ls "clip/c1/source"))) - (is (not (contains? (leaf/scene :c1 ls) :analysis))) - (is (not (contains? (get-in (leaf/scene :c1 ls) [:nodes :root]) :channels))))) + (is (not (contains? (leaf/clip :c1 ls) :analysis))) + (is (not (contains? (get-in (leaf/clip :c1 ls) + [:timelines :main :nodes :root]) + :channels))))) (deftest a-namespaced-id-is-one-path-segment ;; docs/architecture.md draws a node as `:eye-r/iris`, and a leaf path is ;; "/"-delimited, so the two have to be reconciled somewhere. - (let [scene {:nodes {:eye-r/iris {:id :eye-r/iris :kind :disc :parent nil :z "a1" - :channels {[:geom :radius] (ch/framed 2)}}}} - ls (leaf/leaves :c1 scene)] - (is (contains? ls "clip/c1/node/eye-r~iris")) - (is (= scene (leaf/scene :c1 ls)))) + (let [c (one-timeline {:eye-r/iris {:id :eye-r/iris :kind :disc :parent nil :z "a1" + :channels {[:geom :radius] (ch/framed 2)}}}) + ls (leaf/leaves :c1 c)] + (is (contains? ls "clip/c1/timeline/main/node/eye-r~iris")) + (is (= c (leaf/clip :c1 ls)))) ;; `(keyword "a~b")` rather than a literal: ~ is unquote in CLJS source. (is (thrown-with-msg? ExceptionInfo #"cannot contain ~" (leaf/segment (keyword "a~b"))))) @@ -79,10 +102,10 @@ (deftest another-clips-leaves-are-ignored-rather-than-merged ;; A project's whole leaf map can be handed in for one clip, which is what makes ;; a two-clip project one fetch. - (let [a (leaf/leaves :a @take/scene) - b (leaf/leaves :b demo/scene)] - (is (= @take/scene (leaf/scene :a (merge a b)))) - (is (= demo/scene (leaf/scene :b (merge a b)))))) + (let [a (leaf/leaves :a @take/clip) + b (leaf/leaves :b demo/clip)] + (is (= @take/clip (leaf/clip :a (merge a b)))) + (is (= demo/clip (leaf/clip :b (merge a b)))))) ;; --------------------------------------------------------------------------- ;; what a document may not contain @@ -91,20 +114,33 @@ ;; `demo/swarm` names its blocks "swarm/pos", which is exactly the descriptive ;; key content addressing replaced: a handle that only means something on the ;; machine that made it. It is a fine load test and not a document. - (let [ps (leaf/problems (leaf/leaves :c1 @swarm/scene))] + (let [ps (leaf/problems (leaf/leaves :c1 @swarm/clip))] (is (seq ps)) (is (some #(re-find #"names tier 2 as \"swarm/pos\"" %) ps) (pr-str (first ps))))) (deftest a-frozen-clip-has-no-problems - (is (empty? (leaf/problems (leaf/leaves :c1 @take/scene)))) + (is (empty? (leaf/problems (leaf/leaves :c1 @take/clip)))) (is (empty? (leaf/problems (leaf/leaves :c1 @take/locked))))) (deftest a-channel-leaf-for-a-node-that-is-not-there-is-named - (let [ls (dissoc (leaf/leaves :c1 @take/scene) "clip/c1/node/mouth")] + (let [ls (dissoc (leaf/leaves :c1 @take/clip) "clip/c1/timeline/main/node/mouth")] + (is (some #(re-find #"node with no node leaf" %) (leaf/problems ls))))) + +(deftest a-node-leaf-is-scoped-to-its-own-timeline + ;; The reason the node index in `problems` is keyed by (clip, timeline, node) + ;; rather than by node alone: two timelines may each hold a `:mouth`, and a + ;; channel of one is not a channel of the other. Keyed by node alone, deleting + ;; the root's node leaf would have been excused by the symbol's. + (let [ls (-> (leaf/leaves :c1 @take/clip) + (assoc "clip/c1/timeline/sym~blink" {:frames 3} + "clip/c1/timeline/sym~blink/node/mouth" + {:id :mouth :kind :poly :parent nil :z "a1"}) + (dissoc "clip/c1/timeline/main/node/mouth"))] (is (some #(re-find #"node with no node leaf" %) (leaf/problems ls))))) (deftest a-property-with-path-punctuation-in-it-is-refused (is (thrown-with-msg? ExceptionInfo #"cannot contain . or /" - (leaf/leaves :c1 {:nodes {:a {:id :a :kind :poly :parent nil :z "a1" - :channels {[:geom :pts.x] (ch/framed [0 0])}}}})))) + (leaf/leaves :c1 (one-timeline + {:a {:id :a :kind :poly :parent nil :z "a1" + :channels {[:geom :pts.x] (ch/framed [0 0])}}}))))) diff --git a/frontend/test/arthur/domain/project_test.cljs b/frontend/test/arthur/domain/project_test.cljs index 0a6de19..8ac1df0 100644 --- a/frontend/test/arthur/domain/project_test.cljs +++ b/frontend/test/arthur/domain/project_test.cljs @@ -9,9 +9,9 @@ back as a slightly wrong performance rather than as an error. So what is compared is the OPS, frame for frame, through both evaluators, in - every frame order — the same machinery scene-test uses to hold `eval-frame` and - `resolver` to each other, which is the strictest statement available about two - scenes being the same scene. + every frame order — the same machinery timeline-test uses to hold `eval-frame` + and `resolver` to each other, which is the strictest statement available about + two documents being the same document. This runs the conversion the network runs — `JSON.parse(JSON.stringify(...))` — and not the network. `clips/tests.py` puts the same document through Django, and @@ -20,8 +20,9 @@ (:require [cljs.test :refer [deftest is testing]] [arthur.demo.take :as take] [arthur.domain.channel :as ch] + [arthur.domain.clip :as clip] [arthur.domain.project :as project] - [arthur.domain.scene :as scene] + [arthur.domain.timeline :as timeline] [arthur.flow.freeze :as freeze] [arthur.support.ops :as ops])) @@ -33,28 +34,31 @@ (def ^:private before (delay @take/frozen)) (def ^:private after (delay (wired :c1 @before))) -(deftest what-comes-back-is-a-valid-scene - (let [ps (scene/problems (:scene @after))] +(deftest what-comes-back-is-a-valid-clip + ;; `clip/problems` and not `timeline/problems`: the round trip has to preserve + ;; the tracking identities and the timeline map as well as the nodes, and only + ;; the clip-level check looks at those. + (let [ps (clip/problems (:clip @after))] (is (empty? ps) (pr-str ps)))) (deftest the-document-comes-back-equal ;; Stronger than it needs to be and worth having: not merely equivalent, EQUAL. ;; Any drift here is a field the codec is rewriting, and a field that is ;; rewritten once is rewritten again on every save. - (is (= (:scene @before) (:scene @after)))) + (is (= (:clip @before) (:clip @after)))) (deftest every-frame-resolves-to-the-same-ops-before-and-after ;; The assertion. Both evaluators, both scenes, every frame order — so a block ;; that came back with its offsets shifted, or a cursor that seeks differently ;; over a rebuilt key map, has nowhere to hide. - (let [n (:frames (:scene @before)) + (let [n (clip/frames (:clip @before)) paths {"specification" [ops/specified ops/specified] "playback" [ops/resolved ops/resolved] "spec vs playback, after" [ops/specified ops/resolved]}] (doseq [[label [f g]] paths [order fs] (ops/orders n)] - (let [a (f (:scene @before) (:store @before)) - b (g (:scene @after) (:store @after))] + (let [a (f (clip/root (:clip @before)) (:store @before)) + b (g (clip/root (:clip @after)) (:store @after))] (testing (str label ", " order) (doseq [frame fs] (is (= (a frame) (b frame)) @@ -76,11 +80,11 @@ ;; The other head mode, because it is the one whose `:head` channels are FRAMED ;; rather than dense: a codec that only handled dense channels would pass ;; everything above and lose the locked take's identity transform. - (let [locked {:scene @take/locked :store @take/store} + (let [locked {:clip @take/locked :store @take/store} back (wired :c1 locked) - a (ops/resolved (:scene locked) (:store locked)) - b (ops/resolved (:scene back) (:store back))] - (is (= (:scene locked) (:scene back))) + a (ops/resolved (clip/root (:clip locked)) (:store locked)) + b (ops/resolved (clip/root (:clip back)) (:store back))] + (is (= (:clip locked) (:clip back))) (doseq [frame (range 0 take/frames 7)] (is (= (a frame) (b frame)) (str "frame " frame))))) @@ -109,9 +113,9 @@ (deftest an-absence-mask-survives-the-wire (let [back (wired :c1 @gappy) - at (fn [clip id path f] - (ch/value-at (get-in (:scene clip) [:nodes id :channels path]) - f (:store clip))) + at (fn [entry id path f] + (ch/value-at (get-in (clip/nodes (:clip entry)) [id :channels path]) + f (:store entry))) ;; Every dense track of the eye, iris, brow and brow-position blocks, and ;; the feature whose gap it must follow — the same table ;; `each-dense-track-follows-its-own-features-presence` pins. @@ -140,7 +144,8 @@ ;; Presence is not visibility, on the far side too: the node is dropped from the ;; frame rather than hidden, and its partner is not. (let [back (wired :c1 @gappy) - drawn (into #{} (map :node) ((scene/resolver (:scene back) (:store back)) 12))] + drawn (into #{} (map :node) + ((timeline/resolver (clip/root (:clip back)) (:store back)) 12))] (is (not (contains? drawn :eye-r))) (is (contains? drawn :eye-l)) (is (contains? drawn :mouth)))) diff --git a/frontend/test/arthur/domain/scene_test.cljs b/frontend/test/arthur/domain/timeline_test.cljs similarity index 84% rename from frontend/test/arthur/domain/scene_test.cljs rename to frontend/test/arthur/domain/timeline_test.cljs index 95dba73..822c345 100644 --- a/frontend/test/arthur/domain/scene_test.cljs +++ b/frontend/test/arthur/domain/timeline_test.cljs @@ -1,4 +1,4 @@ -(ns arthur.domain.scene-test +(ns arthur.domain.timeline-test "Frame evaluation, and the hand-written scene. port-plan step 2 exists to find out whether the data model works BEFORE nine @@ -13,7 +13,8 @@ [arthur.domain.node :as node] [arthur.domain.palette :as pal] [arthur.domain.raster :as raster] - [arthur.domain.scene :as scene] + [arthur.domain.clip :as clip] + [arthur.domain.timeline :as timeline] [arthur.support.ops :as ops])) (defn- poly [id parent z pts color & [extra]] @@ -26,7 +27,7 @@ {:nodes (into {} (map (juxt :id identity)) nodes)}) (defn- ids-at [scene f] - (mapv :node (scene/eval-frame scene f))) + (mapv :node (timeline/eval-frame scene f))) (def ^:private pts-of ops/points) @@ -37,9 +38,9 @@ {:id :b :kind :group :parent :a :z "a1"} {:id :c :kind :group :parent :b :z "a1"} {:id :d :kind :group :parent :a :z "a2"}) - ord (scene/order (:nodes s))] - (is (= 0 (scene/depth (:nodes s) :a))) - (is (= 2 (scene/depth (:nodes s) :c))) + ord (timeline/order (:nodes s))] + (is (= 0 (timeline/depth (:nodes s) :a))) + (is (= 2 (timeline/depth (:nodes s) :c))) (let [pos (into {} (map-indexed (fn [i id] [id i])) ord)] (doseq [[id p] [[:b :a] [:c :b] [:d :a]]] (is (< (get pos p) (get pos id)) (str p " must come before " id)))))) @@ -49,12 +50,12 @@ ;; diagnostic than a stack trace naming the nodes. (let [s (sc {:id :a :kind :group :parent :b :z "a1"} {:id :b :kind :group :parent :a :z "a1"})] - (is (thrown-with-msg? ExceptionInfo #"cycle" (scene/order (:nodes s)))) - (is (seq (scene/problems s))))) + (is (thrown-with-msg? ExceptionInfo #"cycle" (timeline/order (:nodes s)))) + (is (seq (timeline/problems s))))) (deftest a-missing-parent-is-named-rather-than-silently-orphaning (let [s (sc {:id :a :kind :group :parent :nope :z "a1"})] - (is (seq (scene/problems s))))) + (is (seq (timeline/problems s))))) (deftest reparenting-is-one-field-and-does-not-move-a-subtree ;; The flat-with-pointers claim, asserted as the thing it buys: a reparent is an @@ -69,9 +70,9 @@ (is (identical? (get-in s [:nodes :b]) (get-in s' [:nodes :b])) "and so is the new one") (is (= [[0 0] [10 0] [10 10]] - (pts-of (first (filter #(= :c (:node %)) (scene/eval-frame s 0)))))) + (pts-of (first (filter #(= :c (:node %)) (timeline/eval-frame s 0)))))) (is (= [[100 0] [110 0] [110 10]] - (pts-of (first (filter #(= :c (:node %)) (scene/eval-frame s' 0)))))))) + (pts-of (first (filter #(= :c (:node %)) (timeline/eval-frame s' 0)))))))) ;; ---- draw order ---- @@ -113,7 +114,7 @@ :channels {[:xform :pos] (ch/framed [100 50]) [:xform :scale] (ch/framed [2 2])}} (poly :p :g "a1" [0 0 10 0 10 10 0 10] :skin-base)) - op (first (scene/eval-frame s 0))] + op (first (timeline/eval-frame s 0))] (is (= [[100 50] [120 50] [120 70] [100 70]] (pts-of op))))) (deftest a-keyed-group-position-moves-its-children-and-holds-between-keys @@ -123,7 +124,7 @@ :channels {[:xform :pos] (ch/keyed {0 [0 0], 4 [10 0], 8 [10 10], 12 [0 10]})}} (poly :p :g "a1" [0 0 2 0 2 2] :skin-base)) - at #(first (pts-of (first (scene/eval-frame s %))))] + at #(first (pts-of (first (timeline/eval-frame s %))))] (is (= [0 0] (at 0))) (is (= [0 0] (at 3)) "held") (is (= [10 0] (at 4))) @@ -140,7 +141,7 @@ {:id :g :kind :group :parent :root :z "a1" :channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)))}} (poly :p :g "a1" [0 0 1 0 1 1] :skin-base)) - x-at #(first (first (pts-of (first (scene/eval-frame s %)))))] + x-at #(first (first (pts-of (first (timeline/eval-frame s %)))))] (is (= [0 0 0 3 3 3 6 6 6 9 9 9] (mapv x-at (range 12))))) (testing "and a node may set its own grid, which the model permits deliberately" @@ -148,7 +149,7 @@ {:id :g :kind :group :parent :root :z "a1" :time {:mode :map :expose 4} :channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)))}} (poly :p :g "a1" [0 0 1 0 1 1] :skin-base)) - x-at #(first (first (pts-of (first (scene/eval-frame s %)))))] + x-at #(first (first (pts-of (first (timeline/eval-frame s %)))))] (is (= [0 0 0 0 4 4 4 4 8 8 8 8] (mapv x-at (range 12))))))) (deftest offset-is-per-node-which-is-the-entire-point-of-mouth-lead @@ -162,7 +163,7 @@ {:id :mouth :kind :group :parent :root :z "a2" :time {:mode :map :offset 2} :channels {[:xform :pos] (ch/keyed keys)}} (poly :mouth-p :mouth "a1" [0 0 1 0 1 1] :mouth-dark)) - x-of (fn [f id] (->> (scene/eval-frame s f) + x-of (fn [f id] (->> (timeline/eval-frame s f) (filter #(= id (:node %))) first pts-of first first))] (is (= [0 1 2 3] (mapv #(x-of % :plate-p) (range 4)))) (is (= [2 3 4 5] (mapv #(x-of % :mouth-p) (range 4))) "the mouth reads ahead"))) @@ -205,11 +206,11 @@ :dense {:store "pts" :offset 0 :stride 6 :frames 2}} [:style :color] (ch/framed :mouth-dark)}} (poly :teeth :m "a2" [0 0 1 0 1 1] :teeth))] - (is (= [:child] (mapv :node (scene/eval-frame absent-pos 0 store)))) - (is (= [] (mapv :node (scene/eval-frame absent-pos 1 store))) + (is (= [:child] (mapv :node (timeline/eval-frame absent-pos 0 store)))) + (is (= [] (mapv :node (timeline/eval-frame absent-pos 1 store))) "an absent transform gives the children nowhere to be") - (is (= [:m :teeth] (mapv :node (scene/eval-frame absent-pts 0 store)))) - (is (= [:teeth] (mapv :node (scene/eval-frame absent-pts 1 store))) + (is (= [:m :teeth] (mapv :node (timeline/eval-frame absent-pts 0 store)))) + (is (= [:teeth] (mapv :node (timeline/eval-frame absent-pts 1 store))) "an absent outline removes only itself"))) ;; ---- stencils ---- @@ -223,7 +224,7 @@ {:id :iris :kind :disc :parent :root :stencil :sclera :z "a2" :channels {[:geom :radius] (ch/framed 4) [:style :color] (ch/framed :iris)}}) - ops (scene/eval-frame s 0)] + ops (timeline/eval-frame s 0)] (is (= [:sclera :iris] (mapv :node ops))) (is (= (:eye-white pal/index-of) (:stencil (second ops)))))) @@ -250,7 +251,7 @@ :channels {[:geom :radius] (ch/framed 3) [:style :color] (ch/framed :iris)}} {:id :r :kind :rect :parent :g :z "a2" :channels {[:geom :size] (ch/framed 1.7) [:style :color] (ch/framed :pupil)}}) - [d r] (scene/eval-frame s 0)] + [d r] (timeline/eval-frame s 0)] (is (= [50 60 6] [(:cx d) (:cy d) (:r d)])) ;; 1.7 x 2 is 3.4, and a block 3.4px wide would be 3px on one frame and 4 on ;; the next, which reads as the pupil breathing. @@ -266,7 +267,7 @@ ;; The frame orders and the snapshot live in `arthur.support.ops`, because the ;; same comparison is what proves a scene survived the server — see ;; flow/project-test. - (let [s demo/scene + (let [s demo/timeline spec (ops/specified s nil) fast (ops/resolved s nil)] (doseq [[label fs] (ops/orders (:frames s))] @@ -277,28 +278,38 @@ (deftest the-resolver-reuses-one-buffer-per-node ;; At 30fps per-frame allocation is the only thing that will make this stutter, ;; and fixed topology is what makes the buffer size knowable at all. - (let [res (scene/resolver demo/scene) + (let [res (timeline/resolver demo/timeline) buf-of (fn [f id] (->> (res f) (filter #(= id (:node %))) first :pts))] (is (identical? (buf-of 0 :card) (buf-of 30 :card))))) ;; ---- the hand-written scene, end to end ---- -(deftest the-hand-written-scene-is-valid - (let [ps (scene/problems demo/scene)] +(deftest the-hand-written-clip-is-valid + ;; `clip/problems` rather than `timeline/problems`: it checks the clip's fields, + ;; the timeline map and the tracking identities as well as the nodes, so it is + ;; the check a save would make. + (let [ps (clip/problems demo/clip)] (is (empty? ps) (pr-str ps))) - (is (pos? (:frames demo/scene)))) + (is (pos? demo/frames)) + (testing "a clip is not a timeline, and handing one over fails loudly" + ;; The mistake this split makes easy: both are maps with an :id, and the wrong + ;; one resolves to no ops rather than to an error. + (is (thrown-with-msg? ExceptionInfo #"not a timeline" + (timeline/resolver demo/clip))) + (is (thrown-with-msg? ExceptionInfo #"not a timeline" + (timeline/eval-frame demo/clip 0))))) -(deftest the-hand-written-scene-renders-and-moves +(deftest the-hand-written-clip-renders-and-moves ;; port-plan step 2's done condition, as an assertion rather than a look: the ;; scene rasterises, it writes only palette indices, and the pixels are not the ;; same on every frame. - (let [res (scene/resolver demo/scene) + (let [res (timeline/resolver demo/timeline) render (fn [f] - (let [r (raster/make (:width demo/scene) (:height demo/scene))] + (let [r (raster/make (:width demo/clip) (:height demo/clip))] (raster/clear! r (:bg pal/index-of)) (raster/draw-ops! r (res f)) r)) - frames (mapv render (range 0 (:frames demo/scene) 6)) + frames (mapv render (range 0 demo/frames 6)) sig (fn [r] (vec (array-seq (:buf r))))] (is (every? (fn [r] (every? #(< % (count pal/rgb)) (array-seq (:buf r)))) frames) "every byte written is a real palette index") @@ -306,17 +317,17 @@ (testing "the mark actually covers pixels" (is (pos? (count (remove zero? (sig (first frames))))))))) -(deftest the-hand-written-scene-steps-on-the-exposure-grid +(deftest the-hand-written-clip-steps-on-the-exposure-grid ;; Exposure 2 on the clip root, inherited, so odd frames are identical to the ;; even frame before them. If this fails, exposure is being applied somewhere ;; other than the frame the channels are sampled at. - (let [res (scene/resolver demo/scene) + (let [res (timeline/resolver demo/timeline) render (fn [f] - (let [r (raster/make (:width demo/scene) (:height demo/scene))] + (let [r (raster/make (:width demo/clip) (:height demo/clip))] (raster/clear! r (:bg pal/index-of)) (raster/draw-ops! r (res f)) (vec (array-seq (:buf r)))))] - (doseq [f (range 0 (:frames demo/scene) 2)] + (doseq [f (range 0 demo/frames 2)] (is (= (render f) (render (inc f))) (str "frame " (inc f) " must hold frame " f))) ;; Two grid slots that straddle a key, not two adjacent ones: between keys ;; nothing changes, because that is what hold MEANS. The scene's second key @@ -324,13 +335,13 @@ ;; expose-before-anything-else rule showing up in pixels. (is (not= (render 56) (render 58)) "and a key on the grid is seen"))) -(deftest the-hand-written-scene-keeps-the-iris-and-pupil-inside-the-card +(deftest the-hand-written-clip-keeps-the-iris-and-pupil-inside-the-card ;; The stencil chain, on real pixels: the iris is clipped by the card and the ;; pupil by the iris, and neither is expressed anywhere as a chain. - (let [res (scene/resolver demo/scene)] - (doseq [f (range 0 (:frames demo/scene) 4)] - (let [before (raster/make (:width demo/scene) (:height demo/scene)) - after (raster/make (:width demo/scene) (:height demo/scene)) + (let [res (timeline/resolver demo/timeline)] + (doseq [f (range 0 demo/frames 4)] + (let [before (raster/make (:width demo/clip) (:height demo/clip)) + after (raster/make (:width demo/clip) (:height demo/clip)) ops (res f) card? (fn [op] (= :card (:node op)))] (raster/clear! before (:bg pal/index-of)) @@ -360,9 +371,9 @@ (poly :p :root "a1" [0 0 10 0 10 10] :skin-base)) day {:skin-base 1} night {:skin-base 17}] - (is (= 1 (:color (first (scene/eval-frame s 0 nil day))))) - (is (= 17 (:color (first (scene/eval-frame s 0 nil night))))) - (is (= 17 (:color (first ((scene/resolver s nil night) 0)))) + (is (= 1 (:color (first (timeline/eval-frame s 0 nil day))))) + (is (= 17 (:color (first (timeline/eval-frame s 0 nil night))))) + (is (= 17 (:color (first ((timeline/resolver s nil night) 0)))) "and the playback path agrees"))) (deftest a-tone-the-ramp-does-not-define-is-loudly-wrong @@ -370,7 +381,7 @@ ;; authored data and should be impossible to miss. (let [s (sc {:id :root :kind :group :z "a1"} (poly :p :root "a1" [0 0 10 0 10 10] :skin-base))] - (is (= 255 (:color (first (scene/eval-frame s 0 nil {}))))))) + (is (= 255 (:color (first (timeline/eval-frame s 0 nil {}))))))) (deftest partitioning-the-index-space-stops-two-palettes-colliding-on-a-stencil ;; A stencil is a colour key, so two nodes sharing a tone share a stencil — @@ -383,6 +394,6 @@ [:style :color] (ch/framed :iris)}}) ;; :night's tones sit above :day's in one concatenated space night {:eye-white 14 :iris 15} - ops (scene/eval-frame s 0 nil night)] + ops (timeline/eval-frame s 0 nil night)] (is (= 14 (:stencil (second ops))) "the stencil resolves to the index the stencil node actually drew in"))) diff --git a/frontend/test/arthur/domain/wire_test.cljs b/frontend/test/arthur/domain/wire_test.cljs index f3592e7..f55ead6 100644 --- a/frontend/test/arthur/domain/wire_test.cljs +++ b/frontend/test/arthur/domain/wire_test.cljs @@ -54,7 +54,7 @@ (deftest a-whole-leaf-map-round-trips-through-parsed-json ;; What a save actually does: transit, then parsed so the column holds JSON. - (let [ls (leaf/leaves :c1 @take/scene)] + (let [ls (leaf/leaves :c1 @take/clip)] (is (= ls (into {} (map (fn [[p v]] [p (round-json v)])) ls))))) ;; --------------------------------------------------------------------------- diff --git a/frontend/test/arthur/flow/eye_occlusion_test.cljs b/frontend/test/arthur/flow/eye_occlusion_test.cljs index ad20946..82b8e2f 100644 --- a/frontend/test/arthur/flow/eye_occlusion_test.cljs +++ b/frontend/test/arthur/flow/eye_occlusion_test.cljs @@ -2,9 +2,10 @@ (:require [cljs.test :refer [deftest is]] [arthur.demo.take :as take] [arthur.domain.channel :as ch] + [arthur.domain.clip :as clip] [arthur.domain.landmarks :as lm] [arthur.domain.palette :as pal] - [arthur.domain.scene :as scene] + [arthur.domain.timeline :as timeline] [arthur.flow.condition.eyes :as condition-eyes] [arthur.flow.ingest :as ingest] [arthur.flow.measure.eyes :as eyes] @@ -45,20 +46,20 @@ (deftest the-full-take-hides-only-the-annotated-eye-and-returns-to-the-same-id (let [presence (ingest/feature-presence take/frames {:eye-r [[10 14]]}) - {:keys [scene store]} (flow-take/build + {:keys [clip store]} (flow-take/build (assoc take/params :aspect 1 :name "observed-gap") {:dense @take/analysis :presence presence}) sample (fn [id frame] - (ch/value-at (get-in scene [:nodes id :channels [:geom :pts]]) + (ch/value-at (get-in (clip/nodes clip) [id :channels [:geom :pts]]) frame store))] - (is (empty? (scene/problems scene))) - (is (= [:eye-r :eye-l] (get-in scene [:groups :eyes-1 :members]))) + (is (empty? (clip/problems clip))) + (is (= [:eye-r :eye-l] (get-in clip [:groups :eyes-1 :members]))) (doseq [f (range 9 14)] (is (ch/nothing? (sample :eye-r f))) (is (not (ch/nothing? (sample :eye-l f)))) (is (not (ch/nothing? (sample :mouth f))))) (let [drawn (into #{} (map :node) - ((scene/resolver scene store pal/index-of) 11))] + ((timeline/resolver (clip/root clip) store pal/index-of) 11))] (is (not (contains? drawn :eye-r))) (is (not (contains? drawn :iris-r))) (is (contains? drawn :eye-l)) diff --git a/frontend/test/arthur/flow/freeze_test.cljs b/frontend/test/arthur/flow/freeze_test.cljs index 1db91e0..a679b41 100644 --- a/frontend/test/arthur/flow/freeze_test.cljs +++ b/frontend/test/arthur/flow/freeze_test.cljs @@ -11,12 +11,13 @@ (:require [cljs.test :refer [deftest is testing]] [arthur.demo.take :as take] [arthur.domain.channel :as ch] + [arthur.domain.clip :as clip] [arthur.domain.geom :as geom] [arthur.domain.node :as node] [arthur.domain.palette :as pal] [arthur.domain.raster :as raster] [arthur.domain.ring :as ring] - [arthur.domain.scene :as scene] + [arthur.domain.timeline :as timeline] [arthur.flow.freeze :as freeze])) (def ^:private W 320) @@ -24,13 +25,17 @@ ;; The whole vertical slice, exactly as the page builds it. Asserting against the ;; page's own clip rather than against a fixture built here is deliberate: a -;; fixture is a second scene nobody looks at, and the one that renders is the one +;; fixture is a second document nobody looks at, and the one that renders is the one ;; that has to be right. -(def clip (delay @take/frozen)) -(def scene* (delay (:scene @clip))) -(def store (delay (:store @clip))) +(def frozen (delay @take/frozen)) +(def clip* (delay (:clip @frozen))) +;; The ROOT TIMELINE, which is what an evaluator takes. `clip*` is the document — +;; `:fps`, the stage, the analysis, the tracking identities — and `tl*` is the bag +;; of nodes in its frame space. `timeline/resolver` refuses the wrong one loudly. +(def tl* (delay (clip/root @clip*))) +(def store (delay (:store @frozen))) -(defn- node [id] (get-in @scene* [:nodes id])) +(defn- node [id] (get-in @tl* [:nodes id])) (defn- chan [id path] (get-in (node id) [:channels path])) (defn- block-of @@ -44,16 +49,19 @@ (let [v (ch/value-at (chan id [:geom :pts]) f @store)] (mapv #(ch/component v %) (range (.-length v))))) -(defn- ops-at [sc f] - ((scene/resolver sc @store pal/index-of) f)) +(defn- ops-at + "Ops for one frame of a TIMELINE." + [tl f] + ((timeline/resolver tl @store pal/index-of) f)) (defn- render - "One frame of a scene into a byte buffer. The stage's size comes off the clip, - because project dimensions are the project's and not the footage's." - [sc f] - (let [r (raster/make (:width sc) (:height sc))] + "One frame of a CLIP into a byte buffer. The stage's size comes off the clip and + the ops off its root timeline, because project dimensions are the project's and + not the footage's — and not a timeline's either." + [c f] + (let [r (raster/make (:width c) (:height c))] (raster/clear! r (get pal/index-of :bg)) - (raster/draw-ops! r (ops-at sc f)) + (raster/draw-ops! r (ops-at (clip/root c) f)) (vec (array-seq (:buf r))))) (defn- drawn @@ -64,25 +72,27 @@ ;; --------------------------------------------------------------------------- ;; the shape of what came out -(deftest the-frozen-take-is-a-valid-scene-in-every-head-mode - ;; `scene/problems` is total by construction, so this is safe to run over data - ;; before the data is trusted — which is what it is for. +(deftest the-frozen-take-is-a-valid-clip-in-every-head-mode + ;; `clip/problems` is total by construction, so this is safe to run over data + ;; before the data is trusted — which is what it is for. It checks the clip's + ;; fields, every timeline in it and the tracking identities, so it is the whole + ;; of what a save would refuse. (doseq [mode [:as-filmed :locked]] - (let [sc (freeze/head-mode {:mode mode} @clip)] - (is (empty? (scene/problems sc)) (str mode ": " (pr-str (scene/problems sc)))))) - (let [sc (freeze/head-mode {:mode :per-plate :kept #{0 12 40 88 150}} @clip)] - (is (empty? (scene/problems sc)) (pr-str (scene/problems sc))))) + (let [c (freeze/head-mode {:mode mode} @frozen)] + (is (empty? (clip/problems c)) (str mode ": " (pr-str (clip/problems c)))))) + (let [c (freeze/head-mode {:mode :per-plate :kept #{0 12 40 88 150}} @frozen)] + (is (empty? (clip/problems c)) (pr-str (clip/problems c))))) (deftest the-tree-is-the-one-the-model-specifies ;; :face is AUTHORED and :head is MEASURED, and they are two nodes because two ;; different things want that transform. A group node is free; keeping the ;; hand-placed and the measured transform apart is the whole reason the ;; transform is decomposed in the first place. - (is (= [:face :root] (scene/lineage (:nodes @scene*) :face))) - (is (= [:head :face :root] (scene/lineage (:nodes @scene*) :head))) - (is (= [:mouth :head :face :root] (scene/lineage (:nodes @scene*) :mouth))) + (is (= [:face :root] (timeline/lineage (:nodes @tl*) :face))) + (is (= [:head :face :root] (timeline/lineage (:nodes @tl*) :head))) + (is (= [:mouth :head :face :root] (timeline/lineage (:nodes @tl*) :mouth))) (is (= [:mouth-in :mouth :head :face :root] - (scene/lineage (:nodes @scene*) :mouth-in))) + (timeline/lineage (:nodes @tl*) :mouth-in))) ;; Exposure lives on the clip root and inherits strictly. (is (= {:mode :map :expose 2} (:time (node :root)))) (is (every? #(nil? (:time (node %))) [:face :head :mouth :mouth-in]))) @@ -186,8 +196,8 @@ ;; of the two says it is. A test that recomputed the chain would only be ;; checking arithmetic against itself; this checks `node/local!`, `node/world!` ;; and `emit` as well. - (let [sc (freeze/head-mode {:mode :as-filmed} @clip) - res (scene/resolver sc @store pal/index-of) + (let [c (freeze/head-mode {:mode :as-filmed} @frozen) + res (timeline/resolver (clip/root c) @store pal/index-of) k (first (:value (chan :face [:xform :scale]))) anc (:value (chan :face [:xform :anchor])) pos (:value (chan :face [:xform :pos])) @@ -228,9 +238,9 @@ (deftest the-three-head-modes-are-the-three-channel-shapes (let [kept #{0 12 40 88 150} - of (fn [sc path] (get-in sc [:nodes :head :channels path]))] + of (fn [c path] (get-in (clip/nodes c) [:head :channels path]))] (testing "locked is framed identity" - (let [sc (freeze/head-mode {:mode :locked} @clip)] + (let [sc (freeze/head-mode {:mode :locked} @frozen)] (is (= [:framed :framed :framed] (mapv #(ch/describe (of sc %)) [[:xform :pos] [:xform :rot] [:xform :scale]]))) @@ -238,12 +248,12 @@ (is (= 0.0 (:value (of sc [:xform :rot])))) (is (= [1.0 1.0] (:value (of sc [:xform :scale])))))) (testing "as filmed is dense" - (let [sc (freeze/head-mode {:mode :as-filmed} @clip)] + (let [sc (freeze/head-mode {:mode :as-filmed} @frozen)] (is (= [:dense :dense :dense] (mapv #(ch/describe (of sc %)) [[:xform :pos] [:xform :rot] [:xform :scale]]))))) (testing "per plate is keyed at exactly the kept frames" - (let [sc (freeze/head-mode {:mode :per-plate :kept kept} @clip)] + (let [sc (freeze/head-mode {:mode :per-plate :kept kept} @frozen)] (doseq [path [[:xform :pos] [:xform :rot] [:xform :scale]]] (is (= :keyed (ch/describe (of sc path)))) (is (= (sort kept) (sort (keys (:keys (of sc path)))))) @@ -262,25 +272,29 @@ ;; It has to be impossible for the toggle to move something a hand placed, and ;; it has to be a DOCUMENT edit: tier 1, undoable, syncable, instant, and not a ;; reason to re-analyse. - (let [a (freeze/head-mode {:mode :as-filmed} @clip) - b (freeze/head-mode {:mode :locked} @clip) - c (freeze/head-mode {:mode :per-plate :kept #{0 40}} @clip)] - (doseq [sc [b c]] - (is (= (get-in a [:nodes :face]) (get-in sc [:nodes :face])) + (let [a (freeze/head-mode {:mode :as-filmed} @frozen) + b (freeze/head-mode {:mode :locked} @frozen) + c (freeze/head-mode {:mode :per-plate :kept #{0 40}} @frozen)] + (doseq [x [b c]] + (is (= (get (clip/nodes a) :face) (get (clip/nodes x) :face)) ":face moved") - (is (= (dissoc (:nodes a) :head) (dissoc (:nodes sc) :head)) + (is (= (dissoc (clip/nodes a) :head) (dissoc (clip/nodes x) :head)) "a node other than :head changed") ;; The measurement does not go away when the head is locked: always measure, ;; always store factored, toggle the parent. - (is (= (get-in a [:nodes :head :measured]) (get-in sc [:nodes :head :measured])))))) + (is (= (get-in (clip/nodes a) [:head :measured]) + (get-in (clip/nodes x) [:head :measured]))) + ;; And nothing above the timeline moved either: the toggle is one node's + ;; channels, so the clip's own fields and its other timelines are untouched. + (is (= (dissoc a :timelines) (dissoc x :timelines)))))) (deftest a-head-mode-that-is-not-one-of-the-three-is-refused (is (thrown-with-msg? ExceptionInfo #"not one of the three channel shapes" - (freeze/head-mode {:mode :stabilised} @clip))) + (freeze/head-mode {:mode :stabilised} @frozen))) ;; The kept-frame set belongs to the plate strip, not to measurement, so freeze ;; cannot invent one. (is (thrown-with-msg? ExceptionInfo #"kept-frame set" - (freeze/head-mode {:mode :per-plate} @clip)))) + (freeze/head-mode {:mode :per-plate} @frozen)))) ;; --------------------------------------------------------------------------- ;; the face: authored, and what makes makeXform deletable @@ -290,7 +304,7 @@ ;; transform on a node, which a hand can revise, and it claims no generator that ;; would offer to overwrite it. (doseq [path [[:xform :pos] [:xform :rot] [:xform :scale] [:xform :anchor]]] - (let [c (get (node/channels (get-in @scene* [:nodes :face])) path)] + (let [c (get (node/channels (node :face)) path)] (is (= :framed (ch/describe c)) (str path " is not framed")) (is (nil? (:generated c)) (str path " claims provenance"))))) @@ -314,18 +328,20 @@ ;; re-measuring anything. (let [big (freeze/clip (assoc take/params :stage [640 480] :name "big") @take/measured) - key-of (fn [sc] (:store (:dense (get-in sc [:nodes :mouth :channels [:geom :pts]]))))] - (is (= [640 480] [(:width (:scene big)) (:height (:scene big))])) + key-of (fn [c] (:store (:dense (get-in (clip/nodes c) + [:mouth :channels [:geom :pts]]))))] + (is (= [640 480] [(:width (:clip big)) (:height (:clip big))])) ;; Stronger than it was, and for free: the stage is not an input to tier 2, so ;; the two clips do not merely hold equal bytes — they name the SAME BLOCK, and ;; a stage change cannot invalidate a bake. The clip's name is not an input ;; either, which is why "big" and "take" still agree. - (is (= (key-of @scene*) (key-of (:scene big))) + (is (= (key-of @clip*) (key-of (:clip big))) "a different stage is a different document over the same tier 2") - (is (= (vec (array-seq (:data (get @store (key-of @scene*))))) - (vec (array-seq (:data (get (:store big) (key-of (:scene big))))))) + (is (= (vec (array-seq (:data (get @store (key-of @clip*))))) + (vec (array-seq (:data (get (:store big) (key-of (:clip big))))))) "the geometry is the same numbers at either stage size") - (is (not= (:value (get-in (:scene big) [:nodes :face :channels [:xform :scale]])) + (is (not= (:value (get-in (clip/nodes (:clip big)) + [:face :channels [:xform :scale]])) (:value (chan :face [:xform :scale])))))) ;; --------------------------------------------------------------------------- @@ -355,7 +371,7 @@ ;; interior comes and goes. (is (nil? (chan :mouth [:vis]))) (doseq [f (range 0 take/frames 9)] - (is (some #(= :mouth (:node %)) (ops-at @scene* f)) + (is (some #(= :mouth (:node %)) (ops-at @tl* f)) (str "frame " f " drew no mouth outline")))) ;; --------------------------------------------------------------------------- @@ -384,13 +400,15 @@ ;; `:generated` out of the document and the frame is the same bytes. If this ;; ever fails, a rotoscoped part and a hand-drawn one have stopped being the ;; same data. - (let [stripped (update @scene* :nodes - (fn [ns] (into {} (map (fn [[id n]] - [id (update n :channels - #(into {} (map (fn [[p c]] [p (dissoc c :generated)])) %))])) - ns)))] + (let [strip-node (fn [n] + (update n :channels + #(into {} (map (fn [[p c]] [p (dissoc c :generated)])) %))) + stripped (clip/update-root + @clip* + update :nodes + #(into {} (map (fn [[id n]] [id (strip-node n)])) %))] (doseq [f (range 0 take/frames 17)] - (is (= (render @scene* f) (render stripped f)) + (is (= (render @clip* f) (render stripped f)) (str "frame " f " differs with provenance removed"))))) ;; --------------------------------------------------------------------------- @@ -401,21 +419,23 @@ presence {:eye-r (mapv #(not (contains? gap %)) (range take/frames))} c (freeze/clip (assoc take/params :name "one-eye-gappy") (assoc @take/measured :presence presence)) - sc (:scene c) + tl (clip/root (:clip c)) at (fn [id f] - (ch/value-at (get-in sc [:nodes id :channels [:geom :pts]]) f (:store c)))] - (is (= [:eye-r :eye-l] (get-in sc [:groups :eyes-1 :members]))) - (is (= :eye-r (get-in sc [:features :eye-r :id]))) + (ch/value-at (get-in (:nodes tl) [id :channels [:geom :pts]]) f (:store c)))] + ;; The identities are the CLIP's, and a gap does not touch them: a feature keeps + ;; its id and its pair membership across the frames it was not observed on. + (is (= [:eye-r :eye-l] (get-in (:clip c) [:groups :eyes-1 :members]))) + (is (= :eye-r (get-in (:clip c) [:features :eye-r :id]))) (is (not (ch/nothing? (at :eye-r 39)))) (is (ch/nothing? (at :eye-r 50))) (is (not (ch/nothing? (at :eye-r 60)))) (is (not (ch/nothing? (at :eye-l 50)))) (is (not (ch/nothing? (at :mouth 50)))) - (let [drawn-nodes (into #{} (map :node) ((scene/resolver sc (:store c) pal/index-of) 50))] + (let [drawn-nodes (into #{} (map :node) ((timeline/resolver tl (:store c) pal/index-of) 50))] (is (not (contains? drawn-nodes :eye-r))) (is (contains? drawn-nodes :eye-l)) (is (contains? drawn-nodes :mouth))) - (is (empty? (scene/problems sc))))) + (is (empty? (clip/problems (:clip c)))))) (deftest each-dense-track-follows-its-own-features-presence ;; Every feature is given a DIFFERENT gap, so a track wired to the wrong one @@ -437,9 +457,9 @@ windows) c (freeze/clip (assoc take/params :name "per-track-gaps") (assoc @take/measured :presence presence)) - sc (:scene c) + tl (clip/root (:clip c)) at (fn [id path f] - (ch/value-at (get-in sc [:nodes id :channels path]) f (:store c))) + (ch/value-at (get-in (:nodes tl) [id :channels path]) f (:store c))) ;; Node, channel, and the feature whose gap it must follow. Every dense ;; track of the eye, iris, brow and brow-position blocks appears once. tracks [[:eye-r [:geom :pts] :eye-r] @@ -452,7 +472,7 @@ [:brow-l [:geom :pts] :brow-l] [:brow-r [:xform :pos] :brow-r] [:brow-l [:xform :pos] :brow-l]]] - (is (empty? (scene/problems sc))) + (is (empty? (clip/problems (:clip c)))) (doseq [[id path owner] tracks [feature gap] windows f gap] @@ -465,10 +485,10 @@ (deftest teeth-follow-the-mouth-through-their-stencil-and-not-through-a-mask ;; Teeth are their own feature, so that they can carry their own :area :teeth ;; parameters — which means an occluded MOUTH sets no absence bit on them. They - ;; do not need one: they are stencilled by :mouth-in, and scene/finish drops a + ;; do not need one: they are stencilled by :mouth-in, and timeline/finish drops a ;; node whose stencil drew nothing. So the coupling is real, and it is the ;; stencil rule rather than the mask that enforces it. The rule itself is - ;; asserted in scene-test; what is pinned here is the wiring that relies on it. + ;; asserted in timeline-test; what is pinned here is the wiring that relies on it. (let [gap (set (range 40 60)) teeth-in {;; The mouth's own inner ring standing in for a pixel-derived ;; contour: real geometry and no nils, so every frame has a @@ -485,7 +505,7 @@ ;; Hoisted: the resolver caches its order and reuses its buffers, so the ;; node ids come out before the next frame is asked for. nodes-at (fn [c] - (let [r (scene/resolver (:scene c) (:store c) pal/index-of)] + (let [r (timeline/resolver (clip/root (:clip c)) (:store c) pal/index-of)] (fn [f] (into #{} (map :node) (r f))))) ref-at (nodes-at ref) occ-at (nodes-at occ) @@ -495,7 +515,7 @@ open (filter #(contains? (ref-at %) :teeth) (range take/frames)) inside (first (filter gap open)) outside (first (remove gap open))] - (is (= :mouth-in (get-in (:scene occ) [:nodes :teeth :stencil])) + (is (= :mouth-in (get-in (clip/nodes (:clip occ)) [:teeth :stencil])) "teeth stop inheriting the mouth's absence if this stops being their stencil") (is (some? inside) "no open-mouth frame inside the gap to test with") (is (some? outside) "no open-mouth frame outside the gap to test with") @@ -503,14 +523,14 @@ ;; Annotating the MOUTH sets no bit on the teeth: they are a feature of ;; their own and nothing named them. (is (not (ch/nothing? - (ch/value-at (get-in (:scene occ) [:nodes :teeth :channels [:geom :pts]]) + (ch/value-at (get-in (clip/nodes (:clip occ)) [:teeth :channels [:geom :pts]]) inside (:store occ))))) ;; They are dropped anyway — :mouth-in drew nothing to clip them against. (is (not (contains? (occ-at inside) :mouth-in))) (is (not (contains? (occ-at inside) :teeth))) ;; And the same articulation outside the gap still draws them. (is (contains? (occ-at outside) :teeth))) - (is (empty? (scene/problems (:scene occ)))))) + (is (empty? (clip/problems (:clip occ)))))) (deftest an-undetected-frame-has-no-pose-at-all ;; A subject that was not on the frame has NO VALUE, which is different from a @@ -521,8 +541,8 @@ det (mapv #(not (contains? gap %)) (range take/frames)) c (freeze/clip (assoc take/params :name "gappy") (assoc @take/measured :detected det)) - sc (:scene c) - res (scene/resolver sc (:store c) pal/index-of)] + tl (clip/root (:clip c)) + res (timeline/resolver tl (:store c) pal/index-of)] (doseq [f [39 40 50 59 60]] (let [ops (res f)] (if (contains? gap f) @@ -531,7 +551,7 @@ ;; And it is the MASK doing it, not a hidden flag: `[:vis]` on :mouth-in is ;; unchanged across the gap, because hiding and absence are different ;; questions with different answers. - (is (= (mapv #(ch/value-at (get-in sc [:nodes :mouth-in :channels [:vis]]) %) + (is (= (mapv #(ch/value-at (get-in (:nodes tl) [:mouth-in :channels [:vis]]) %) (range take/frames)) (mapv #(ch/value-at (chan :mouth-in [:vis]) %) (range take/frames)))))) @@ -569,7 +589,7 @@ (deftest the-take-draws-something-on-every-frame (doseq [f (range 0 take/frames 5)] - (let [n (drawn (render @scene* f))] + (let [n (drawn (render @clip* f))] (is (> n 200) (str "frame " f " drew only " n " pixels"))))) (deftest the-mouth-moves @@ -577,10 +597,10 @@ ;; from different beats are genuinely different mouths and frames inside one ;; beat are not. This is the numeric half of step 5's done-criterion; the other ;; half is a picture and lives in test/browser/take.mjs. - (let [locked (freeze/head-mode {:mode :locked} @clip) + (let [locked (freeze/head-mode {:mode :locked} @frozen) shot (fn [f] (let [r (raster/make W H) - mouth (filter #(= :mouth (:node %)) (ops-at locked f))] + mouth (filter #(= :mouth (:node %)) (ops-at (clip/root locked) f))] (raster/clear! r (get pal/index-of :bg)) (raster/draw-ops! r mouth) (vec (array-seq (:buf r))))) @@ -594,6 +614,6 @@ (is (< (differ (shot 10) (shot 12)) 200) "a held pose is moving more than the detector noise it should have lost")) ;; As filmed, the head carries it around the stage as well. - (let [filmed (freeze/head-mode {:mode :as-filmed} @clip)] + (let [filmed (freeze/head-mode {:mode :as-filmed} @frozen)] (is (> (count (remove true? (map = (render filmed 10) (render filmed 120)))) 300) "the head does not move across the take"))) diff --git a/frontend/test/arthur/support/ops.cljs b/frontend/test/arthur/support/ops.cljs index 6f5cf06..8a1c6b4 100644 --- a/frontend/test/arthur/support/ops.cljs +++ b/frontend/test/arthur/support/ops.cljs @@ -1,10 +1,10 @@ (ns arthur.support.ops - "Comparing two evaluations of a scene, frame for frame. + "Comparing two evaluations of a timeline, frame for frame. - `domain/scene` has two evaluators on purpose — `eval-frame` is the - specification and `resolver` is what playback uses — and scene-test's central + `domain/timeline` has two evaluators on purpose — `eval-frame` is the + specification and `resolver` is what playback uses — and timeline-test's central assertion is that they agree in forward, backward and random frame order. - Step 9 needs the same comparison for a different question: that a scene which + Step 9 needs the same comparison for a different question: that a document which has been through the server produces the same ops as the one that went in. Shared rather than copied, because the interesting part is not the equality — @@ -12,12 +12,15 @@ point buffer per node, so it can agree on a forward pass and disagree on a scrub, and a copy of this list that forgot 'backward' would test the easy half. + Every entry point takes a TIMELINE, not a clip: what produces ops is a bag of + nodes in a frame space, and a caller with a clip says `(clip/root c)`. + `snapshot` is what makes ops comparable at all: a resolved op carries `:pts` as a VIEW into a reused buffer, so two ops from different frames can be `=` while naming the same array, and holding one and then asking for the next frame changes what the first one says. Reading the points out is what pins the frame." (:require [arthur.domain.palette :as pal] - [arthur.domain.scene :as scene])) + [arthur.domain.timeline :as timeline])) (defn points "An op's points as a vector of [x y], read out of its buffer." @@ -27,7 +30,7 @@ (defn snapshot "Ops -> comparable data. `:i` goes too: it is the draw-order index, and it is a - function of the scene rather than of the frame." + function of the timeline rather than of the frame." [ops] (mapv (fn [op] (cond-> (dissoc op :pts :i) @@ -47,13 +50,13 @@ (defn specified "(fn [f] -> snapshot) through `eval-frame`, the specification." - ([scene store] (specified scene store pal/index-of)) - ([scene store palette] - (fn [f] (snapshot (scene/eval-frame scene f store palette))))) + ([tl store] (specified tl store pal/index-of)) + ([tl store palette] + (fn [f] (snapshot (timeline/eval-frame tl f store palette))))) (defn resolved "(fn [f] -> snapshot) through `resolver`, the playback path." - ([scene store] (resolved scene store pal/index-of)) - ([scene store palette] - (let [res (scene/resolver scene store palette)] + ([tl store] (resolved tl store pal/index-of)) + ([tl store palette] + (let [res (timeline/resolver tl store palette)] (fn [f] (snapshot (res f)))))) diff --git a/frontend/test/browser/take.mjs b/frontend/test/browser/take.mjs index 6337ff1..8ef8381 100644 --- a/frontend/test/browser/take.mjs +++ b/frontend/test/browser/take.mjs @@ -186,7 +186,7 @@ const PROBE = `(() => { cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null, hash: h >>> 0, frame: document.querySelector('.readout span')?.textContent ?? '', - scene: [...document.querySelectorAll('.transport .row button')] + selectedClip: [...document.querySelectorAll('.transport .row button')] .filter((b) => b.classList.contains('on')).map((b) => b.textContent), }; })()`; @@ -235,9 +235,9 @@ async function main() { } if (!probe) throw new Error('no canvas.stage on the page — is `shadow-cljs watch app` running?'); - console.log(`\ncanvas ${probe.w}x${probe.h}, scene ${JSON.stringify(probe.scene)}`); + console.log(`\ncanvas ${probe.w}x${probe.h}, clip ${JSON.stringify(probe.selectedClip)}`); - check(probe.scene.includes('take'), 'the take is the scene that opens'); + check(probe.selectedClip.includes('take'), 'the take is the clip that opens'); check(probe.w === 320 && probe.h === 200, 'the canvas is the stage size', `${probe.w}x${probe.h}`); check(probe.drawn > 200, 'the first frame is not blank', `${probe.drawn} px drawn`); @@ -384,8 +384,8 @@ async function main() { 'and the open mouth still has an interior on the far side'); // The reopened clip is not one of the built-ins: this is the document that came // back from the server, not the one that was in the page all along. - check(!back[0].scene.includes('take'), 'the picture is the reopened document', - JSON.stringify(back[0].scene)); + check(!back[0].selectedClip.includes('take'), 'the picture is the reopened document', + JSON.stringify(back[0].selectedClip)); check(page.logs.length === 0, 'no errors on the console', page.logs.slice(0, 3).join(' | '));