From 7738c4e1c80ca2ba084a297e48a0e9b5b08e312b Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Mon, 28 Sep 2026 02:48:57 -0400 Subject: [PATCH] Align project paths with timeline model --- clips/tests/test_api.py | 32 ++++++------ docs/architecture.md | 49 +++++++++---------- frontend/src/arthur/domain/leaf.cljs | 10 ++-- frontend/src/arthur/domain/project.cljs | 3 +- frontend/src/arthur/fx/http.cljs | 3 +- frontend/test/arthur/domain/feature_test.cljs | 14 ++++-- 6 files changed, 56 insertions(+), 55 deletions(-) diff --git a/clips/tests/test_api.py b/clips/tests/test_api.py index 69b395e..c7e902e 100644 --- a/clips/tests/test_api.py +++ b/clips/tests/test_api.py @@ -241,13 +241,14 @@ class DocumentTests(TestCase): # Transit-shaped, because that is what a leaf actually holds: a map with a # cache marker, keyword keys, and a frame-keyed inner map. return { - "clip/c1/timing": ["^ ", "~:fps", 30, "~:frames", 48], - "clip/c1/node/mouth": ["^ ", "~:id", "~:mouth", "~:z", "a1"], - "clip/c1/channel/mouth/geom.pts": [ + "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": [ "^ ", "~:animated?", True, "~:dense", ["^ ", "~:store", self.block, "~:offset", 0, "~:stride", 16], ], - "clip/c1/channel/mouth-in/vis": [ + "clip/c1/timeline/main/channel/mouth-in/vis": [ "^ ", "~:animated?", True, "~:keys", ["^ ", "~i0", True, "~i12", False], ], } @@ -263,7 +264,7 @@ class DocumentTests(TestCase): def test_a_document_comes_back_exactly(self): response = self.save() self.assertEqual(200, response.status_code, response.content) - self.assertEqual(4, len(response.json()["written"])) + self.assertEqual(5, len(response.json()["written"])) loaded = self.client.get(f"/api/projects/{self.project.id}").json() self.assertEqual(1, len(loaded["clips"])) @@ -282,22 +283,23 @@ class DocumentTests(TestCase): self.save() first = {leaf.path: leaf.version for leaf in Leaf.objects.all()} moved = self.leaves() - moved["clip/c1/channel/mouth-in/vis"] = [ + moved["clip/c1/timeline/main/channel/mouth-in/vis"] = [ "^ ", "~:animated?", True, "~:keys", ["^ ", "~i0", False], ] response = self.save(moved) - self.assertEqual(["clip/c1/channel/mouth-in/vis"], response.json()["written"]) - self.assertEqual(3, response.json()["unchanged"]) + self.assertEqual(["clip/c1/timeline/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/channel/mouth-in/vis"]) + self.assertEqual(2, after["clip/c1/timeline/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/node/mouth"} + fewer = {k: v for k, v in self.leaves().items() + if k != "clip/c1/timeline/main/node/mouth"} response = self.save(fewer) - self.assertEqual(["clip/c1/node/mouth"], response.json()["removed"]) - self.assertEqual(3, Leaf.objects.count()) + self.assertEqual(["clip/c1/timeline/main/node/mouth"], response.json()["removed"]) + self.assertEqual(4, Leaf.objects.count()) def test_a_save_does_not_disturb_another_clip(self): # A save is not the only way the document changes, so a save that cleared @@ -329,7 +331,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/node/mouth" + url = f"/api/projects/{self.project.id}/leaves/clip/c1/timeline/main/node/mouth" got = self.client.get(url) self.assertEqual('"1"', got["ETag"]) @@ -345,7 +347,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/node/mouth" + url = f"/api/projects/{self.project.id}/leaves/clip/c1/timeline/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) @@ -377,7 +379,7 @@ class DocumentTests(TestCase): ) self.assertEqual(201, response.status_code) revision = Revision.objects.get() - self.assertEqual(4, len(revision.document)) + self.assertEqual(5, len(revision.document)) self.assertEqual(self.leaves(), revision.document) # Coarse on purpose: a save does not write one, because tier 1 will hold # cel polygons and a snapshot per save bloats the table. diff --git a/docs/architecture.md b/docs/architecture.md index 35d1c21..f3d0b63 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -615,23 +615,19 @@ 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//node/ -clip//measured/ clip//channel// +clip//source clip//timeline/ +clip//timeline//node/ +clip//timeline//measured/ +clip//timeline//channel// ``` -Two departures from the design above, both because step 8 moved settings. +Settings live on subject, feature and group leaves. Each feature has one area, so +these leaves give settings their own address without separating them from the +identity they describe. -`params/:area` is **not** a leaf. That path came from a draft where params were one -blob per clip, and two people tuning teeth and eyes collided on every slider move. -Settings now live on the subject, the feature and the group, and a feature has -exactly one area — so the feature leaf already *is* the area-scoped leaf, and -splitting it again would separate a feature's params from its identity. - -`measured/` is one leaf holding several channels, which contradicts "every -channel gets its own". `:head`'s measured channels are not authored: a freeze -writes them together and a re-freeze replaces them together, and `head-mode` reads -them to write `:channels`. A leaf per measured channel would offer a write nobody -can make. +`measured/` holds several channels together. `:head`'s measured channels are +written and replaced together by a freeze; `head-mode` reads them to write authored +`:channels`. A leaf path is "/"-delimited and an id is one segment of it, so a namespaced id — `:eye-r/iris`, as drawn under **The node, decomposed** — is written `eye-r~iris`, @@ -761,20 +757,21 @@ collaborator's keying. The fix is addressing, not an algorithm: ``` palette sequence/:sid -clip/:cid/timing exposure, lead, kept frames -clip/:cid/params/:area teeth | eyes | brows | mouth | plate -clip/:cid/node/:nid one node: source, parent, stencil, z, colour -clip/:cid/channel/:nid/:prop -clip/:cid/cel/:nid/:frame -clip/:cid/overrides/:nid/:prop +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 ``` -An earlier draft of this list had `params` and `scene` as one leaf each, and both -were too coarse: one person tuning teeth while another tunes eyes would have -collided on every slider move, and two people adding nodes would have collided -always. Split params **by feature area** and give every node its own leaf. With -fractional `:z` there is no separate order leaf to contend on, which is the -second thing fractional indices buy. +Each feature and node has its own leaf, so tuning separate features and adding +separate nodes use separate addresses. Fractional `:z` keeps draw order on the +node leaf. Each path is a **leaf**: independently addressed, independently versioned, LWW with an `If-Match` on its version. The boundaries are chosen so the things people diff --git a/frontend/src/arthur/domain/leaf.cljs b/frontend/src/arthur/domain/leaf.cljs index 0cf8aef..1fb4616 100644 --- a/frontend/src/arthur/domain/leaf.cljs +++ b/frontend/src/arthur/domain/leaf.cljs @@ -19,13 +19,9 @@ 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. + 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. `: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 diff --git a/frontend/src/arthur/domain/project.cljs b/frontend/src/arthur/domain/project.cljs index 4763d54..41163fe 100644 --- a/frontend/src/arthur/domain/project.cljs +++ b/frontend/src/arthur/domain/project.cljs @@ -53,7 +53,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/node/mouth` is a keyword whose `name` is \"c1/node/mouth\", so the + `:clip/c1/timeline/main/node/mouth` is a keyword whose `name` is + \"c1/timeline/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/fx/http.cljs b/frontend/src/arthur/fx/http.cljs index d594c37..c83cc61 100644 --- a/frontend/src/arthur/fx/http.cljs +++ b/frontend/src/arthur/fx/http.cljs @@ -4,7 +4,8 @@ Everything here returns a promise of a PARSED JS VALUE, not of CLJS data, and that is deliberate: a leaf is transit, and `domain/project` reads it straight out of the response object. A keywordising `js->clj` on the way past would turn the - leaf path \"clip/c1/node/mouth\" into a keyword whose name is \"c1/node/mouth\", + leaf path \"clip/c1/timeline/main/node/mouth\" into a keyword whose name is + \"c1/timeline/main/node/mouth\", losing the prefix — a corruption that only shows up on the way back in. CSRF IS NOT EXEMPTED. The page renders `{% csrf_token %}`, so Django sets its diff --git a/frontend/test/arthur/domain/feature_test.cljs b/frontend/test/arthur/domain/feature_test.cljs index ce4ec99..2cf7e9d 100644 --- a/frontend/test/arthur/domain/feature_test.cljs +++ b/frontend/test/arthur/domain/feature_test.cljs @@ -1,5 +1,6 @@ (ns arthur.domain.feature-test (:require [cljs.test :refer [deftest is]] + [arthur.domain.clip :as clip] [arthur.domain.feature :as feature] [arthur.domain.params :as params])) @@ -17,11 +18,14 @@ [id {:id id :kind :eye-pair :subject (nth people i) :members ids :params {}}])) members)) - :nodes {}})) + :timelines {:main {:id :main :frames 1 :nodes {}}}})) + +(defn- problems [c] + (feature/problems c (clip/nodes c))) (deftest five-people-can-have-nine-identified-eyes (let [scene (nine-eyes)] - (is (empty? (feature/problems scene))) + (is (empty? (problems scene))) (is (= [:eye-8] (get-in scene [:groups :pair-4 :members]))) (is (= :person-4 (get-in scene [:features :eye-8 :subject]))))) @@ -41,11 +45,11 @@ unpaired (feature/remove-from-pair scene :eye-0)] (is (= before (feature/effective-params unpaired :eye-0))) (is (= [:eye-1] (get-in unpaired [:groups :pair-0 :members]))) - (is (empty? (feature/problems unpaired)))))) + (is (empty? (problems unpaired)))))) (deftest a-pair-cannot-cross-subjects-or-own-an-eye-twice (let [scene (nine-eyes)] - (is (seq (feature/problems + (is (seq (problems (assoc-in scene [:groups :pair-0 :members] [:eye-0 :eye-2])))) - (is (seq (feature/problems + (is (seq (problems (assoc-in scene [:groups :pair-1 :members] [:eye-0 :eye-3]))))))