Align project paths with timeline model
This commit is contained in:
parent
9778b9023b
commit
7738c4e1c8
6 changed files with 56 additions and 55 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]))))))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue