diff --git a/clips/migrations/0007_symbols_not_timelines.py b/clips/migrations/0007_symbols_not_timelines.py new file mode 100644 index 0000000..1f726c8 --- /dev/null +++ b/clips/migrations/0007_symbols_not_timelines.py @@ -0,0 +1,75 @@ +"""Schema 2: a document holds symbols, not timelines, and no symbol is reserved. + +Three renames, each in the stored transit and nowhere else: + + clip//timeline/... -> clip//symbol/... + a node leaf's :kind :symbol -> :kind :instance + a feature leaf's :timeline key -> :symbol + +A leaf value is transit's map form, ["^ ", k1, v1, k2, v2, ...]. Only TOP-LEVEL +pairs are rewritten, and only literal ones: transit caches a repeated keyword as +"^N", and a rename that met a cache reference where it expected the keyword would +be guessing. Every saved leaf at the time of writing had these as literals; if one +does not, the migration stops rather than writing a document that decodes to +something else. + +Renaming a cached keyword in place is safe because the cache is positional: the +literal keeps its slot, so any later "^N" that referred to it now refers to the +new name, which is what it meant. +""" + +import re + +from django.db import migrations, models + +PATH = re.compile(r"^(clip/[^/]+/)timeline(/|$)") + + +def _rename_pair(value, key, old, new, path): + if not (isinstance(value, list) and value[:1] == ["^ "]): + return value + out = list(value) + for i in range(1, len(out) - 1, 2): + if out[i] != key: + continue + if old is None: + out[i] = new + elif out[i + 1] == old: + out[i + 1] = new + elif isinstance(out[i + 1], str) and out[i + 1].startswith("^") and out[i + 1] != "^ ": + raise RuntimeError(f"leaf {path!r} has a cached {key} value; migrate it by hand") + return out + + +def forwards(apps, schema_editor): + Leaf = apps.get_model("clips", "Leaf") + Project = apps.get_model("clips", "Project") + for leaf in Leaf.objects.all(): + path = PATH.sub(r"\1symbol\2", leaf.path) + value = leaf.value + parts = path.split("/") + if len(parts) == 6 and parts[2] == "symbol" and parts[4] == "node": + value = _rename_pair(value, "~:kind", "~:symbol", "~:instance", leaf.path) + if len(parts) == 4 and parts[2] == "feature": + value = _rename_pair(value, "~:timeline", None, "~:symbol", leaf.path) + if path != leaf.path or value != leaf.value: + leaf.path = path + leaf.value = value + leaf.version += 1 + leaf.save(update_fields=["path", "value", "version"]) + Project.objects.update(schema_version=2) + + +class Migration(migrations.Migration): + dependencies = [ + ("clips", "0006_project_schema_version"), + ] + + operations = [ + migrations.AlterField( + model_name="project", + name="schema_version", + field=models.PositiveIntegerField(default=2), + ), + migrations.RunPython(forwards, migrations.RunPython.noop), + ] diff --git a/clips/models.py b/clips/models.py index b120873..c561a03 100644 --- a/clips/models.py +++ b/clips/models.py @@ -206,7 +206,7 @@ class Project(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) name = models.CharField(max_length=200, default="untitled") - schema_version = models.PositiveIntegerField(default=1) + schema_version = models.PositiveIntegerField(default=2) seq = models.PositiveBigIntegerField(default=0) palette = models.CharField(max_length=64, default="arthur/default") created = models.DateTimeField(auto_now_add=True) diff --git a/clips/tests/test_api.py b/clips/tests/test_api.py index 08554ad..6d67bd5 100644 --- a/clips/tests/test_api.py +++ b/clips/tests/test_api.py @@ -382,13 +382,13 @@ class DocumentTests(TestCase): # cache marker, keyword keys, and a frame-keyed inner map. return { "clip/c1/timing": ["^ ", "~:fps", 30], - "clip/c1/timeline/main": ["^ ", "~:frames", 48], - "clip/c1/timeline/main/node/mouth": ["^ ", "~:id", "~:mouth", "~:z", "a1"], - "clip/c1/timeline/main/channel/mouth/geom.pts": [ + "clip/c1/symbol/main": ["^ ", "~:frames", 48], + "clip/c1/symbol/main/node/mouth": ["^ ", "~:id", "~:mouth", "~:z", "a1"], + "clip/c1/symbol/main/channel/mouth/geom.pts": [ "^ ", "~:animated?", True, "~:dense", ["^ ", "~:store", self.block, "~:offset", 0, "~:stride", 16], ], - "clip/c1/timeline/main/channel/mouth-in/vis": [ + "clip/c1/symbol/main/channel/mouth-in/vis": [ "^ ", "~:animated?", True, "~:keys", ["^ ", "~i0", True, "~i12", False], ], } @@ -407,7 +407,7 @@ class DocumentTests(TestCase): self.assertEqual(5, len(response.json()["written"])) loaded = self.client.get(f"/api/projects/{self.project.id}").json() - self.assertEqual(1, loaded["schema_version"]) + self.assertEqual(2, loaded["schema_version"]) self.assertEqual(1, len(loaded["clips"])) clip = loaded["clips"][0] self.assertEqual("c1", clip["cid"]) @@ -424,22 +424,22 @@ class DocumentTests(TestCase): self.save() first = {leaf.path: leaf.version for leaf in Leaf.objects.all()} moved = self.leaves() - moved["clip/c1/timeline/main/channel/mouth-in/vis"] = [ + moved["clip/c1/symbol/main/channel/mouth-in/vis"] = [ "^ ", "~:animated?", True, "~:keys", ["^ ", "~i0", False], ] response = self.save(moved) - self.assertEqual(["clip/c1/timeline/main/channel/mouth-in/vis"], response.json()["written"]) + self.assertEqual(["clip/c1/symbol/main/channel/mouth-in/vis"], response.json()["written"]) self.assertEqual(4, response.json()["unchanged"]) after = {leaf.path: leaf.version for leaf in Leaf.objects.all()} - self.assertEqual(2, after["clip/c1/timeline/main/channel/mouth-in/vis"]) + self.assertEqual(2, after["clip/c1/symbol/main/channel/mouth-in/vis"]) self.assertEqual(first["clip/c1/timing"], after["clip/c1/timing"]) def test_a_removed_node_removes_its_leaf(self): self.save() fewer = {k: v for k, v in self.leaves().items() - if k != "clip/c1/timeline/main/node/mouth"} + if k != "clip/c1/symbol/main/node/mouth"} response = self.save(fewer) - self.assertEqual(["clip/c1/timeline/main/node/mouth"], response.json()["removed"]) + self.assertEqual(["clip/c1/symbol/main/node/mouth"], response.json()["removed"]) self.assertEqual(4, Leaf.objects.count()) def test_a_save_does_not_disturb_another_clip(self): @@ -472,7 +472,7 @@ class DocumentTests(TestCase): def test_a_leaf_write_carries_an_etag(self): self.save() - url = f"/api/projects/{self.project.id}/leaves/clip/c1/timeline/main/node/mouth" + url = f"/api/projects/{self.project.id}/leaves/clip/c1/symbol/main/node/mouth" got = self.client.get(url) self.assertEqual('"1"', got["ETag"]) @@ -488,7 +488,7 @@ class DocumentTests(TestCase): # take-theirs. A PUT that replaced unconditionally is the bug where the # loser's work disappears silently. self.save() - url = f"/api/projects/{self.project.id}/leaves/clip/c1/timeline/main/node/mouth" + url = f"/api/projects/{self.project.id}/leaves/clip/c1/symbol/main/node/mouth" self.put(url, {"value": ["^ ", "~:z", "a2"]}, HTTP_IF_MATCH='"1"') stale = self.put(url, {"value": ["^ ", "~:z", "a3"]}, HTTP_IF_MATCH='"1"') self.assertEqual(409, stale.status_code) diff --git a/docs/animation-model.md b/docs/animation-model.md index 4c1a2bc..a8393cf 100644 --- a/docs/animation-model.md +++ b/docs/animation-model.md @@ -415,9 +415,11 @@ different rules: *not* to the plate, which is the whole point of it — so the offset genuinely belongs at the node, not the clip. -## Timelines, and why a scene is one +## Symbols, and why a scene is one -A **timeline** is an ordered bag of nodes in its own frame space: +A **symbol** is an ordered bag of nodes in its own frame space. (Earlier drafts +and code called this a *timeline*; that word now means only the UI pane that +shows one.) ```clojure {:frames 91 @@ -427,9 +429,10 @@ A **timeline** is an ordered bag of nodes in its own frame space: That is the whole type, and **everything that holds nodes is one of these**: -- a clip's **scene** is its root timeline, -- a **symbol** in the library is a timeline, -- a node with `:kind :symbol` is an **instance** of one. +- what a document opens on is a symbol, and **no symbol is reserved** — a new + document's is called `main` only because it has to be called something, +- anything placed inside another symbol is a symbol, +- a node with `:kind :instance` is an **instance** of one. An earlier draft of this document had a scene and a `:kind :timeline` symbol as two structures with the same fields and never said they were the same thing. @@ -474,7 +477,7 @@ for all three is the same — **their own**: ### Instances -A node with `:kind :symbol` and `:of :sym/blink` places one. Its own channels +A node with `:kind :instance` and `:of :sym/blink` places one. Its own channels compose *over* the symbol's, so one definition is placed many times and tinted, offset or retimed at each placement — that is how a three-frame blink is reused at frames 40, 88 and 200 without copying it. diff --git a/docs/architecture.md b/docs/architecture.md index 90d6536..09bc757 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -639,10 +639,10 @@ is what step 9 implemented, for the subset that exists: clip//name clip//subject/ clip//timing clip//feature/ clip//stage clip//group/ -clip//source clip//timeline/ -clip//timeline//node/ -clip//timeline//measured/ -clip//timeline//channel// +clip//source clip//symbol/ +clip//symbol//node/ +clip//symbol//measured/ +clip//symbol//channel// ``` Settings live on subject, feature and group leaves. Each feature has one area, so @@ -785,12 +785,12 @@ clip/:cid/timing clip rate clip/:cid/subject/:sid tracked subject and settings clip/:cid/feature/:fid tracked feature and settings clip/:cid/group/:gid shared settings for an eye pair -clip/:cid/timeline/:tid frame count, palette -clip/:cid/timeline/:tid/node/:nid one node: parent, stencil, z, time -clip/:cid/timeline/:tid/channel/:nid/:prop -clip/:cid/timeline/:tid/measured/:nid -clip/:cid/timeline/:tid/cel/:nid/:frame -clip/:cid/timeline/:tid/overrides/:nid/:prop +clip/:cid/symbol/:sid frame count, palette +clip/:cid/symbol/:sid/node/:nid one node: parent, stencil, z, time +clip/:cid/symbol/:sid/channel/:nid/:prop +clip/:cid/symbol/:sid/measured/:nid +clip/:cid/symbol/:sid/cel/:nid/:frame +clip/:cid/symbol/:sid/overrides/:nid/:prop ``` Each feature and node has its own leaf, so tuning separate features and adding diff --git a/docs/multi-face-representation.md b/docs/multi-face-representation.md index 65584c3..51ae686 100644 --- a/docs/multi-face-representation.md +++ b/docs/multi-face-representation.md @@ -8,11 +8,11 @@ symbol instance. Timelines already provide local node names, independent playbac and persistence. No new kind of scene container is needed. ```clojure -:timelines +:symbols {:main {:nodes {:root {:time {:mode :map :expose 2}} :face {:parent :root :channels } - :face-1 {:kind :symbol :of :face-1 :parent :face :z "a0"} - :face-2 {:kind :symbol :of :face-2 :parent :face :z "a1"}}} + :face-1 {:kind :instance :of :face-1 :parent :face :z "a0"} + :face-2 {:kind :instance :of :face-2 :parent :face :z "a1"}}} :face-1 {:nodes {:head {...} :mouth {:parent :head ...} ...}} :face-2 {:nodes {:head {...} :mouth {:parent :head ...} ...}}} diff --git a/docs/timing-handoff.md b/docs/timing-handoff.md index 30e15f7..b9fa406 100644 --- a/docs/timing-handoff.md +++ b/docs/timing-handoff.md @@ -70,7 +70,7 @@ handling and the relevant key whitelist if its storage location requires it. - `freeze/performance-nodes` marks generated animated channels with `:pose-sampled?` and local `:pose-group` names. This includes keyed visibility as well as dense geometry. `:generated` remains provenance for regeneration. -- `timeline/channel-frame` already applies explicit pose choices and default +- `symbol/channel-frame` already applies explicit pose choices and default picture sampling to marked channels. Playback and export both use `clip/resolver` with `:picture-fps`; there is no need for a second sampling implementation. Export's pose count is still a rate-based estimate. diff --git a/frontend/README.md b/frontend/README.md index fff8e0f..42dc4ba 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -116,8 +116,14 @@ projects the server holds; the built-in scenes are under their own heading, italic, and are not projects — they are compiled into the bundle and the server has never heard of them. -Selection lives in app-db under `:ui`, as `[:node ]`, -`[:timeline ]` or `[:subject|:feature|:group ]` — four panes ask what is +Everything that holds nodes is a **symbol**, and none is special: a new document +has one called `main` because it has to be called something. Which symbol is on +screen is editor state, `[:ui :open]`, not a fact about the document — the stage +draws it, the timeline lists it, the transport plays it and a new shape goes into +it. A document opens on the longest symbol nothing else places. + +Selection lives in app-db under `:ui`, as `[:node ]`, +`[:symbol ]` or `[:subject|:feature|:group ]` — four panes ask what is selected, and a ratom private to one of them can only be shared by making the other three require it. @@ -125,12 +131,12 @@ other three require it. into detection. Dragging a symbol out of the pool onto the stage places an instance of it at the playhead. -The timeline's rows are the open clip's nodes, front-most first, with a dot per +The timeline's rows are the open symbol's nodes, front-most first, with a dot per keyframe and a bar over the frames the node exists on; a dense channel is hatched rather than ticked, because one value per frame is a solid block that says less -than the bar does. Opening a row shows its channels; opening a **symbol** row -shows the timeline it instances, with every frame number mapped back into the -stage's own frame space — see the namespace docstring in `ui/timeline.cljs`, which +than the bar does. Opening a row shows its channels; opening an **instance** row +shows the symbol it places, with every frame number mapped back into the open +symbol's frame space — see the namespace docstring in `ui/timeline.cljs`, which is where that mapping is argued. ### Paint sketch @@ -168,9 +174,9 @@ and real footage use `src/arthur/flow/take.cljs` for the measurement order and `src/arthur/flow/freeze.cljs` for the landmark-to-channel conversion. **8625 stage study**, in the open menu, loads the locally saved `IMG_8625.MOV` project and places its -post-processed timeline twice. The stage layout is +post-processed face symbol twice. The stage layout is `src/arthur/demo/stage_8625.edn`: the right picture and sound start at frame 48, -and the two pictures overlap slightly in stage space. Audio has its own timeline +and the two pictures overlap slightly in stage space. Audio has its own nodes, linked to the picture instances but with independent spans and gain channels. The right sound swells and pans across the stage, then fades out at frame 260 while its picture continues to @@ -357,12 +363,12 @@ them is `clips/templates/clips/index.html`. ## Two evaluators, on purpose -`domain/timeline` has both `eval-frame` and `resolver`, and they are not +`domain/symbol` has both `eval-frame` and `resolver`, and they are not alternatives: -- **`(eval-frame timeline f store)`** is the specification. Allocating, order-free, +- **`(eval-frame symbol f store)`** is the specification. Allocating, order-free, obviously correct. Tests and one-off renders use it. -- **`(resolver timeline store)` -> `(fn [f] ops)`** is what playback uses. It caches +- **`(resolver symbol 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/audio/mix.cljs b/frontend/src/arthur/audio/mix.cljs index cec1e6f..98dea64 100644 --- a/frontend/src/arthur/audio/mix.cljs +++ b/frontend/src/arthur/audio/mix.cljs @@ -91,18 +91,15 @@ (.setValueAtTime param (* factor v) (/ f fps))))))) (defn tracks-of - "The audio nodes of one of the clip's timelines. + "The audio nodes of one of the clip's symbols. Any symbol may carry its own + sound, and playback mixes the open one's." + [document sid] + (filter #(= :audio (:kind %)) (vals (:nodes (clip/symbol document sid))))) - A timeline parameter rather than always the root, because a symbol is a - timeline and may carry its own sound. `:main` is the clip's own, which is what - playback mixes." - [document tid] - (filter #(= :audio (:kind %)) (vals (:nodes (clip/timeline document tid))))) - -(defn- render! [document tid sources store] +(defn- render! [document sid sources store] (let [fps (:fps document) - frames (:frames (clip/timeline document tid)) - tracks (tracks-of document tid) + frames (:frames (clip/symbol document sid)) + tracks (tracks-of document sid) output (js/OfflineAudioContext. 2 (js/Math.ceil (* (/ frames fps) 44100)) 44100)] (doseq [track tracks] @@ -133,20 +130,20 @@ (.startRendering output))) (defn buffer! - "Promise of the `AudioBuffer` one timeline's audio tracks mix down to, or nil + "Promise of the `AudioBuffer` one symbol's audio tracks mix down to, or nil when it has none. The raw product. `mix!` packages it as a WAV URL for the transport and `export/frames` packages it as WAV bytes in an archive; a muxer would take it as it is, which is why this is the function the others are written in terms of." - ([document tid] (buffer! document tid nil)) - ([document tid store] - (let [tracks (tracks-of document tid)] + ([document sid] (buffer! document sid nil)) + ([document sid store] + (let [tracks (tracks-of document sid)] (if (empty? tracks) (js/Promise.resolve nil) (-> (js/Promise.all (into-array (map source! (distinct (map #(get-in % [:source :footage]) tracks))))) - (.then (fn [pairs] (render! document tid (into {} (array-seq pairs)) store)))))))) + (.then (fn [pairs] (render! document sid (into {} (array-seq pairs)) store)))))))) (defn decode! "Promise of the `AudioBuffer` behind a URL. What a clip whose audio is a plain @@ -162,9 +159,9 @@ (.decodeAudioData (js/OfflineAudioContext. 1 1 44100) bytes))))) (defn mix! - "Promise of a mixed WAV URL, or the original URL for a clip without audio - tracks. Each track can be trimmed and faded independently of its linked picture." - ([document fallback-url] (mix! document fallback-url nil)) - ([document fallback-url store] - (-> (buffer! document clip/root-id store) - (.then (fn [buffer] (if buffer (wav-url buffer) fallback-url)))))) + "Promise of a mixed WAV URL for symbol `sid`, or the original URL when it has + no audio tracks. Each track can be trimmed and faded independently of its + linked picture." + [document sid fallback-url store] + (-> (buffer! document sid store) + (.then (fn [buffer] (if buffer (wav-url buffer) fallback-url))))) diff --git a/frontend/src/arthur/db.cljs b/frontend/src/arthur/db.cljs index 5ad50aa..740faca 100644 --- a/frontend/src/arthur/db.cljs +++ b/frontend/src/arthur/db.cljs @@ -20,10 +20,8 @@ Read OFF the clip rather than written again beside it: copying a number by hand into this table is how it comes to disagree with the document it describes. - `:frames` comes from the ROOT TIMELINE and `:fps` from the clip, which is the - split `arthur.domain.clip` exists to make — a timeline is a frame space, a clip - is a rate — and an earlier version of this docstring noted that they sat on one - map \"only because there is one clip per scene today\". They do not any more." + There is no `:frames` here, because a length belongs to a symbol and which + symbol is open is the editor's state — see `events/playback/frames`." [label-key label clip store] (merge {:label label :clip clip :store store ;; A static asset since step 9, and not the repo root's `audio.wav`. @@ -32,8 +30,7 @@ ;; that the clock has something to run against with no footage ingested. :audio "/static/arthur/audio.wav" :cid (name label-key) - :display-fps (:fps clip) - :frames (domain-clip/frames clip)} + :display-fps (:fps clip)} (select-keys clip [:fps :width :height]))) (def clips @@ -72,7 +69,7 @@ ;; transform on a node, so nothing downstream of the freeze knows the frame ;; size — and it is why ui/player no longer hardcodes 320x200. :clip (let [c (domain-clip/blank)] - {:fps (:fps c) :frames (domain-clip/frames c) + {:fps (:fps c) :width (:width c) :height (:height c) :audio nil :display-fps (:fps c)}) @@ -106,11 +103,12 @@ ;; machinery that would share it. ;; --- export --- ;; - ;; The REQUEST and its progress, never the frames. Which timeline to write and + ;; The REQUEST and its progress, never the frames. Which symbol to write and ;; at what integer zoom is authored state like anything else; the megabytes the ;; render produces are handed straight to a download and never enter the db. - ;; `:isolate` is the placement to render alone, or nil for the whole timeline. - :export {:timeline :main :isolate nil :zoom 4 :busy? false :done 0 :total 0 + ;; `:isolate` is the placement to render alone, or nil for the whole symbol; + ;; `:symbol` nil means whichever symbol is open. + :export {:symbol nil :isolate nil :zoom 4 :busy? false :done 0 :total 0 :status nil} :playback {:frame 0 @@ -134,20 +132,28 @@ ;; names, so a pane dispatches on it rather than on which of several ;; "selected-x" keys happens to be non-nil: ;; - ;; [:node ] a shape or a placement - ;; [:timeline ] a timeline, root or library + ;; [:node ] a shape or an instance + ;; [:symbol ] a symbol ;; [:subject ] [:feature ] [:group ] a tracked object ;; ;; `:draft` is the polygon being clicked out, flat [x y x y …] as geometry is ;; stored everywhere. `:expanded` holds timeline row PATHS — a path and not a ;; node id, because one symbol placed twice is two rows that open separately. ;; + ;; `:open` is the symbol on screen — the one the stage draws, the timeline + ;; lists, the transport plays and a new shape goes into — and `:tabs` the + ;; symbols open beside it. Editor state and not the document's, because no + ;; symbol is special to the document: which one you are looking at is a fact + ;; about you. + ;; ;; `:knobs` holds a generated setting's value WHILE THE REGENERATION IS IN ;; FLIGHT, keyed by [scope id knob]. Moving a slider dispatches a preview that ;; re-freezes blocks asynchronously, so until it lands the clip still reports ;; the old value — and a slider reading from the clip would spring back under ;; the user's finger on every frame of the drag. - :ui {:selection nil + :ui {:open nil + :tabs [] + :selection nil :tone :skin-base :tool nil :draft [] diff --git a/frontend/src/arthur/demo.cljs b/frontend/src/arthur/demo.cljs index 3d89a1e..46b527f 100644 --- a/frontend/src/arthur/demo.cljs +++ b/frontend/src/arthur/demo.cljs @@ -6,7 +6,7 @@ validates would not be the one that renders, and the model would be validated against a scene nobody ever looked at." (:require [arthur.domain.clip :as domain-clip] - [arthur.domain.timeline :as timeline] + [arthur.domain.symbol :as symbol] [cljs.reader :as reader] [shadow.resource :as rc])) @@ -14,15 +14,15 @@ (def clip (reader/read-string source)) -(def timeline - "The clip's root timeline: what an evaluator takes. `clip` is the document." - (domain-clip/root clip)) +(def main + "The scene's one symbol: what an evaluator takes. `clip` is the document." + (domain-clip/symbol clip :main)) (def fps (:fps clip)) -(def frames (domain-clip/frames clip)) +(def frames (domain-clip/frames clip :main)) (defn ops-at "Draw ops for one frame, via the specification path. The page uses - `timeline/resolver` instead; this is here for the REPL." + `symbol/resolver` instead; this is here for the REPL." [f] - (timeline/eval-frame timeline f)) + (symbol/eval-frame main f)) diff --git a/frontend/src/arthur/demo/scene.edn b/frontend/src/arthur/demo/scene.edn index f445462..19a6d08 100644 --- a/frontend/src/arthur/demo/scene.edn +++ b/frontend/src/arthur/demo/scene.edn @@ -32,7 +32,7 @@ :width 320 :height 200 - :timelines + :symbols {:main {:id :main :frames 229 diff --git a/frontend/src/arthur/demo/stage.cljs b/frontend/src/arthur/demo/stage.cljs index 9119ed3..b0645f8 100644 --- a/frontend/src/arthur/demo/stage.cljs +++ b/frontend/src/arthur/demo/stage.cljs @@ -35,7 +35,7 @@ (let [{:keys [name width height frames symbol instances audio scale]} layout default-anchor (or (:anchor layout) [(/ (:width source) 2) (/ (:height source) 2)]) - original (get-in source [:timelines :main]) + original (get-in source [:symbols :main]) ;; Authored id -> uuid, so the `:linked-to` in the EDN resolves to the ;; identity the document uses. Built before either pass because the audio ;; nodes refer to the instances. @@ -49,7 +49,7 @@ {:root {:id :root :name "stage" :kind :group :z "a1"}} (map (fn [{:keys [uuid name z span at in center anchor drift phase]}] (let [anchor (or anchor default-anchor)] - [uuid {:id uuid :name name :kind :symbol :of symbol + [uuid {:id uuid :name name :kind :instance :of symbol :parent :root :z z :span span :time {:mode :map :at at :in in :rate 1} :channels {[:xform :pos] (if drift @@ -68,6 +68,6 @@ pan (assoc [:audio :pan] pan))}]) audio))] (assoc source :name name :width width :height height - :timelines (assoc (:timelines source) + :symbols (assoc (:symbols source) :main {:id :main :frames frames :nodes nodes} symbol (assoc original :id symbol))))) diff --git a/frontend/src/arthur/demo/swarm.cljs b/frontend/src/arthur/demo/swarm.cljs index 0ef0010..e03f103 100644 --- a/frontend/src/arthur/demo/swarm.cljs +++ b/frontend/src/arthur/demo/swarm.cljs @@ -153,7 +153,7 @@ :fps fps :width 320 :height 200 - :timelines + :symbols {:main {:id :main :frames frames diff --git a/frontend/src/arthur/demo/take.cljs b/frontend/src/arthur/demo/take.cljs index 9f5b25a..dc3e737 100644 --- a/frontend/src/arthur/demo/take.cljs +++ b/frontend/src/arthur/demo/take.cljs @@ -12,7 +12,7 @@ │ FREEZE ──▶ channels on nodes │ - timeline/resolver ──▶ raster + symbol/resolver ──▶ raster — and the order of that diagram is the whole argument for the stage split. The anchor fit is knob-free. Conditioning smooths its four parameters. The rings are diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index 16a6a6e..ffa6d1c 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -1,47 +1,43 @@ (ns arthur.domain.clip - "A CLIP: the unit of work, and a library of timelines. + "A CLIP: the unit of work, and a library of symbols. {:name \"take\" :fps 30 :width 320 :height 200 :analysis {...} :subjects {...} :features {...} :groups {...} - :timelines {:main {:id :main :frames 229 :nodes {...}}}} + :symbols {:main {:id :main :frames 229 :nodes {...}}}} Every field here is a fact about the clip and NOT about a bag of nodes, which is the cut this namespace exists to make. Before it, one map carried both: `:fps`, the stage dimensions, the analysis record and the tracking identities sat beside - `:nodes`, and `arthur.db` said of it — correctly — that they \"sit on the scene - map only because there is one clip per scene today\". The cost of leaving them - together was not untidiness. It was that a SYMBOL had nowhere to live: a library - timeline is a bag of nodes with a frame space and nothing else, so under the old - shape it would have had to be a clip with seven meaningless fields, or a second - structure with the same `:nodes` key that every walk had to be taught about. + `:nodes`. The cost of leaving them together was not untidiness. It was that a + SYMBOL had nowhere to live: a symbol is a bag of nodes with a frame space and + nothing else, so under the old shape it would have had to be a clip with seven + meaningless fields. - Now there is one node-holding type — `arthur.domain.timeline` — and a clip holds - a MAP of them. A `:kind :symbol` instance names a timeline in `:timelines`, - and the clip resolver gives each placement its own reading heads. + Now there is one node-holding type — `arthur.domain.symbol` — and a clip holds + a MAP of them. A `:kind :instance` node places one symbol inside another, and + the clip resolver gives each instance its own reading heads. - THE ROOT TIMELINE HAS A RESERVED ID, `:main`, rather than the clip carrying a - pointer to it. A pointer is a field that can be wrong — it can name a timeline - that is not there, and then every reader needs a fallback — where a reserved name - can only be absent, which `problems` reports once. Flash reserves `_root` the - same way and for the same reason. Nothing else about `:main` is special: it is an - ordinary entry in the map, and a symbol is another one. + NO SYMBOL IS SPECIAL. There is no reserved root and no pointer to one: which + symbol is on screen is the editor's state, not the document's, and every + function here that needs a symbol is told which. A new document has one symbol + called `:main` because it has to be called something, and that is all the name + means — it can be renamed, placed inside another symbol or deleted like any of + them. `unplaced` answers the question a reserved root used to: which symbols + nothing else places, and so which ones a person opening the document wants. WHY :fps IS HERE AND :frames IS NOT. A rate is how fast the whole clip plays - against its audio, and a nested timeline cannot have one of its own — retiming an + against its audio, and a nested symbol cannot have one of its own — retiming an instance is `:rate` on its `:time` map, which is a factor and not a rate. A - frame COUNT is a property of a frame space, so every timeline has its own." + frame COUNT is a property of a frame space, so every symbol has its own." + (:refer-clojure :exclude [symbol]) (:require [arthur.domain.feature :as feature] [arthur.domain.node :as node] [arthur.domain.palette :as pal] [arthur.domain.pose :as pose] - [arthur.domain.timeline :as timeline])) - -(def ^:const root-id - "The reserved id of the timeline a clip plays. See the namespace docstring." - :main) + [arthur.domain.symbol :as symbol])) (def clip-keys "Every top-level field of a clip, and the reason `arthur.domain.leaf` refuses @@ -50,38 +46,56 @@ that loses something on every round trip, which is the one bug a persistence layer must not be able to have. Add the field here and to `leaf/leaves` and `leaf/clip` in the same commit." - #{:name :fps :analysis :subjects :features :groups :width :height :timelines}) + #{:name :fps :analysis :subjects :features :groups :width :height :symbols}) -(defn timeline - "One of the clip's timelines, by id." - [clip id] - (get-in clip [:timelines id])) - -(defn root - "The timeline the clip plays." - [clip] - (timeline clip root-id)) +(defn symbol + "One of the clip's symbols, by id." + [clip sid] + (get-in clip [:symbols sid])) (defn frames - "The clip's length, which is its root timeline's frame space and is not written - down twice. Reading it off the root is what stops the two from disagreeing." + "A symbol's length. Read off the symbol, never copied beside it." + [clip sid] + (:frames (symbol clip sid))) + +(defn update-symbol + "Apply f to one symbol in place." + [clip sid f & args] + (apply update-in clip [:symbols sid] f args)) + +(defn places + "The ids of the symbols `sid` places, directly." + [clip sid] + (into #{} (keep (fn [n] (when (= :instance (:kind n)) (:of n)))) + (vals (:nodes (symbol clip sid))))) + +(defn contains-symbol? + "Whether `inner` is `outer` or is placed anywhere inside it. Placing `outer` + into `inner` when this is true is a cycle." + [clip outer inner] + (let [seen (volatile! #{})] + (letfn [(walk [sid] + (or (= sid inner) + (when-not (@seen sid) + (vswap! seen conj sid) + (some walk (places clip sid)))))] + (boolean (walk outer))))) + +(defn unplaced + "The symbols no other symbol places, sorted by id. What to open when a + document is opened." [clip] - (:frames (root clip))) + (let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))] + (vec (sort-by str (remove placed (keys (:symbols clip))))))) -(defn update-timeline - "Apply f to one timeline in place." - [clip id f & args] - (apply update-in clip [:timelines id] f args)) - -(defn update-root [clip f & args] - (apply update-timeline clip root-id f args)) - -(defn nodes - "The root timeline's nodes. A convenience for the many callers that mean the - root and would otherwise spell it out; anything that could mean a symbol says - which timeline instead." +(defn opens-on + "The symbol a document opens on: the longest one nothing else places, ties + broken by id. The symbol that contains everything else is the longest of the + unplaced ones in every document made so far, and a reserved name is what this + replaces." [clip] - (:nodes (root clip))) + (first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)]) + (unplaced clip)))) (def ^:const blank-frames "How long a new document is before anything says otherwise. Four seconds at 30, @@ -89,9 +103,9 @@ 120) (defn blank - "A new, empty document. + "A new, empty document: one empty symbol. - `:nodes` is empty rather than seeded with a layer, because an empty timeline is + `:nodes` is empty rather than seeded with a layer, because an empty symbol is a true statement and a layer nobody asked for is one more thing to delete. The tracking maps are present and empty for the same reason `clip-keys` exists: a field that is sometimes absent is a field every reader needs a fallback for." @@ -100,39 +114,39 @@ :fps 30 :width 320 :height 200 :subjects {} :features {} :groups {} - :timelines {root-id {:id root-id :frames blank-frames :nodes {}}}}) + :symbols {:main {:id :main :frames blank-frames :nodes {}}}}) (defn place-symbol - "An instance of library timeline `tid`, on the root timeline, at `frame`. + "An instance of symbol `sid`, inside symbol `into`, at `frame` of `into`. THE UUID IS AN ARGUMENT. A placement's identity is the key it has in the node map — it is what `:linked-to`, an export target and a saved leaf all name — so generating one in here would make this function's result depend on when it was - called, and this namespace is the pure one. `demo/stage_8625.edn` authors its - placements' uuids by hand for the same reason, in more words. + called, and this namespace is the pure one. The instance's own time starts where it was dropped: `:at frame` with `:in 0` - means local frame 0 of the symbol plays on `frame` of the stage, which is what - dragging something onto a playhead is asking for. `:span` runs to the end of - the root's frame space rather than to the symbol's length, because a symbol - shorter than the space it is placed in should hold its last frame rather than - disappear." - [clip tid frame uuid [x y]] - (let [target (timeline clip tid) - end (frames clip)] - (if (or (nil? target) (= root-id tid) (nil? frame) (neg? frame) (>= frame end)) + means local frame 0 of the symbol plays on `frame` of `into`, which is what + dragging something onto a playhead is asking for. + + Refused, returning the clip unchanged, when it would make a cycle: a symbol + cannot be placed inside itself or inside anything it places." + [clip into sid frame uuid [x y]] + (let [target (symbol clip sid) + end (frames clip into)] + (if (or (nil? target) (nil? end) (nil? frame) (neg? frame) (>= frame end) + (contains-symbol? clip sid into)) clip - (update-root - clip assoc-in [:nodes uuid] + (update-symbol + clip into assoc-in [:nodes uuid] {:id uuid - :name (name tid) - :kind :symbol - :of tid + :name (name sid) + :kind :instance + :of sid :parent nil ;; Lexicographic draw order, as `domain/paint` does it: a placement made ;; later sits above one made earlier, and neither has to renumber. - :z (str "z" (js/Date.now) "-" (name tid)) - :span [frame end] + :z (str "z" (js/Date.now) "-" (name sid)) + :span [frame (min end (+ frame (:frames target)))] :time {:mode :map :at frame :in 0 :rate 1} :channels {[:xform :pos] {:animated? false :value [x y]}}})))) @@ -158,35 +172,30 @@ op))) (defn resolver - "Resolve a clip, including each library timeline placed by a symbol instance. + "Resolve symbol `sid` of a clip, including every symbol its instances place. - Each instance owns its own timeline resolver, so two offsets never share a + Each instance owns its own symbol resolver, so two offsets never share a channel cursor or point buffer. The returned ops must be drawn before the next - frame, as with timeline/resolver. + frame, as with symbol/resolver. - `root` is which timeline to resolve AS the root, and it defaults to the clip's. - Passing a symbol's id is the whole of \"render that symbol\": a library timeline - and the clip's own are the same type, so a symbol resolves by being rooted - rather than by a second code path — which is the return on collapsing the two - into `domain/timeline`. Its frame space is its own `:frames`, and nested symbols - inside it still resolve, because this is the function that knows how to do that." - ([clip store] (resolver clip store pal/index-of root-id)) - ([clip store palette] (resolver clip store palette root-id)) - ([clip store palette root] (resolver clip store palette root nil)) - ([clip store palette root {:keys [picture-fps] :as opts}] - (letfn [(build [tid chain pose-tracks] - (when (some #{tid} chain) - (throw (ex-info "symbol timeline cycle" {:chain (conj chain tid)}))) - (let [tl (or (timeline clip tid) - (throw (ex-info "symbol names a missing timeline" {:timeline tid}))) - nodes (:nodes tl) - rank (timeline/draw-rank nodes (timeline/order nodes)) + Any symbol can be resolved and none is the default: the frame space is the + resolved symbol's own `:frames`, and nested instances inside it still resolve, + because this is the function that knows how to do that." + ([clip store palette sid] (resolver clip store palette sid nil)) + ([clip store palette sid {:keys [picture-fps] :as opts}] + (letfn [(build [sid chain pose-tracks] + (when (some #{sid} chain) + (throw (ex-info "symbol cycle" {:chain (conj chain sid)}))) + (let [sym (or (symbol clip sid) + (throw (ex-info "an instance names a missing symbol" {:symbol sid}))) + nodes (:nodes sym) + rank (symbol/draw-rank nodes (symbol/order nodes)) ids (sort-by rank (keys nodes)) - own (timeline/resolver tl store palette pose-tracks + own (symbol/resolver sym store palette pose-tracks (assoc opts :source-fps (:fps clip))) children (into {} - (for [[id n] nodes :when (= :symbol (:kind n))] - [id (build (:of n) (conj chain tid) + (for [[id n] nodes :when (= :instance (:kind n))] + [id (build (:of n) (conj chain sid) (get-in n [:playback :tracks]))]))] (fn [f] (let [by-id (into {} (map (juxt :node identity)) (own f))] @@ -194,10 +203,10 @@ (mapcat (fn [id] (let [n (get nodes id)] - (if (= :symbol (:kind n)) - (let [m (timeline/world-of own id) - local (timeline/frame-of own id) - target (timeline clip (:of n)) + (if (= :instance (:kind n)) + (let [m (symbol/world-of own id) + local (symbol/frame-of own id) + target (symbol clip (:of n)) length (:frames target) frame (when (and m (number? local)) (if (get-in n [:time :loop?]) @@ -208,7 +217,7 @@ [])) (when-let [op (get by-id id)] [op])))) ids))))))] - (build root [] nil)))) + (build sid [] nil)))) (defn problems "Human-readable reasons this clip will not evaluate or save." @@ -217,28 +226,26 @@ (concat (for [k (remove clip-keys (keys clip))] (str "clip has a field with no leaf to save it in: " (pr-str k))) - (when-not (map? (:timelines clip)) - [":timelines must be a map of id -> timeline"]) - (when (and (map? (:timelines clip)) (nil? (root clip))) - [(str "no " (pr-str root-id) " timeline — a clip plays the one with the reserved id")]) + (when-not (map? (:symbols clip)) + [":symbols must be a map of id -> symbol"]) (when-not (or (nil? (:fps clip)) (and (number? (:fps clip)) (pos? (:fps clip)))) [(str ":fps is " (pr-str (:fps clip)) " — a rate is a positive number")]) - (for [[id tl] (:timelines clip) - :when (not= id (:id tl))] - (str "timeline under key " (pr-str id) " has :id " (pr-str (:id tl)))) - (for [[id tl] (:timelines clip) - p (timeline/problems tl)] - (str "timeline " (pr-str id) ": " p)) - (for [[tid tl] (:timelines clip) - [id n] (:nodes tl) - :when (and (= :symbol (:kind n)) - (not (contains? (:timelines clip) (:of n))))] - (str "timeline " (pr-str tid) " symbol " (pr-str id) - " names missing timeline " (pr-str (:of n)))) - (for [[tid tl] (:timelines clip) - [id n] (:nodes tl) - :when (= :symbol (:kind n)) - :let [target (get-in clip [:timelines (:of n)]) + (for [[id sym] (:symbols clip) + :when (not= id (:id sym))] + (str "symbol under key " (pr-str id) " has :id " (pr-str (:id sym)))) + (for [[id sym] (:symbols clip) + p (symbol/problems sym)] + (str "symbol " (pr-str id) ": " p)) + (for [[sid sym] (:symbols clip) + [id n] (:nodes sym) + :when (and (= :instance (:kind n)) + (not (contains? (:symbols clip) (:of n))))] + (str "symbol " (pr-str sid) " instance " (pr-str id) + " names missing symbol " (pr-str (:of n)))) + (for [[sid sym] (:symbols clip) + [id n] (:nodes sym) + :when (= :instance (:kind n)) + :let [target (get-in clip [:symbols (:of n)]) active (filter (fn [node] (some :pose-sampled? (vals (:channels node)))) (vals (:nodes target))) @@ -247,11 +254,11 @@ (map #(vector :node (:id %)) active)))] p (pose/problems (get-in n [:playback :tracks]) (:frames target) groups)] - (str "timeline " (pr-str tid) " symbol " (pr-str id) ": " p)) - (for [[tid tl] (:timelines clip) - [id n] (:nodes tl) + (str "symbol " (pr-str sid) " instance " (pr-str id) ": " p)) + (for [[sid sym] (:symbols clip) + [id n] (:nodes sym) :when (and (= :audio (:kind n)) (:linked-to n) - (not (contains? (:nodes tl) (:linked-to n))))] - (str "timeline " (pr-str tid) " audio " (pr-str id) + (not (contains? (:nodes sym) (:linked-to n))))] + (str "symbol " (pr-str sid) " audio " (pr-str id) " links to missing node " (pr-str (:linked-to n)))) (feature/problems clip)))) diff --git a/frontend/src/arthur/domain/feature.cljs b/frontend/src/arthur/domain/feature.cljs index 69839e2..13a3d4b 100644 --- a/frontend/src/arthur/domain/feature.cljs +++ b/frontend/src/arthur/domain/feature.cljs @@ -1,6 +1,6 @@ (ns arthur.domain.feature "Tracked subjects, feature ownership, and eye-pair settings. - Features name their timeline explicitly; node ids are local to that timeline." + Features name their symbol explicitly; node ids are local to that symbol." (:require [arthur.domain.params :as params])) (defn owned @@ -44,14 +44,14 @@ clip)) (defn problems - "Check tracked identities and timeline-local node ownership." + "Check tracked identities and symbol-local node ownership." [clip] (let [subjects (:subjects clip) features (:features clip) groups (:groups clip) memberships (mapcat (comp :members val) groups) node-owners (for [[_ f] features n (:nodes f)] - [(:timeline f) n])] + [(:symbol f) n])] (vec (concat (for [[id s] subjects :when (not= id (:id s))] @@ -60,8 +60,8 @@ :when (not (params/valid-settings? :subject (or (:params s) {})))] (str "subject " (pr-str id) " has invalid settings")) (for [[id _] subjects - :when (not (seq (get-in clip [:timelines id :nodes :head :measured])))] - (str "subject " (pr-str id) " has no measured head in its timeline")) + :when (not (seq (get-in clip [:symbols id :nodes :head :measured])))] + (str "subject " (pr-str id) " has no measured head in its symbol")) (for [[id f] features :when (not= id (:id f))] (str "feature " (pr-str id) " has a different :id")) (for [[id f] features :when (not (contains? subjects (:subject f)))] @@ -72,10 +72,10 @@ :when (not (params/valid-settings? (:area f) (or (:params f) {})))] (str "feature " (pr-str id) " has invalid settings for " (pr-str (:area f)))) (for [[id f] features - :when (not (contains? (:timelines clip) (:timeline f)))] - (str "feature " (pr-str id) " names a missing timeline")) + :when (not (contains? (:symbols clip) (:symbol f)))] + (str "feature " (pr-str id) " names a missing symbol")) (for [[id f] features node-id (:nodes f) - :let [owned-nodes (get-in clip [:timelines (:timeline f) :nodes])] + :let [owned-nodes (get-in clip [:symbols (:symbol f) :nodes])] :when (not (contains? owned-nodes node-id))] (str "feature " (pr-str id) " refers to missing node " (pr-str node-id))) (for [[id n] (frequencies node-owners) :when (> n 1)] diff --git a/frontend/src/arthur/domain/leaf.cljs b/frontend/src/arthur/domain/leaf.cljs index d5fbc6d..f318ef0 100644 --- a/frontend/src/arthur/domain/leaf.cljs +++ b/frontend/src/arthur/domain/leaf.cljs @@ -11,21 +11,21 @@ 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//subject/ a tracked subject and its params clip//feature/ one feature: area, nodes, params clip//group/ an eye pair and its shared params - 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 + clip//symbol/ frames, and a palette one day + clip//symbol//node/ kind, parent, stencil, z, time + clip//symbol//channel// + clip//symbol//measured/ the channels a re-freeze owns - WHY NODES SIT UNDER A TIMELINE. A clip holds a library of timelines. Its root - and each symbol have their own nodes, so the timeline id is a path segment. - The root is `main`, and a symbol's nodes use the same path shape. + WHY NODES SIT UNDER A SYMBOL. A clip holds a library of symbols and each has + its own nodes, so the symbol id is a path segment. No symbol has a reserved + segment: `main` in a path is an id like any other. - `:frames` MOVED OFF `timing` onto the timeline. A timeline is a frame space and a + `:frames` MOVED OFF `timing` onto the symbol. A symbol is a frame space and a clip is a rate, so `timing` holds `:fps` alone. Both used to be in one leaf, which - is how a nested timeline's length would have had nowhere to go. + is how a nested symbol's length would have had nowhere to go. WHY THESE BOUNDARIES. Last-writer-wins only clobbers when its unit is too big, so the cut is chosen so that the things people do simultaneously land on @@ -56,7 +56,7 @@ it is one character rather than a scheme." (:require [arthur.domain.clip :as clip] [arthur.domain.sha256 :as sha] - [arthur.domain.timeline :as timeline] + [arthur.domain.symbol :as symbol] [clojure.string :as str])) ;; --------------------------------------------------------------------------- @@ -124,11 +124,11 @@ (when (seq unknown) (throw (ex-info "the clip has a field with no leaf to save it in; see arthur.domain.clip/clip-keys" {:unknown (vec (sort-by str unknown))})))) - (doseq [[id tl] (:timelines clip)] - (let [unknown (remove timeline/timeline-keys (keys tl))] + (doseq [[id sym] (:symbols clip)] + (let [unknown (remove symbol/symbol-keys (keys sym))] (when (seq unknown) - (throw (ex-info "a timeline has a field with no leaf to save it in; see arthur.domain.timeline/timeline-keys" - {:timeline id :unknown (vec (sort-by str unknown))}))))) + (throw (ex-info "a symbol has a field with no leaf to save it in; see arthur.domain.symbol/symbol-keys" + {:symbol id :unknown (vec (sort-by str unknown))}))))) (let [at (fn [& parts] (str/join "/" (into ["clip" (segment cid)] parts))) some-leaf (fn [path v] (when (seq v) {path v}))] (apply merge @@ -140,30 +140,30 @@ (for [[id v] (:subjects clip)] {(at "subject" (segment id)) v}) (for [[id v] (:features clip)] {(at "feature" (segment id)) v}) (for [[id v] (:groups clip)] {(at "group" (segment id)) v}) - ;; The timeline's own facts. `:id` is the path segment, so writing it + ;; The symbol's own facts. `:id` is the path segment, so writing it ;; into the value as well would be the one field a rename could ;; disagree with itself about; `clip` puts it back. - (for [[tid tl] (:timelines clip)] - {(at "timeline" (segment tid)) - (select-keys tl [:frames :palette])}) - (for [[tid tl] (:timelines clip) - [id n] (:nodes tl)] - {(at "timeline" (segment tid) "node" (segment id)) + (for [[sid sym] (:symbols clip)] + {(at "symbol" (segment sid)) + (select-keys sym [:frames :palette])}) + (for [[sid sym] (:symbols clip) + [id n] (:nodes sym)] + {(at "symbol" (segment sid) "node" (segment id)) (apply dissoc n node-channel-keys)}) - (for [[tid tl] (:timelines clip) - [id n] (:nodes tl) + (for [[sid sym] (:symbols clip) + [id n] (:nodes sym) :when (seq (:measured n))] - {(at "timeline" (segment tid) "measured" (segment id)) (:measured n)}) - (for [[tid tl] (:timelines clip) - [id n] (:nodes tl) + {(at "symbol" (segment sid) "measured" (segment id)) (:measured n)}) + (for [[sid sym] (:symbols clip) + [id n] (:nodes sym) [prop ch] (:channels n)] - {(at "timeline" (segment tid) "channel" (segment id) (prop->path prop)) ch}))))) + {(at "symbol" (segment sid) "channel" (segment id) (prop->path prop)) ch}))))) (defn clip "The inverse of `leaves`, for one clip. Paths belonging to another clip are ignored, so a project's whole leaf map can be handed straight in. - A timeline's `:id` is restored from its path segment rather than read out of the + A symbol's `:id` is restored from its path segment rather than read out of the value, which is why `leaves` does not write it: a segment and a field that both claim to be the id are two places for one fact." [cid leaves] @@ -173,14 +173,14 @@ (let [[_ found kind a b c] (str/split path #"/")] (if-not (= want found) acc - (if (= "timeline" kind) - (let [tid (unsegment a) - acc (assoc-in acc [:timelines tid :id] tid)] + (if (= "symbol" kind) + (let [sid (unsegment a) + acc (assoc-in acc [:symbols sid :id] sid)] (case b - nil (update-in acc [:timelines tid] merge v) - "node" (update-in acc [:timelines tid :nodes (unsegment c)] merge v) - "measured" (assoc-in acc [:timelines tid :nodes (unsegment c) :measured] v) - "channel" (assoc-in acc [:timelines tid :nodes (unsegment c) + nil (update-in acc [:symbols sid] merge v) + "node" (update-in acc [:symbols sid :nodes (unsegment c)] merge v) + "measured" (assoc-in acc [:symbols sid :nodes (unsegment c) :measured] v) + "channel" (assoc-in acc [:symbols sid :nodes (unsegment c) :channels (path->prop (nth (str/split path #"/") 6))] v) (throw (ex-info "not a leaf path" {:path path})))) @@ -210,20 +210,20 @@ content-addressed is that it does not have to travel with tier 1 to be found." [leaves] (let [parts (into {} (map (juxt identity #(vec (str/split % #"/")))) (keys leaves)) - ;; A node leaf, by (clip, timeline, node). Under a timeline id, because a - ;; symbol and the root may both hold a `:mouth` and a channel of one is not - ;; a channel of the other. + ;; A node leaf, by (clip, symbol, node). Under a symbol id, because two + ;; symbols may both hold a `:mouth` and a channel of one is not a channel + ;; of the other. nodes (into #{} (keep (fn [[_ p]] - (when (and (= 6 (count p)) (= "timeline" (nth p 2)) + (when (and (= 6 (count p)) (= "symbol" (nth p 2)) (= "node" (nth p 4))) [(nth p 1) (nth p 3) (nth p 5)]))) parts) ;; Which segment index holds the kind, and what shapes are legal. legal? (fn [p] (and (= "clip" (first p)) (second p) - (if (= "timeline" (nth p 2 nil)) + (if (= "symbol" (nth p 2 nil)) (case (count p) - 4 true ; the timeline itself + 4 true ; the symbol itself 6 (#{"node" "measured"} (nth p 4)) 7 (= "channel" (nth p 4)) false) @@ -238,13 +238,13 @@ :when (not (legal? p))] (str (pr-str path) " is not a leaf path")) (for [[path p] (sort-by key parts) - :when (and (legal? p) (= "timeline" (nth p 2 nil)) (>= (count p) 6) + :when (and (legal? p) (= "symbol" (nth p 2 nil)) (>= (count p) 6) (#{"channel" "measured"} (nth p 4)) (not (contains? nodes [(nth p 1) (nth p 3) (nth p 5)])))] (str (pr-str path) " addresses a node with no node leaf")) (for [[path p] (sort-by key parts) :let [v (get leaves path)] - :when (and (legal? p) (= "timeline" (nth p 2 nil)) (= 7 (count p)) + :when (and (legal? p) (= "symbol" (nth p 2 nil)) (= 7 (count p)) (:dense v) (not (sha/key? (:store (:dense v)))))] (str (pr-str path) " names tier 2 as " (pr-str (:store (:dense v))) " — a dense channel in a saved document names a content address")))))) diff --git a/frontend/src/arthur/domain/node.cljs b/frontend/src/arthur/domain/node.cljs index bc2d432..a4ffb07 100644 --- a/frontend/src/arthur/domain/node.cljs +++ b/frontend/src/arthur/domain/node.cljs @@ -21,9 +21,9 @@ "`:bitmap` is in the vocabulary and not implemented; it is here so that a scene that names one fails as \"not implemented\" rather than as \"not a kind\"." - #{:poly :disc :rect :group :bitmap :symbol :audio}) + #{:poly :disc :rect :group :bitmap :instance :audio}) -(def implemented-kinds #{:poly :disc :rect :group :symbol :audio}) +(def implemented-kinds #{:poly :disc :rect :group :instance :audio}) (def xform-paths "In composition order, which is also the order they have to be sampled in. @@ -45,7 +45,7 @@ change to this spec silently change what gets drawn." (let [base (into #{[:vis]} xform-paths)] {:group base - :symbol base + :instance base :audio (into base [[:audio :gain] [:audio :pan] [:audio :rate]]) :poly (into base [[:geom :pts] [:style :color]]) ;; A disc's radius is framed in practice — iris size is a knob, not a @@ -111,7 +111,7 @@ it on most frames, so the lead slider reads as doing nothing at exposures above 1, which is indistinguishable from the slider being unwired. - Composed along the parent chain, outermost first, by timeline/eval-frame. Two + Composed along the parent chain, outermost first, by symbol/eval-frame. Two rules fall out and they are different rules: exposure INHERITS STRICTLY, because a head cutting on odd frames against a mouth cutting on even ones reads as two performances; offset is PER-NODE by design, because mouth lead applies @@ -122,13 +122,13 @@ (if (= mode :inherit) f (do - (when (and (not (#{:symbol :audio} (:kind n))) rate (not= rate 1.0) (not= rate 1)) - (throw (ex-info "time map :rate belongs to a symbol or audio instance" + (when (and (not (#{:instance :audio} (:kind n))) rate (not= rate 1.0) (not= rate 1)) + (throw (ex-info "time map :rate belongs to an instance or an audio node" {:node (:id n) :time (:time n)}))) (when (and sample-fps (not (and source-fps (pos? source-fps)))) (throw (ex-info "picture sampling needs a positive source fps" {:node (:id n) :time (:time n)}))) - (cond-> (if (#{:symbol :audio} (:kind n)) + (cond-> (if (#{:instance :audio} (:kind n)) (+ (or in 0) (* (or rate 1) (- f (or at 0)))) f) sample-fps (sample-frame source-fps sample-fps) @@ -265,10 +265,10 @@ (not (contains? implemented-kinds k))) (conj (str ":kind " k " is in the vocabulary but not implemented")) - (and (= k :symbol) (nil? (:of n))) (conj "a symbol instance needs :of") + (and (= k :instance) (nil? (:of n))) (conj "an instance needs :of") (and (= k :audio) (nil? (get-in n [:source :footage]))) (conj "an audio instance needs :source :footage") - (and (#{:symbol :audio} k) (some? (get-in n [:time :rate])) + (and (#{:instance :audio} k) (some? (get-in n [:time :rate])) (not (pos? (get-in n [:time :rate])))) (conj "an instance's :rate must be positive") (nil? (:z n)) (conj "no :z — draw order is authored per scene, not implied by the tree") diff --git a/frontend/src/arthur/domain/paint.cljs b/frontend/src/arthur/domain/paint.cljs index 6e943a2..53f1fe4 100644 --- a/frontend/src/arthur/domain/paint.cljs +++ b/frontend/src/arthur/domain/paint.cljs @@ -1,12 +1,13 @@ (ns arthur.domain.paint - "Small authored polygon operations. Paint nodes read timeline frames directly; - the roto root's exposure and picture sampling must not quantise a hand edit." + "Small authored polygon operations, each on a named symbol. Paint nodes read + their symbol's frames directly; a roto instance's exposure and picture sampling + must not quantise a hand edit." (:require [arthur.domain.channel :as channel])) (def geometry [:geom :pts]) -(defn shapes [clip] - (->> (get-in clip [:timelines :main :nodes]) +(defn shapes [clip sid] + (->> (get-in clip [:symbols sid :nodes]) (filter (fn [[_ node]] (:paint? node))) (sort-by (comp :z val)) vec)) @@ -15,21 +16,21 @@ (let [frames (sort (keys (:keys ch)))] (or (last (take-while #(<= % frame) frames)) (first frames)))) -(defn new-shape [clip id frame points color] - (let [end (get-in clip [:timelines :main :frames]) +(defn new-shape [clip sid id frame points color] + (let [end (get-in clip [:symbols sid :frames]) z (str "z" (js/Date.now) "-" (name id))] (if (and (<= 0 frame) (< frame end) (>= (count points) 6) (even? (count points))) - (assoc-in clip [:timelines :main :nodes id] - {:id id :name (str "shape " (inc (count (shapes clip)))) + (assoc-in clip [:symbols sid :nodes id] + {:id id :name (str "shape " (inc (count (shapes clip sid)))) :kind :poly :paint? true :parent nil :z z :span [frame end] :channels {geometry (channel/keyed {frame points}) [:style :color] (channel/framed color)}}) clip))) -(defn add-key [clip id frame] - (let [path [:timelines :main :nodes id] +(defn add-key [clip sid id frame] + (let [path [:symbols sid :nodes id] node (get-in clip path) ch (get-in node [:channels geometry]) [start end] (:span node)] @@ -38,20 +39,20 @@ (vec (channel/value-at ch frame))) clip))) -(defn set-vertex [clip id key-frame vertex [x y]] - (let [path [:timelines :main :nodes id :channels geometry :keys key-frame] +(defn set-vertex [clip sid id key-frame vertex [x y]] + (let [path [:symbols sid :nodes id :channels geometry :keys key-frame] points (get-in clip path) i (* 2 vertex)] (if (and points (< (inc i) (count points))) (assoc-in clip path (-> points (assoc i x) (assoc (inc i) y))) clip))) -(defn set-segment-interp [clip id key-frame interp] - (let [node (get-in clip [:timelines :main :nodes id]) +(defn set-segment-interp [clip sid id key-frame interp] + (let [node (get-in clip [:symbols sid :nodes id]) keys (get-in node [:channels geometry :keys])] (if (and (:paint? node) (contains? keys key-frame) (some #(< key-frame %) (clojure.core/keys keys)) (#{:hold :linear} interp)) - (assoc-in clip [:timelines :main :nodes id :channels geometry + (assoc-in clip [:symbols sid :nodes id :channels geometry :segments key-frame] interp) clip))) diff --git a/frontend/src/arthur/domain/png.cljs b/frontend/src/arthur/domain/png.cljs index 7958255..97b8c4c 100644 --- a/frontend/src/arthur/domain/png.cljs +++ b/frontend/src/arthur/domain/png.cljs @@ -88,7 +88,7 @@ (defn encoder "(fn [raster ramp] -> promise of PNG bytes), for one stage size and one zoom. - Built once per export rather than per frame, in the shape `timeline/resolver` + Built once per export rather than per frame, in the shape `symbol/resolver` already uses: everything that does not change frame to frame is held here. What that buys is the scanline scratch, which at zoom 6 is seven megabytes — a per-frame allocation of that size is the one thing that would make a long export diff --git a/frontend/src/arthur/domain/pose.cljs b/frontend/src/arthur/domain/pose.cljs index c309a22..d305fd6 100644 --- a/frontend/src/arthur/domain/pose.cljs +++ b/frontend/src/arthur/domain/pose.cljs @@ -32,16 +32,17 @@ default-frame)) (defn put-cut - "Set one held pose on a symbol instance. Earlier motion stays untouched." - [clip instance group at source] - (let [node (get-in clip [:timelines :main :nodes instance]) - symbol (get-in clip [:timelines (:of node)]) - length (:frames symbol) + "Set one held pose on an instance inside symbol `sid`. Earlier motion stays + untouched." + [clip sid instance group at source] + (let [node (get-in clip [:symbols sid :nodes instance]) + placed (get-in clip [:symbols (:of node)]) + length (:frames placed) active (filter (fn [n] (some :pose-sampled? (vals (:channels n)))) - (vals (:nodes symbol))) + (vals (:nodes placed))) groups (set (map #(or (:pose-group %) (:id %)) active)) ids (set (map :id active))] - (when-not (and (= :symbol (:kind node)) + (when-not (and (= :instance (:kind node)) (or (contains? groups group) (and (vector? group) (= 2 (count group)) (= :node (first group)) @@ -50,17 +51,17 @@ (integer? source) (<= 0 source) (< source length)) (throw (ex-info "invalid stage pose cut" {:instance instance :group group :at at :source source}))) - (update-in clip [:timelines :main :nodes instance :playback :tracks group] + (update-in clip [:symbols sid :nodes instance :playback :tracks group] #(assoc (or % {}) at source)))) (defn remove-cut "Remove a cut; an empty track again follows the normal generated motion." - [clip instance group at] - (let [path [:timelines :main :nodes instance :playback :tracks group]] + [clip sid instance group at] + (let [path [:symbols sid :nodes instance :playback :tracks group]] (if-let [entries (get-in clip path)] (if-let [remaining (not-empty (dissoc entries at))] (assoc-in clip path remaining) - (update-in clip [:timelines :main :nodes instance :playback :tracks] + (update-in clip [:symbols sid :nodes instance :playback :tracks] dissoc group)) clip))) diff --git a/frontend/src/arthur/domain/project.cljs b/frontend/src/arthur/domain/project.cljs index 41163fe..3f68019 100644 --- a/frontend/src/arthur/domain/project.cljs +++ b/frontend/src/arthur/domain/project.cljs @@ -29,6 +29,12 @@ (:require [arthur.domain.leaf :as leaf] [arthur.domain.wire :as wire])) +(def schema-version + "The stored document format this client reads and writes. 2 is symbols: leaf + paths say `symbol`, a placing node is `:kind :instance`, and no symbol id is + reserved. `clips/migrations/0007` moved every saved project from 1." + 2) + (defn block-keys "Every tier-2 key a leaf map names, in a stable order." [leaves] @@ -53,8 +59,8 @@ round-trip a clip through `JSON.parse(JSON.stringify(...))` and be running the same conversion the network runs, rather than a CLJS-shaped rehearsal of it. The one thing a keywordising `js->clj` would quietly break is the leaf paths — - `:clip/c1/timeline/main/node/mouth` is a keyword whose `name` is - \"c1/timeline/main/node/mouth\", so the + `:clip/c1/symbol/main/node/mouth` is a keyword whose `name` is + \"c1/symbol/main/node/mouth\", so the \"clip/\" would be lost on the way back in. Refuses a document `domain/leaf` calls unaddressable, which is where a hand-made diff --git a/frontend/src/arthur/domain/raster.cljs b/frontend/src/arthur/domain/raster.cljs index 22023ed..e279cd0 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 timeline/resolver hands the same + a node's vertex count is known at freeze time, so symbol/resolver hands the same buffer back every frame and a frame allocates nothing. At 30fps per-frame allocation is the only thing that will make this stutter. diff --git a/frontend/src/arthur/domain/timeline.cljs b/frontend/src/arthur/domain/symbol.cljs similarity index 84% rename from frontend/src/arthur/domain/timeline.cljs rename to frontend/src/arthur/domain/symbol.cljs index 09017ae..5c7335c 100644 --- a/frontend/src/arthur/domain/timeline.cljs +++ b/frontend/src/arthur/domain/symbol.cljs @@ -1,38 +1,36 @@ -(ns arthur.domain.timeline - "A TIMELINE: an ordered bag of nodes in its own frame space, and the two ways to +(ns arthur.domain.symbol + "A SYMBOL: an ordered bag of nodes in its own frame space, and the two ways to evaluate it at a frame. {:id :main :frames 229 :nodes {id -> node} :palette nil} - That is the whole type, and EVERYTHING THAT HOLDS NODES IS ONE OF THESE. A - clip's root timeline is one; a symbol in the library is one; a `:kind :symbol` - node is an INSTANCE of one. An earlier arrangement had the clip's node tree and - a library symbol as two structures with the same fields and never said they were - the same thing — the clip map carried `:fps`, `:width`, `:height`, `:analysis` - and the tracking identities alongside `:nodes`, so a symbol had nowhere to live - that was not a clip with seven meaningless fields. Flash's `_root` is a - MovieClip and After Effects' pre-comp is just a layer; collapsing them is what - makes nesting arbitrary and free rather than a feature to be added. + That is the whole type, and EVERYTHING THAT HOLDS NODES IS ONE OF THESE. What + a document opens on is a symbol; what a `:kind :instance` node places is a + symbol; there is no second structure. An earlier arrangement had a root node + tree and a library entry as two structures with the same fields and never said + they were the same thing. Flash's `_root` is a MovieClip and After Effects' + pre-comp is just a layer; collapsing them is what makes nesting arbitrary and + free rather than a feature to be added. - The clip-level facts are in `arthur.domain.clip`. A timeline has a FRAME SPACE, + The clip-level facts are in `arthur.domain.clip`. A symbol has a FRAME SPACE, not a rate and not a size: `:fps` is the clip's, because a rate is a fact about - how fast the whole thing plays, and a nested timeline cannot have its own. + how fast the whole thing plays, and a nested symbol cannot have its own. TWO AXES OF NESTING, and conflating them is why \"nested\" and \"flat with parent pointers\" sound contradictory when they are not. Parent/child is transform - composition WITHIN one timeline and is stored flat with pointers. Instance is a - timeline inside another timeline and is stored by reference into the library. - Each timeline is flat; timelines nest. Every argument for flat storage — + composition WITHIN one symbol and is stored flat with pointers. Instance is a + symbol inside another symbol and is stored by reference into the library. + Each symbol is flat; symbols nest. Every argument for flat storage — addressability, one-field reparenting, structural sharing, per-node sync leaves — is about the first axis and is untouched by the second. Two ways to evaluate one at a frame: - (eval-frame tl f store) THE SPECIFICATION. Allocating, order-free, + (eval-frame sym f store) THE SPECIFICATION. Allocating, order-free, obviously correct. Use it in tests and for a one-off render. - (resolver tl store) -> (fn [f] ops). What playback uses. Caches the + (resolver sym store) -> (fn [f] ops). What playback uses. Caches the topological order and the z paths, holds one CURSOR per channel and one PREALLOCATED point buffer per node, so a frame allocates the op @@ -42,7 +40,7 @@ read and where points are written. That is deliberate: two independent implementations of frame evaluation would drift, and the drift would look like a rendering bug rather than like two functions disagreeing. What differs - between them is exactly the part that can be wrong, and timeline-test asserts + between them is exactly the part that can be wrong, and symbol-test asserts they agree frame for frame in forward, backward and random order. The output is a list of DRAW OPS, and it is the boundary with the rasteriser: @@ -76,12 +74,12 @@ (when-let [p (:parent (get nodes i))] (if (contains? nodes p) p - (throw (ex-info "node's :parent is not in the timeline" + (throw (ex-info "node's :parent is not in the symbol" {:node i :parent p}))))) chain (into [] (comp (take-while some?) (take (inc (count nodes)))) (iterate up id))] (when (> (count chain) (count nodes)) - (throw (ex-info "parent cycle in timeline" {:node id :chain chain}))) + (throw (ex-info "parent cycle in symbol" {:node id :chain chain}))) chain)) (defn depth @@ -129,9 +127,9 @@ "id -> its position in draw order. Computed ONCE. Draw order is a function of the z paths, which are structural — - they change when the timeline changes and never because the playhead moved — so + they change when the symbol changes and never because the playhead moved — so sorting ops by z on every frame was re-deriving a constant thirty times a - second. Here it is derived when the timeline is, and a frame sorts small integers. + second. Here it is derived when the symbol is, and a frame sorts small integers. `sort-by` is stable and `ord` is topological, so nodes sharing a z path keep parent-before-child order without a tiebreak field on every op." @@ -146,9 +144,9 @@ "Tone keyword -> the index the raster writes, in a given palette. `palette` is a map of tone -> index. It is a PARAMETER, not a global: a tone - names which mark this is, and which ramp it is read in belongs to the timeline + names which mark this is, and which ramp it is read in belongs to the symbol the node sits in, so resolution cannot reach for one ambient answer. Today - there is one palette and it is passed in anyway; when timelines carry a + there is one palette and it is passed in anyway; when symbols carry a `:palette` channel, the walk carries the palette in scope exactly as it already carries the parent transform and the local frame. @@ -229,7 +227,7 @@ flow/freeze writes it KEYED, because a threshold crossing is a handful of transitions and hold is the default, and because a human has to be able to fix one frame of it. When something does want a dense one it will land here loudly - instead of blanking the timeline. + instead of blanking the symbol. Absence is not a boolean and is not an error: a subject that is not on the frame has nothing to show." @@ -271,13 +269,13 @@ :rd rd}))))))) (defn- emit - "Emit geometry in the timeline's space. Rect sizes stay fractional until + "Emit geometry in the symbol's space. Rect sizes stay fractional until rasterization, so enclosing symbol transforms can still scale them." [{:keys [palette buf-for]} n {:keys [m rd]} base] (let [colour #(colour-index palette (rd [:style :color]))] (case (:kind n) :group nil - :symbol nil + :instance nil :audio nil :poly @@ -311,20 +309,20 @@ {:node (:id n) :kind (:kind n)}))))) (defn- nodes-of - "The timeline's node map, REFUSING a map that has none. + "The symbol's node map, REFUSING a map that has none. - A clip and a timeline both have an `:id` and both are maps, so handing a CLIP to + A clip and a symbol both have an `:id` and both are maps, so handing a CLIP to an evaluator is the one mistake this type split makes easy — and the result is not an error, it is `(:nodes clip)` being nil and a frame resolving to no ops at all. That reads as a black stage, or, in a benchmark, as \"0 nodes\" and a flattering number. It happened once while the split was being made, which is why this is a guard and not a comment." - [tl] - (let [nodes (:nodes tl)] + [sym] + (let [nodes (:nodes sym)] (when-not (map? nodes) - (throw (ex-info (str "not a timeline: :nodes is " (pr-str nodes) - " — a clip is not a timeline, its `:timelines` hold them") - {:keys (vec (sort-by str (keys tl)))}))) + (throw (ex-info (str "not a symbol: :nodes is " (pr-str nodes) + " — a clip is not a symbol, its `:symbols` hold them") + {:keys (vec (sort-by str (keys sym)))}))) nodes)) (defn- channel-frame @@ -386,18 +384,18 @@ ;; the specification (defn eval-frame - "Timeline at frame f -> draw ops in z order. Pure, and allocates freely. + "Symbol at frame f -> draw ops in z order. Pure, and allocates freely. - `f` is in THIS timeline's frame space. At the clip's root that is clip frames; - inside an instance it is the instance's own space, and the instance boundary is + `f` is in THIS symbol's frame space. For the symbol on screen that is the + transport's frame; inside an instance it is the instance's own space, and the instance boundary is the only place the space changes. This is the definition of what a frame means. `resolver` is what plays it." - ([tl f] (eval-frame tl f nil pal/index-of)) - ([tl f store] (eval-frame tl f store pal/index-of)) - ([tl f store palette] (eval-frame tl f store palette nil nil)) - ([tl f store palette pose-tracks opts] - (let [nodes (nodes-of tl) + ([sym f] (eval-frame sym f nil pal/index-of)) + ([sym f store] (eval-frame sym f store pal/index-of)) + ([sym f store palette] (eval-frame sym f store palette nil nil)) + ([sym f store palette pose-tracks opts] + (let [nodes (nodes-of sym) choices (pose/prepare pose-tracks) anchors (prepared-anchors nodes) {:keys [source-fps picture-fps]} opts @@ -456,12 +454,12 @@ The op maps themselves are allocated fresh, and deliberately: there are a dozen of them per frame against hundreds of points, so pooling them would buy nothing and cost the ability to hand an op list around as plain data." - ([tl] (resolver tl nil pal/index-of nil nil)) - ([tl store] (resolver tl store pal/index-of nil nil)) - ([tl store palette] (resolver tl store palette nil nil)) - ([tl store palette pose-tracks] (resolver tl store palette pose-tracks nil)) - ([tl store palette pose-tracks {:keys [source-fps picture-fps]}] - (let [nodes (nodes-of tl) + ([sym] (resolver sym nil pal/index-of nil nil)) + ([sym store] (resolver sym store pal/index-of nil nil)) + ([sym store palette] (resolver sym store palette nil nil)) + ([sym store palette pose-tracks] (resolver sym store palette pose-tracks nil)) + ([sym store palette pose-tracks {:keys [source-fps picture-fps]}] + (let [nodes (nodes-of sym) choices (pose/prepare pose-tracks) anchors (prepared-anchors nodes) ord (order nodes) @@ -507,20 +505,20 @@ ;; --------------------------------------------------------------------------- -(def timeline-keys - "Every field a timeline may carry, and the reason `arthur.domain.leaf` refuses +(def symbol-keys + "Every field a symbol may carry, and the reason `arthur.domain.leaf` refuses one it does not know: a field added without a leaf to save it in is a field that saves silently and comes back missing. - `:palette` is in the vocabulary and nothing writes one yet. A timeline is where - a ramp belongs — `domain/timeline` takes the palette as a PARAMETER rather than - reaching for a global precisely so that a nested timeline can carry its own — + `:palette` is in the vocabulary and nothing writes one yet. A symbol is where + a ramp belongs — `domain/symbol` takes the palette as a PARAMETER rather than + reaching for a global precisely so that a nested symbol can carry its own — and leaving the field out would make the first one a migration instead of a write." #{:id :frames :nodes :palette}) (defn problems - "Human-readable reasons this timeline will not evaluate. Empty means it will. + "Human-readable reasons this symbol will not evaluate. Empty means it will. Node structure only. The tracking identities — subjects, features, groups — are the CLIP's and are checked by `arthur.domain.clip/problems`, which is not a @@ -530,8 +528,8 @@ Total by construction — it reports a cycle rather than looping on one — because its whole job is to be safe to run over authored data before that data is trusted." - [tl] - (let [nodes (:nodes tl)] + [sym] + (let [nodes (:nodes sym)] (if-not (map? nodes) [":nodes must be a map of id -> node"] (-> [] @@ -541,11 +539,11 @@ (into (for [[id n] nodes :when (and (:parent n) (not (contains? nodes (:parent n))))] (str "node " (pr-str id) " has :parent " (pr-str (:parent n)) - " which is not in the timeline"))) + " which is not in the symbol"))) (into (for [[id n] nodes :when (and (:stencil n) (not (contains? nodes (:stencil n))))] (str "node " (pr-str id) " has :stencil " (pr-str (:stencil n)) - " which is not in the timeline"))) + " which is not in the symbol"))) (into (for [[id n] nodes p (node/problems n)] (str "node " (pr-str id) ": " p))) @@ -554,19 +552,19 @@ :let [anchors (:anchors n)] :when (some? anchors) :when (not (and (map? anchors) (contains? anchors 0) - (integer? (:frames tl)) + (integer? (:frames sym)) (every? #(and (integer? %) (<= 0 %) - (< % (:frames tl))) + (< % (:frames sym))) (concat (keys anchors) (vals anchors))) (seq (:measured n)) (= (:channels n) (:measured n))))] (str "node " (pr-str id) ": :anchors must start at frame 0, name valid measured frames, and read that node's own measured channels"))) - (into (for [k (remove timeline-keys (keys tl))] - (str "timeline has a field with no leaf to save it in: " (pr-str k)))) - (into (when-not (or (nil? (:frames tl)) (and (integer? (:frames tl)) (pos? (:frames tl)))) - [(str ":frames is " (pr-str (:frames tl)) - " — a timeline is a frame SPACE, so its length is a positive integer")])) + (into (for [k (remove symbol-keys (keys sym))] + (str "symbol has a field with no leaf to save it in: " (pr-str k)))) + (into (when-not (or (nil? (:frames sym)) (and (integer? (:frames sym)) (pos? (:frames sym)))) + [(str ":frames is " (pr-str (:frames sym)) + " — a symbol is a frame SPACE, so its length is a positive integer")])) (into (try (doall (map #(depth nodes %) (keys nodes))) nil diff --git a/frontend/src/arthur/events/export.cljs b/frontend/src/arthur/events/export.cljs index 1e202c1..999ba5d 100644 --- a/frontend/src/arthur/events/export.cljs +++ b/frontend/src/arthur/events/export.cljs @@ -2,7 +2,7 @@ "Export, as intents and one effect. The walk is not an event and must not become one: it is a promise chain that - runs for as long as the timeline is long, and re-frame events are the wrong unit + runs for as long as the symbol is long, and re-frame events are the wrong unit for something with a middle. So `::start` collects what the render needs out of the db and hands it to an fx, and the fx dispatches progress back — the same arrangement `events/project`'s save uses, and for the same reason. @@ -44,84 +44,88 @@ (defn target-value "An export target as a `