Align project paths with timeline model

This commit is contained in:
Olive Vaughn 2026-09-28 02:48:57 -04:00
parent 9778b9023b
commit 7738c4e1c8
6 changed files with 56 additions and 55 deletions

View file

@ -241,13 +241,14 @@ class DocumentTests(TestCase):
# Transit-shaped, because that is what a leaf actually holds: a map with a # Transit-shaped, because that is what a leaf actually holds: a map with a
# cache marker, keyword keys, and a frame-keyed inner map. # cache marker, keyword keys, and a frame-keyed inner map.
return { return {
"clip/c1/timing": ["^ ", "~:fps", 30, "~:frames", 48], "clip/c1/timing": ["^ ", "~:fps", 30],
"clip/c1/node/mouth": ["^ ", "~:id", "~:mouth", "~:z", "a1"], "clip/c1/timeline/main": ["^ ", "~:frames", 48],
"clip/c1/channel/mouth/geom.pts": [ "clip/c1/timeline/main/node/mouth": ["^ ", "~:id", "~:mouth", "~:z", "a1"],
"clip/c1/timeline/main/channel/mouth/geom.pts": [
"^ ", "~:animated?", True, "~:dense", "^ ", "~:animated?", True, "~:dense",
["^ ", "~:store", self.block, "~:offset", 0, "~:stride", 16], ["^ ", "~: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], "^ ", "~:animated?", True, "~:keys", ["^ ", "~i0", True, "~i12", False],
], ],
} }
@ -263,7 +264,7 @@ class DocumentTests(TestCase):
def test_a_document_comes_back_exactly(self): def test_a_document_comes_back_exactly(self):
response = self.save() response = self.save()
self.assertEqual(200, response.status_code, response.content) 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() loaded = self.client.get(f"/api/projects/{self.project.id}").json()
self.assertEqual(1, len(loaded["clips"])) self.assertEqual(1, len(loaded["clips"]))
@ -282,22 +283,23 @@ class DocumentTests(TestCase):
self.save() self.save()
first = {leaf.path: leaf.version for leaf in Leaf.objects.all()} first = {leaf.path: leaf.version for leaf in Leaf.objects.all()}
moved = self.leaves() moved = self.leaves()
moved["clip/c1/channel/mouth-in/vis"] = [ moved["clip/c1/timeline/main/channel/mouth-in/vis"] = [
"^ ", "~:animated?", True, "~:keys", ["^ ", "~i0", False], "^ ", "~:animated?", True, "~:keys", ["^ ", "~i0", False],
] ]
response = self.save(moved) response = self.save(moved)
self.assertEqual(["clip/c1/channel/mouth-in/vis"], response.json()["written"]) self.assertEqual(["clip/c1/timeline/main/channel/mouth-in/vis"], response.json()["written"])
self.assertEqual(3, response.json()["unchanged"]) self.assertEqual(4, response.json()["unchanged"])
after = {leaf.path: leaf.version for leaf in Leaf.objects.all()} 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"]) self.assertEqual(first["clip/c1/timing"], after["clip/c1/timing"])
def test_a_removed_node_removes_its_leaf(self): def test_a_removed_node_removes_its_leaf(self):
self.save() 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) response = self.save(fewer)
self.assertEqual(["clip/c1/node/mouth"], response.json()["removed"]) self.assertEqual(["clip/c1/timeline/main/node/mouth"], response.json()["removed"])
self.assertEqual(3, Leaf.objects.count()) self.assertEqual(4, Leaf.objects.count())
def test_a_save_does_not_disturb_another_clip(self): 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 # 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): def test_a_leaf_write_carries_an_etag(self):
self.save() 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) got = self.client.get(url)
self.assertEqual('"1"', got["ETag"]) self.assertEqual('"1"', got["ETag"])
@ -345,7 +347,7 @@ class DocumentTests(TestCase):
# take-theirs. A PUT that replaced unconditionally is the bug where the # take-theirs. A PUT that replaced unconditionally is the bug where the
# loser's work disappears silently. # loser's work disappears silently.
self.save() 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"') self.put(url, {"value": ["^ ", "~:z", "a2"]}, HTTP_IF_MATCH='"1"')
stale = self.put(url, {"value": ["^ ", "~:z", "a3"]}, HTTP_IF_MATCH='"1"') stale = self.put(url, {"value": ["^ ", "~:z", "a3"]}, HTTP_IF_MATCH='"1"')
self.assertEqual(409, stale.status_code) self.assertEqual(409, stale.status_code)
@ -377,7 +379,7 @@ class DocumentTests(TestCase):
) )
self.assertEqual(201, response.status_code) self.assertEqual(201, response.status_code)
revision = Revision.objects.get() revision = Revision.objects.get()
self.assertEqual(4, len(revision.document)) self.assertEqual(5, len(revision.document))
self.assertEqual(self.leaves(), revision.document) self.assertEqual(self.leaves(), revision.document)
# Coarse on purpose: a save does not write one, because tier 1 will hold # Coarse on purpose: a save does not write one, because tier 1 will hold
# cel polygons and a snapshot per save bloats the table. # cel polygons and a snapshot per save bloats the table.

View file

