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
# 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.

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>/timing clip/<cid>/feature/<fid>
clip/<cid>/stage clip/<cid>/group/<gid>
clip/<cid>/source clip/<cid>/node/<nid>
clip/<cid>/measured/<nid> clip/<cid>/channel/<nid>/<prop>
clip/<cid>/source clip/<cid>/timeline/<tid>
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
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/<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.
`measured/<nid>` 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

View file

@ -19,13 +19,9 @@
clip/<cid>/timeline/<tid>/channel/<nid>/<prop>
clip/<cid>/timeline/<tid>/measured/<nid> the channels a re-freeze owns
WHY NODES SIT UNDER A TIMELINE. They did not until the clip and the timeline came
apart, and the flat `clip/<cid>/node/<nid>` was the persistence half of the same
conflation: it could only ever address the nodes of the one bag a clip had. A
library symbol is a timeline, an instance references one, and both need their
nodes addressed — so the timeline id is a segment, the root is `main`, and a
symbol's nodes are reachable by the same path shape as the clip's own. Adding it
later would have meant rewriting every stored path.
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

View file

@ -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

View file

@ -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

View file

@ -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]))))))