@ -615,23 +615,19 @@ is what step 9 implemented, for the subset that exists:
clip/<cid>/name clip/<cid>/subject/<sid> clip/<cid>/name clip/<cid>/subject/<sid>
clip/<cid>/timing clip/<cid>/feature/<fid> clip/<cid>/timing clip/<cid>/feature/<fid>
clip/<cid>/stage clip/<cid>/group/<gid> clip/<cid>/stage clip/<cid>/group/<gid>
clip/<cid>/source clip/<cid>/node/<nid> clip/<cid>/source clip/<cid>/timeline/<tid>
clip/<cid>/measured/<nid> clip/<cid>/channel/<nid>/<prop> clip/<cid>/timeline/<tid>/node/<nid>
clip/<cid>/timeline/<tid>/measured/<nid>
clip/<cid>/timeline/<tid>/channel/<nid>/<prop>
``` ```
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 `measured/<nid>` holds several channels together. `:head`'s measured channels are
blob per clip, and two people tuning teeth and eyes collided on every slider move. written and replaced together by a freeze; `head-mode` reads them to write authored
Settings now live on the subject, the feature and the group, and a feature has `:channels`.
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/<nid>` 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.
A leaf path is "/"-delimited and an id is one segment of it, so a namespaced id — 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`, `: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 palette
sequence/:sid sequence/:sid
clip/:cid/timing exposure, lead, kept frames clip/:cid/timing clip rate
clip/:cid/params/:area teeth | eyes | brows | mouth | plate clip/:cid/subject/:sid tracked subject and settings
clip/:cid/node/:nid one node: source, parent, stencil, z, colour clip/:cid/feature/:fid tracked feature and settings
clip/:cid/channel/:nid/:prop clip/:cid/group/:gid shared settings for an eye pair
clip/:cid/cel/:nid/:frame clip/:cid/timeline/:tid frame count, palette
clip/:cid/overrides/:nid/:prop 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 Each feature and node has its own leaf, so tuning separate features and adding
were too coarse: one person tuning teeth while another tunes eyes would have separate nodes use separate addresses. Fractional `:z` keeps draw order on the
collided on every slider move, and two people adding nodes would have collided node leaf.
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 path is a **leaf**: independently addressed, independently versioned, LWW 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 with an `If-Match` on its version. The boundaries are chosen so the things people

View file

@ -19,13 +19,9 @@
clip/<cid>/timeline/<tid>/channel/<nid>/<prop> clip/<cid>/timeline/<tid>/channel/<nid>/<prop>
clip/<cid>/timeline/<tid>/measured/<nid> the channels a re-freeze owns clip/<cid>/timeline/<tid>/measured/<nid> the channels a re-freeze owns
WHY NODES SIT UNDER A TIMELINE. They did not until the clip and the timeline came WHY NODES SIT UNDER A TIMELINE. A clip holds a library of timelines. Its root
apart, and the flat `clip/<cid>/node/<nid>` was the persistence half of the same and each symbol have their own nodes, so the timeline id is a path segment.
conflation: it could only ever address the nodes of the one bag a clip had. A The root is `main`, and a symbol's nodes use the same path shape.
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 `: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 clip is a rate, so `timing` holds `:fps` alone. Both used to be in one leaf, which

View file

@ -53,7 +53,8 @@
round-trip a clip through `JSON.parse(JSON.stringify(...))` and be running the 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 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 — 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. \"clip/\" would be lost on the way back in.
Refuses a document `domain/leaf` calls unaddressable, which is where a hand-made Refuses a document `domain/leaf` calls unaddressable, which is where a hand-made

View file

@ -4,7 +4,8 @@
Everything here returns a promise of a PARSED JS VALUE, not of CLJS data, and 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 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 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. 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 CSRF IS NOT EXEMPTED. The page renders `{% csrf_token %}`, so Django sets its

View file

@ -1,5 +1,6 @@
(ns arthur.domain.feature-test (ns arthur.domain.feature-test
(:require [cljs.test :refer [deftest is]] (:require [cljs.test :refer [deftest is]]
[arthur.domain.clip :as clip]
[arthur.domain.feature :as feature] [arthur.domain.feature :as feature]
[arthur.domain.params :as params])) [arthur.domain.params :as params]))
@ -17,11 +18,14 @@
[id {:id id :kind :eye-pair [id {:id id :kind :eye-pair
:subject (nth people i) :subject (nth people i)
:members ids :params {}}])) members)) :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 (deftest five-people-can-have-nine-identified-eyes
(let [scene (nine-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 (= [:eye-8] (get-in scene [:groups :pair-4 :members])))
(is (= :person-4 (get-in scene [:features :eye-8 :subject]))))) (is (= :person-4 (get-in scene [:features :eye-8 :subject])))))
@ -41,11 +45,11 @@
unpaired (feature/remove-from-pair scene :eye-0)] unpaired (feature/remove-from-pair scene :eye-0)]
(is (= before (feature/effective-params unpaired :eye-0))) (is (= before (feature/effective-params unpaired :eye-0)))
(is (= [:eye-1] (get-in unpaired [:groups :pair-0 :members]))) (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 (deftest a-pair-cannot-cross-subjects-or-own-an-eye-twice
(let [scene (nine-eyes)] (let [scene (nine-eyes)]
(is (seq (feature/problems (is (seq (problems
(assoc-in scene [:groups :pair-0 :members] [:eye-0 :eye-2])))) (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])))))) (assoc-in scene [:groups :pair-1 :members] [:eye-0 :eye-3]))))))