diff --git a/Dockerfile b/Dockerfile index 805d9a6..6e6d717 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,4 +40,4 @@ RUN python manage.py collectstatic --noinput \ USER app EXPOSE 8000 -CMD ["sh", "-c", "python manage.py migrate --noinput && exec gunicorn server.wsgi:application --bind 0.0.0.0:8000 --workers 1 --threads 4 --timeout 120"] +CMD ["sh", "-c", "python manage.py migrate --noinput && exec daphne --bind 0.0.0.0 --port 8000 server.asgi:application"] diff --git a/README.md b/README.md index 6f178d2..7b70ce8 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@ mise exec -- python manage.py migrate ./do start # Django + frontend watcher ``` -In the app, upload a video, choose its footage, click **load frames**, then -**save**. Opening that project on another client reuses its saved landmarks and +In the app, drop a video on the media pool — it uploads, extracts and runs +detection — then **save**. Opening that project on another client reuses its saved landmarks and mouth crops without detecting source frames again. The upload path derives its footage response from database records; it does not create or consume a `manifest.json` file. See [frontend/README.md](frontend/README.md) for details. diff --git a/clips/consumers.py b/clips/consumers.py new file mode 100644 index 0000000..98de12f --- /dev/null +++ b/clips/consumers.py @@ -0,0 +1,87 @@ +"""One socket per open project, and it is tl's, nearly line for line. + +Two things ride it. DELTAS, which the server sends after a write commits — the +socket is read-only for the document, and a dropped socket cannot lose a write. +PRESENCE, which peers gossip between themselves: all the server does is hand out +a connection id and stamp the sender's identity onto every message, so nobody can +post as somebody else. +""" +import json +import uuid + +from asgiref.sync import async_to_sync +from channels.generic.websocket import AsyncWebsocketConsumer +from channels.layers import get_channel_layer + +# Who is connected, per project: {group: {cid: presence}}. A cache of what has +# already been relayed, so a joiner gets the room in one message. Process-local, +# like the in-memory channel layer this runs on. +ROOMS = {} + + +def group(project_id): + return f"project_{project_id}" + + +def broadcast(project_id, delta, kind="delta"): + """Send a committed write to everyone in the project's room. `access` says + only that who may write has changed, and each client asks for itself.""" + async_to_sync(get_channel_layer().group_send)( + group(project_id), {"type": "project.delta", "delta": {"kind": kind, **delta}}, + ) + + +class ProjectConsumer(AsyncWebsocketConsumer): + RELAYED = ("state",) + + @property + def room(self): + return ROOMS.setdefault(self.group, {}) + + async def connect(self): + self.group = group(self.scope["url_route"]["kwargs"]["project_id"]) + self.cid = uuid.uuid4().hex[:12] + user = self.scope.get("user") + self.username = user.get_username() if user and user.is_authenticated else None + await self.channel_layer.group_add(self.group, self.channel_name) + await self.accept() + + me = {"cid": self.cid, "user": self.username} + others = list(self.room.values()) + self.room[self.cid] = me + await self.send(text_data=json.dumps({"kind": "welcome", **me})) + await self.send(text_data=json.dumps({"kind": "roster", "peers": others})) + await self._relay({"kind": "join"}) + + async def disconnect(self, code): + if hasattr(self, "cid"): + self.room.pop(self.cid, None) + if not self.room: + ROOMS.pop(self.group, None) + await self._relay({"kind": "leave"}) + await self.channel_layer.group_discard(self.group, self.channel_name) + + async def receive(self, text_data=None, bytes_data=None): + try: + msg = json.loads(text_data or "{}") + except ValueError: + return + if not isinstance(msg, dict) or msg.get("kind") not in self.RELAYED: + return + if self.cid in self.room: + self.room[self.cid].update( + {k: v for k, v in msg.items() if k not in ("kind", "cid", "user")} + ) + await self._relay(msg) + + async def _relay(self, msg): + await self.channel_layer.group_send( + self.group, + {"type": "peer.msg", "msg": {**msg, "cid": self.cid, "user": self.username}}, + ) + + async def peer_msg(self, event): + await self.send(text_data=json.dumps(event["msg"])) + + async def project_delta(self, event): + await self.send(text_data=json.dumps(event["delta"])) 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/migrations/0008_owners_editors_leaf_seq.py b/clips/migrations/0008_owners_editors_leaf_seq.py new file mode 100644 index 0000000..66aaa89 --- /dev/null +++ b/clips/migrations/0008_owners_editors_leaf_seq.py @@ -0,0 +1,39 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def orphans(apps, schema_editor): + # Every project has an owner, and none of the ones saved before owners did. + apps.get_model("clips", "Project").objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("clips", "0007_symbols_not_timelines"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RunPython(orphans, migrations.RunPython.noop), + migrations.AddField( + model_name="leaf", + name="seq", + field=models.PositiveBigIntegerField( + default=0, help_text="the project seq of the write that last changed it"), + ), + migrations.AddField( + model_name="project", + name="editors", + field=models.ManyToManyField(blank=True, related_name="shared_projects", + to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name="project", + name="owner", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name="projects", to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/clips/migrations/0009_revision_blocks.py b/clips/migrations/0009_revision_blocks.py new file mode 100644 index 0000000..3bd1303 --- /dev/null +++ b/clips/migrations/0009_revision_blocks.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-09-30 01:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clips', '0008_owners_editors_leaf_seq'), + ] + + operations = [ + migrations.AddField( + model_name='revision', + name='blocks', + field=models.JSONField(default=dict, help_text="each clip's tier-2 block keys, by cid, so a restore can name them"), + ), + ] diff --git a/clips/models.py b/clips/models.py index b120873..47f1990 100644 --- a/clips/models.py +++ b/clips/models.py @@ -24,7 +24,9 @@ without parsing its leaves: which footage, which analysis, which blocks. """ import uuid +from django.conf import settings from django.db import models +from django.utils import timezone class Blob(models.Model): @@ -201,12 +203,21 @@ class Project(models.Model): `schema_version` identifies the stored document format. `seq` counts writes to this particular project; it is not a format version. Every write bumps - `seq`, and a client that sees `seq > local + 1` refetches once broadcasts exist. + `seq`, and a client that sees `seq > local + 1` refetches. + + ANYONE WITH THE LINK CAN VIEW; the owner and the editors can write. Every + project has an owner. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="projects", + ) + editors = models.ManyToManyField( + settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects", + ) 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) @@ -219,10 +230,19 @@ class Project(models.Model): return f"{self.name} ({self.id})" def bump(self): - self.seq += 1 - self.save(update_fields=["seq", "updated"]) + """The next seq, taken with an UPDATE so that inside a transaction it is + also the write lock: two concurrent saves cannot both get the same one.""" + Project.objects.filter(id=self.id).update( + seq=models.F("seq") + 1, updated=timezone.now() + ) + self.refresh_from_db(fields=["seq", "updated"]) return self.seq + def can_edit(self, user): + return user.is_authenticated and ( + user.id == self.owner_id or self.editors.filter(id=user.id).exists() + ) + class Clip(models.Model): """Tier 1: the unit of work, and the thing leaf paths are scoped by. @@ -271,6 +291,9 @@ class Leaf(models.Model): path = models.CharField(max_length=300) value = models.JSONField() version = models.PositiveBigIntegerField(default=1) + seq = models.PositiveBigIntegerField( + default=0, help_text="the project seq of the write that last changed it", + ) updated = models.DateTimeField(auto_now=True) class Meta: @@ -288,7 +311,9 @@ class Leaf(models.Model): class Revision(models.Model): - """Tier 1: a snapshot of the authored layer, with a user and a summary. + """Tier 1: a snapshot of the authored layer, with a user and a summary — a + named snapshot, which is how a person marks a version now that every edit + saves itself. ON AN EXPLICIT TRIGGER, not on every save. tl snapshots a small annotation layer; arthur's tier 1 will contain cel polygons, so a snapshot per save bloats @@ -302,6 +327,9 @@ class Revision(models.Model): author = models.CharField(max_length=200, blank=True) summary = models.CharField(max_length=500, blank=True) document = models.JSONField(help_text="every leaf of the project, by path") + blocks = models.JSONField( + default=dict, help_text="each clip's tier-2 block keys, by cid, so a restore can name them", + ) created = models.DateTimeField(auto_now_add=True) class Meta: diff --git a/clips/routing.py b/clips/routing.py new file mode 100644 index 0000000..b94cd1b --- /dev/null +++ b/clips/routing.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .consumers import ProjectConsumer + +websocket_urlpatterns = [ + path("ws/projects/", ProjectConsumer.as_asgi()), +] diff --git a/clips/templates/clips/index.html b/clips/templates/clips/index.html index e08393d..d73c62e 100644 --- a/clips/templates/clips/index.html +++ b/clips/templates/clips/index.html @@ -2,11 +2,14 @@ {% comment %} The host page, served by Django since port-plan step 9. -It was `frontend/public/index.html`, served by shadow-cljs's `:dev-http`, and that -key is gone. The bundle is unchanged: shadow-cljs writes it into -`static/arthur/js` and staticfiles serves it from there, so `manage.py runserver` -and `shadow-cljs watch app` are the whole dev loop with nothing copying files -between them. +It carries no styles of its own any more. They are `static/arthur/app.css`, which +staticfiles serves from the same tree as the bundle — the page grew a five-pane +application chrome and "the styles" stopped being a thing you read in passing on +the way to the markup. + +The bundle is unchanged: shadow-cljs writes it into `static/arthur/js` and +staticfiles serves it from there, so `manage.py runserver` and `shadow-cljs watch +app` are the whole dev loop with nothing copying files between them. The CSRF token is rendered so that Django sets its cookie, which is what `arthur.fx.http` reads to write the `X-CSRFToken` header. Saves are ordinary POSTs @@ -17,72 +20,12 @@ and PUTs with ordinary CSRF protection — no endpoint in this app is exempt. arthur - + {% csrf_token %}
- + diff --git a/clips/tests/test_api.py b/clips/tests/test_api.py index 08554ad..5db496c 100644 --- a/clips/tests/test_api.py +++ b/clips/tests/test_api.py @@ -361,7 +361,10 @@ class DocumentTests(TestCase): """Tier 1: load, save, and the conditional write.""" def setUp(self): - self.project = Project.objects.create(name="a project") + from django.contrib.auth import get_user_model + owner = get_user_model().objects.create_user("owner", password="password1") + self.client.force_login(owner) + self.project = Project.objects.create(name="a project", owner=owner) descriptor = analysis_descriptor() self.analysis = key_for(descriptor) self.client.post("/api/analyses", data=json.dumps( @@ -382,13 +385,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], ], } @@ -401,13 +404,25 @@ class DocumentTests(TestCase): "blocks": blocks if blocks is not None else [self.block]}], }) + def test_every_saved_symbol_is_listed_across_projects(self): + leaves = self.leaves() + leaves["clip/c1/symbol/sym~face"] = ["^ ", "~:name", "face", "~:frames", 12] + leaves["clip/c1/symbol/sym~face/node/mark"] = ["^ ", "~:id", "~:mark", "~:z", "a1"] + self.assertEqual(200, self.save(leaves).status_code) + rows = self.client.get("/api/symbols").json()["symbols"] + self.assertEqual( + [("face", "sym~face", 12), ("main", "main", 48)], + [(r["name"], r["symbol"], r["frames"]) for r in rows]) + self.assertEqual({str(self.project.id)}, {r["project"] for r in rows}) + self.assertEqual({"c1"}, {r["cid"] for r in rows}) + def test_a_document_comes_back_exactly(self): response = self.save() self.assertEqual(200, response.status_code, response.content) 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 +439,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 +487,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 +503,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) @@ -511,6 +526,61 @@ class DocumentTests(TestCase): # --- revisions --------------------------------------------------------- + def patch(self, base, leaves, removed=()): + return self.put(f"/api/projects/{self.project.id}", { + "base": base, + "clips": [{"cid": "c1", "analysis": self.analysis, "leaves": leaves, + "removed": list(removed), "blocks": [self.block]}], + }) + + def test_a_patch_leaves_what_it_does_not_name_alone(self): + seq = self.save().json()["seq"] + response = self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 24]}, + removed=["clip/c1/symbol/main/node/mouth"]) + self.assertEqual(200, response.status_code, response.content) + self.assertEqual(["clip/c1/timing"], response.json()["written"]) + self.assertEqual(4, Leaf.objects.count()) + + def test_two_people_on_different_leaves_both_land(self): + seq = self.save().json()["seq"] + self.assertEqual(200, self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 24]}).status_code) + # The second saver has not caught up, and touched a different leaf. + response = self.patch(seq, {"clip/c1/symbol/main": ["^ ", "~:frames", 12]}) + self.assertEqual(200, response.status_code, response.content) + leaves = self.client.get(f"/api/projects/{self.project.id}").json()["clips"][0]["leaves"] + self.assertEqual(["^ ", "~:fps", 24], leaves["clip/c1/timing"]) + self.assertEqual(["^ ", "~:frames", 12], leaves["clip/c1/symbol/main"]) + + def test_two_people_on_one_leaf_is_a_conflict_that_writes_nothing(self): + seq = self.save().json()["seq"] + self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 24]}) + response = self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 12], + "clip/c1/symbol/main": ["^ ", "~:frames", 12]}) + self.assertEqual(409, response.status_code) + self.assertEqual({"clip/c1/timing": ["^ ", "~:fps", 24]}, response.json()["conflicts"]) + self.assertEqual(seq + 1, Project.objects.get(id=self.project.id).seq) + self.assertEqual(["^ ", "~:frames", 48], + Leaf.objects.get(path="clip/c1/symbol/main").value) + # Caught up to their seq, the same write is ordinary. + self.assertEqual(200, self.patch(seq + 1, {"clip/c1/timing": ["^ ", "~:fps", 12]}).status_code) + + def test_a_named_snapshot_restores_as_an_ordinary_write(self): + self.save() + snap = self.client.post(f"/api/projects/{self.project.id}/revisions", + data=json.dumps({"summary": "before the big change"}), + content_type="application/json").json() + moved = self.leaves() + moved["clip/c1/timing"] = ["^ ", "~:fps", 12] + del moved["clip/c1/symbol/main/node/mouth"] + self.save(moved) + listed = self.client.get(f"/api/projects/{self.project.id}/revisions").json()["revisions"] + self.assertEqual(["before the big change"], [r["summary"] for r in listed]) + restored = self.client.post( + f"/api/projects/{self.project.id}/revisions/{snap['id']}/restore").json() + self.assertEqual(2, restored["changed"]) + leaves = self.client.get(f"/api/projects/{self.project.id}").json()["clips"][0]["leaves"] + self.assertEqual(self.leaves(), leaves) + def test_a_revision_snapshots_the_authored_layer(self): self.save() response = self.client.post( @@ -814,3 +884,99 @@ class UploadTests(TestCase): digest="e" * 64, fps=12, frames=3, width=8, height=6, audio=blob) manifest = self.client.get(f"/api/footage/{footage.id}").json() self.assertIsNone(manifest["video"]) + + +@override_settings(BLOB_ROOT=BLOB_DIR) +class OwnershipTests(TestCase): + """Anyone with the link reads; the owner and the editors write.""" + + def setUp(self): + from django.contrib.auth import get_user_model + User = get_user_model() + self.ann = User.objects.create_user("ann", password="password1") + self.bob = User.objects.create_user("bob", password="password1") + self.project = Project.objects.create(name="ann's", owner=self.ann) + + def write(self): + return self.client.put(f"/api/projects/{self.project.id}", + data=json.dumps({"name": "renamed", "clips": []}), + content_type="application/json") + + def test_anyone_with_the_link_can_read_and_nobody_else_can_write(self): + loaded = self.client.get(f"/api/projects/{self.project.id}").json() + self.assertEqual(("ann", False), (loaded["owner"], loaded["can_edit"])) + self.assertEqual(403, self.write().status_code) + self.client.login(username="bob", password="password1") + self.assertEqual(403, self.write().status_code) + + def test_the_owner_names_an_editor_who_can_then_write(self): + self.client.login(username="bob", password="password1") + self.assertEqual(403, self.client.post( + f"/api/projects/{self.project.id}/editors", data=json.dumps({"username": "bob"}), + content_type="application/json").status_code) + self.client.login(username="ann", password="password1") + self.assertEqual(200, self.write().status_code) + self.assertEqual(["bob"], self.client.post( + f"/api/projects/{self.project.id}/editors", data=json.dumps({"username": "bob"}), + content_type="application/json").json()["editors"]) + self.client.login(username="bob", password="password1") + self.assertTrue(self.client.get(f"/api/projects/{self.project.id}").json()["can_edit"]) + self.assertEqual(200, self.write().status_code) + self.client.login(username="ann", password="password1") + self.client.delete(f"/api/projects/{self.project.id}/editors/bob") + self.client.login(username="bob", password="password1") + self.assertEqual(403, self.write().status_code) + + def test_a_project_is_made_by_somebody_signed_in_and_is_theirs(self): + self.assertEqual(403, self.client.post("/api/projects", data=json.dumps({"name": "x"}), + content_type="application/json").status_code) + self.client.post("/api/signup", data=json.dumps( + {"username": "cat", "password": "password1"}), content_type="application/json") + self.assertEqual("cat", self.client.get("/api/me").json()["username"]) + mine = self.client.post("/api/projects", data=json.dumps({"name": "y"}), + content_type="application/json").json() + self.assertEqual(("cat", True), (mine["owner"], mine["can_edit"])) + listed = {p["name"] for p in self.client.get("/api/projects").json()["projects"]} + self.assertEqual({"y"}, listed) + self.client.logout() + self.assertEqual([], self.client.get("/api/projects").json()["projects"]) + + def test_a_project_has_an_address(self): + response = self.client.get(f"/p/{self.project.id}") + self.assertEqual(200, response.status_code) + # The slug is the name, for people; the id is what finds it. + self.assertEqual(200, self.client.get(f"/p/{self.project.id}/anything-at-all").status_code) + self.assertContains(response, 'id="app"') + + +class SocketTests(TestCase): + """A committed write reaches everyone in the room; presence is stamped.""" + + def test_a_save_is_broadcast_to_the_room(self): + from asgiref.sync import async_to_sync, sync_to_async + from channels.testing import WebsocketCommunicator + from clips.consumers import broadcast + from server.asgi import application + + from django.contrib.auth import get_user_model + project = Project.objects.create( + name="shared", owner=get_user_model().objects.create_user("host")) + + async def scenario(): + peer = WebsocketCommunicator(application, f"/ws/projects/{project.id}", + headers=[(b"origin", b"http://localhost")]) + connected, _ = await peer.connect() + self.assertTrue(connected) + self.assertEqual("welcome", (await peer.receive_json_from())["kind"]) + self.assertEqual([], (await peer.receive_json_from())["peers"]) + self.assertEqual("join", (await peer.receive_json_from())["kind"]) + # The server stamps who sent it; a claimed name is overwritten. + await peer.send_json_to({"kind": "state", "frame": 12, "user": "forged"}) + state = await peer.receive_json_from() + self.assertEqual((12, None), (state["frame"], state["user"])) + await sync_to_async(broadcast)(project.id, {"seq": 1, "clips": []}) + delta = await peer.receive_json_from() + self.assertEqual(("delta", 1), (delta["kind"], delta["seq"])) + await peer.disconnect() + + async_to_sync(scenario)() diff --git a/clips/urls.py b/clips/urls.py index 4b4042f..b21032b 100644 --- a/clips/urls.py +++ b/clips/urls.py @@ -16,6 +16,10 @@ from django.urls import path from . import views urlpatterns = [ + path("me", views.me), + path("login", views.login), + path("signup", views.signup), + path("logout", views.logout), path("detector", views.detector), path("sources", views.sources), path("extractions", views.extractions), @@ -23,9 +27,13 @@ urlpatterns = [ path("footage", views.footage_list), path("footage/", views.footage_detail), path("projects", views.projects), + path("symbols", views.symbols), path("projects/", views.project_detail), path("projects//leaves/", views.leaf_detail), path("projects//revisions", views.revisions), + path("projects//revisions//restore", views.restore), + path("projects//editors", views.editors), + path("projects//editors/", views.editors), path("analyses", views.analyses), path("analyses/", views.analysis_detail), path("blocks", views.blocks), diff --git a/clips/views.py b/clips/views.py index ce8ce79..2b91348 100644 --- a/clips/views.py +++ b/clips/views.py @@ -32,13 +32,17 @@ from pathlib import Path from uuid import UUID from django.conf import settings +from django.contrib.auth import authenticate, get_user_model +from django.contrib.auth import login as auth_login, logout as auth_logout from django.core.exceptions import ValidationError from django.db import transaction +from django.db.models import Q from django.http import FileResponse, HttpResponse, JsonResponse from django.shortcuts import render from django.views.decorators.http import require_http_methods from . import blobs, extraction +from .consumers import broadcast from .models import Analysis, Block, Blob, Clip, Extraction, Footage, Leaf, Project, Revision, Source KEY_LENGTH = 71 # "sha256:" + 64 hex @@ -119,10 +123,27 @@ def _crop_blob(chunks): # the page -def page(request): +def _asset_version(relative): + """A static file's modification time, for its URL. + + The stylesheet and the bundle are served with `Last-Modified` and nothing + else, so a browser is free to keep a stale copy on heuristic freshness — and + new JavaScript over an old stylesheet renders a pane the stylesheet has never + heard of as bare elements. A version in the URL makes each edit a new URL.""" + for root in settings.STATICFILES_DIRS: + path = Path(root) / relative + if path.exists(): + return str(int(path.stat().st_mtime)) + return "0" + + +def page(request, project_id=None, slug=None): """The host page. This replaced `frontend/public/index.html` at step 9, and `:dev-http` in shadow-cljs.edn went away with it.""" - return render(request, "clips/index.html") + return render(request, "clips/index.html", { + "css_version": _asset_version("arthur/app.css"), + "js_version": _asset_version("arthur/js/main.js"), + }) # --------------------------------------------------------------------------- @@ -273,6 +294,44 @@ def footage_list(request): ) +_SYMBOL_LEAF = re.compile(r"^clip/([^/]+)/symbol/([^/]+)$") + + +def _transit_fields(value, *keys): + """Top-level fields of a transit map leaf, by keyword name. A leaf's own + facts are a small flat map, so no key repeats and transit's cache never + stands in for one; anything else reads as absent.""" + if not (isinstance(value, list) and value[:1] == ["^ "]): + return {} + pairs = dict(zip(value[1::2], value[2::2])) + return {k: pairs.get(f"~:{k}") for k in keys} + + +@require_http_methods(["GET"]) +def symbols(request): + """Every symbol in every saved project, for the pool's all-assets folder. + + Read off the leaf PATHS rather than by loading documents: a symbol's own leaf + is `clip//symbol/`, so listing them is one query and no decoding + beyond the name and length its value carries.""" + rows = [] + for leaf in Leaf.objects.filter(path__contains="/symbol/").select_related("project"): + m = _SYMBOL_LEAF.match(leaf.path) + if not m: + continue + fields = _transit_fields(leaf.value, "name", "frames") + rows.append({ + "project": str(leaf.project_id), + "project_name": leaf.project.name, + "cid": m.group(1), + "symbol": m.group(2), + "name": fields.get("name") or m.group(2).replace("~", "/"), + "frames": fields.get("frames"), + }) + rows.sort(key=lambda r: (r["project_name"], r["project"], r["name"])) + return JsonResponse({"symbols": rows}) + + @require_http_methods(["GET"]) def footage_detail(request, footage_id): try: @@ -564,7 +623,11 @@ def block_detail(request, key): # tier 1: projects, clips, leaves -def _project_json(project: Project): +def _who(user): + return {"username": user.get_username() if user.is_authenticated else None} + + +def _project_json(project: Project, user): leaves = list(project.leaves.all()) clips = [] for clip in project.clips.all(): @@ -585,27 +648,103 @@ def _project_json(project: Project): "schema_version": project.schema_version, "seq": project.seq, "palette": project.palette, + "owner": project.owner.get_username(), + "editors": sorted(project.editors.values_list("username", flat=True)), + "can_edit": project.can_edit(user), "clips": clips, } +def _project(project_id): + try: + return Project.objects.select_related("owner").get(id=project_id) + except Project.DoesNotExist: + raise Bad("no such project", status=404) + + +def _writable(request, project_id): + project = _project(project_id) + if not project.can_edit(request.user): + raise Bad("only the owner and the editors can change this project; " + "save a copy instead", status=403) + return project + + +# --------------------------------------------------------------------------- +# who you are +# +# Django's session cookie, and the page's CSRF cookie on every write. Nothing +# here that a signed-in admin does not already have; the API gains a way in that +# is not the admin's login page. + + +@require_http_methods(["GET"]) +def me(request): + return JsonResponse(_who(request.user)) + + +@require_http_methods(["POST"]) +def login(request): + data = json.loads(request.body or b"{}") + user = authenticate(request, username=data.get("username"), password=data.get("password")) + if user is None: + return JsonResponse({"error": "wrong username or password"}, status=400) + auth_login(request, user) + return JsonResponse(_who(user)) + + +@require_http_methods(["POST"]) +def signup(request): + data = json.loads(request.body or b"{}") + username = (data.get("username") or "").strip() + password = data.get("password") or "" + if not username or len(password) < 8: + return JsonResponse({"error": "a username, and a password of 8 or more"}, status=400) + User = get_user_model() + if User.objects.filter(username__iexact=username).exists(): + return JsonResponse({"error": "that username is taken"}, status=409) + user = User.objects.create_user(username=username, password=password) + auth_login(request, user) + return JsonResponse(_who(user), status=201) + + +@require_http_methods(["POST"]) +def logout(request): + auth_logout(request) + return JsonResponse(_who(request.user)) + + +# --------------------------------------------------------------------------- +# tier 1: projects, clips, leaves + + @require_http_methods(["GET", "POST"]) def projects(request): + """GET lists what you own and are an editor of — nothing, signed out; POST + makes one, owned by you. Every project has an owner, so making one needs you + signed in.""" if request.method == "GET": + if not request.user.is_authenticated: + return JsonResponse({"projects": []}) + visible = Q(owner=request.user) | Q(editors=request.user) return JsonResponse( { "projects": [ {"id": str(p.id), "name": p.name, "schema_version": p.schema_version, "seq": p.seq, + "owner": p.owner.get_username(), "updated": p.updated.isoformat()} - for p in Project.objects.all()[:100] + for p in Project.objects.filter(visible).distinct() + .select_related("owner")[:100] ] } ) + if not request.user.is_authenticated: + return JsonResponse({"error": "sign in to make a project"}, status=403) try: data = _body(request) - project = Project.objects.create(name=data.get("name") or "untitled") - return JsonResponse(_project_json(project), status=201) + project = Project.objects.create(name=data.get("name") or "untitled", owner=request.user) + return JsonResponse(_project_json(project, request.user), status=201) except Bad as exc: return _error(exc) @@ -613,43 +752,74 @@ def projects(request): @require_http_methods(["GET", "PUT"]) def project_detail(request, project_id): try: - project = Project.objects.get(id=project_id) - except Project.DoesNotExist: - return JsonResponse({"error": "no such project"}, status=404) - if request.method == "GET": - return JsonResponse(_project_json(project)) + if request.method == "GET": + return JsonResponse(_project_json(_project(project_id), request.user)) + project = _writable(request, project_id) + return _save(project, _body(request), request.user) + except Bad as exc: + return _error(exc) + + +@require_http_methods(["POST", "DELETE"]) +def editors(request, project_id, username=None): + """The owner names who else can write. POST {username} adds; DELETE + `editors/` removes.""" try: - return _save(project, _body(request)) + project = _project(project_id) + if not (request.user.is_authenticated and request.user.id == project.owner_id): + raise Bad("only the owner can change who edits", status=403) + if request.method == "POST": + username = _body(request).get("username") + user = get_user_model().objects.filter(username__iexact=username or "").first() + if user is None: + raise Bad(f"nobody is called {username!r}", status=404) + if request.method == "POST": + project.editors.add(user) + else: + project.editors.remove(user) + broadcast(project.id, {}, kind="access") + return JsonResponse({"editors": sorted(project.editors.values_list("username", flat=True))}) except Bad as exc: return _error(exc) @transaction.atomic -def _save(project: Project, data): - """A whole-document save: one clip's leaves replace that clip's leaves. +def _save(project: Project, data, user): + """A save: one clip's leaves, written. SCOPED BY CLIP, not by project. A payload that carries clip `a` does not - disturb clip `b`'s leaves, because a save is not the only way the document - changes — a single-leaf conditional write is — and a save that cleared - everything it did not mention would be a save that undoes a collaborator. + disturb clip `b`'s leaves. + + Two shapes. Without `base`, a clip's leaves REPLACE that clip's leaves — the + whole-document save. With `base`, the seq the client last caught up to, the + save is a PATCH: `leaves` are the ones it changed, `removed` the ones it + deleted, and nothing it did not mention is touched. A leaf it names that + somebody else changed after `base`, to something else, is a conflict, and the + whole save answers 409 with their values — last-writer-wins per leaf, with the + loser told rather than silently clobbered. docs/architecture.md, "Make the + merge unit small instead of clever". A leaf whose value is unchanged keeps its VERSION. That is what makes the entity tag mean something: a save of a document where one channel moved invalidates one leaf's etag, not all four hundred. """ + base = data.get("base") + seq = project.bump() if data.get("name"): project.name = data["name"] if data.get("palette"): project.palette = data["palette"] + project.save(update_fields=["name", "palette"]) - written, removed, unchanged = [], [], [] + written, removed, unchanged, conflicts, deltas = [], [], [], {}, [] for spec in data.get("clips") or []: cid = spec.get("cid") if not cid: raise Bad("every clip in a save names its cid") leaves = spec.get("leaves") or {} + gone = spec.get("removed") or [] if base is not None else [] prefix = f"clip/{cid}/" - for path in leaves: + for path in [*leaves, *gone]: if not path.startswith(prefix): raise Bad( f"leaf {path!r} is not addressed to clip {cid!r}", @@ -668,6 +838,16 @@ def _save(project: Project, data): status=409, missing=missing, ) + existing = {leaf.path: leaf for leaf in project.leaves.filter(path__startswith=prefix)} + if base is not None: + for path in [*leaves, *gone]: + theirs = existing.get(path) + if theirs and theirs.seq > base and ( + path not in leaves or theirs.value != leaves[path]): + conflicts[path] = theirs.value + if conflicts: + continue + analysis = Analysis.objects.filter(key=spec.get("analysis")).first() footage = None if spec.get("footage"): @@ -677,29 +857,41 @@ def _save(project: Project, data): cid=cid, defaults={"name": spec.get("name") or "", "analysis": analysis, "footage": footage}, ) - clip.blocks.set(Block.objects.filter(key__in=keys)) + blocks = Block.objects.filter(key__in=keys) + if base is None: + clip.blocks.set(blocks) + gone = [path for path in existing if path not in leaves] + else: + clip.blocks.add(*blocks) - existing = {leaf.path: leaf for leaf in project.leaves.filter(path__startswith=prefix)} + changed = {} for path, value in leaves.items(): leaf = existing.get(path) if leaf is None: - Leaf.objects.create(project=project, path=path, value=value) - written.append(path) + Leaf.objects.create(project=project, path=path, value=value, seq=seq) elif leaf.value != value: - leaf.value = value + leaf.value, leaf.seq = value, seq leaf.version += 1 - leaf.save(update_fields=["value", "version", "updated"]) - written.append(path) + leaf.save(update_fields=["value", "version", "seq", "updated"]) else: unchanged.append(path) - for path, leaf in existing.items(): - if path not in leaves: - leaf.delete() - removed.append(path) + continue + changed[path] = value + dropped = [path for path in gone if path in existing] + project.leaves.filter(path__in=dropped).delete() + written += changed + removed += dropped + deltas.append({"cid": cid, "leaves": changed, "removed": dropped, "blocks": keys}) - seq = project.seq + 1 - project.seq = seq - project.save() + if conflicts: + raise Bad( + "somebody else changed these since you last caught up", + status=409, seq=seq - 1, conflicts=conflicts, + ) + by = user.get_username() if user.is_authenticated else None + transaction.on_commit(lambda: broadcast(project.id, { + "seq": seq, "by": by, "name": project.name, "clips": deltas, + })) return JsonResponse( { "id": str(project.id), @@ -722,9 +914,9 @@ def leaf_detail(request, project_id, leaf_path): for a painted cel that is the class of bug that ends trust in a tool. """ try: - project = Project.objects.get(id=project_id) - except Project.DoesNotExist: - return JsonResponse({"error": "no such project"}, status=404) + project = _project(project_id) if request.method == "GET" else _writable(request, project_id) + except Bad as exc: + return _error(exc) leaf = project.leaves.filter(path=leaf_path).first() if request.method == "GET": @@ -742,35 +934,45 @@ def leaf_detail(request, project_id, leaf_path): return _error(Bad("a leaf write carries a value")) match = request.headers.get("If-Match") - if leaf is None: - # ANY `If-Match` on a leaf that does not exist is a failed precondition, - # `*` included: RFC 7232 gives `*` the meaning "the resource must already - # exist", which is exactly the write a client makes when it believes it is - # editing something. Creating it instead would turn "somebody deleted this - # node" into a silent resurrection. - if match: - return JsonResponse( - {"error": "no such leaf", "path": leaf_path}, status=409 - ) - leaf = Leaf.objects.create(project=project, path=leaf_path, value=data["value"]) - else: - if match and match not in ("*", leaf.etag): - response = JsonResponse( - { - "error": "stale write", - "path": leaf.path, - "version": leaf.version, - "value": leaf.value, - }, - status=409, - ) - response["ETag"] = leaf.etag - return response - leaf.value = data["value"] - leaf.version += 1 - leaf.save(update_fields=["value", "version", "updated"]) + with transaction.atomic(): + seq = project.bump() + leaf = project.leaves.filter(path=leaf_path).first() + if leaf is None: + # ANY `If-Match` on a leaf that does not exist is a failed precondition, + # `*` included: RFC 7232 gives `*` the meaning "the resource must already + # exist", which is exactly the write a client makes when it believes it + # is editing something. Creating it instead would turn "somebody deleted + # this node" into a silent resurrection. + if match: + transaction.set_rollback(True) + return JsonResponse( + {"error": "no such leaf", "path": leaf_path}, status=409 + ) + leaf = Leaf.objects.create(project=project, path=leaf_path, value=data["value"], seq=seq) + else: + if match and match not in ("*", leaf.etag): + transaction.set_rollback(True) + response = JsonResponse( + { + "error": "stale write", + "path": leaf.path, + "version": leaf.version, + "value": leaf.value, + }, + status=409, + ) + response["ETag"] = leaf.etag + return response + leaf.value, leaf.seq = data["value"], seq + leaf.version += 1 + leaf.save(update_fields=["value", "version", "seq", "updated"]) + cid = leaf_path.split("/")[1] if leaf_path.startswith("clip/") else None + by = request.user.get_username() if request.user.is_authenticated else None + transaction.on_commit(lambda: broadcast(project.id, { + "seq": seq, "by": by, "name": project.name, + "clips": [{"cid": cid, "leaves": {leaf.path: leaf.value}, "removed": [], "blocks": []}], + })) - seq = project.bump() response = JsonResponse({"path": leaf.path, "version": leaf.version, "seq": seq}) response["ETag"] = leaf.etag return response @@ -778,16 +980,17 @@ def leaf_detail(request, project_id, leaf_path): @require_http_methods(["GET", "POST"]) def revisions(request, project_id): - """Mark a version: one snapshot of the authored layer, with a summary.""" + """Named snapshots: GET lists them, POST {summary} takes one of the document + as it is now.""" try: - project = Project.objects.get(id=project_id) - except Project.DoesNotExist: - return JsonResponse({"error": "no such project"}, status=404) + project = _project(project_id) if request.method == "GET" else _writable(request, project_id) + except Bad as exc: + return _error(exc) if request.method == "GET": return JsonResponse( { "revisions": [ - {"seq": r.seq, "author": r.author, "summary": r.summary, + {"id": r.id, "seq": r.seq, "author": r.author, "summary": r.summary, "created": r.created.isoformat(), "leaves": len(r.document)} for r in project.revisions.all()[:100] ] @@ -797,8 +1000,57 @@ def revisions(request, project_id): revision = Revision.objects.create( project=project, seq=project.seq, - author=data.get("author") or "", - summary=data.get("summary") or "", + author=request.user.get_username(), + summary=(data.get("summary") or "").strip()[:500], document={leaf.path: leaf.value for leaf in project.leaves.all()}, + blocks={clip.cid: sorted(clip.blocks.values_list("key", flat=True)) + for clip in project.clips.all()}, ) - return JsonResponse({"seq": revision.seq, "leaves": len(revision.document)}, status=201) + return JsonResponse({"id": revision.id, "seq": revision.seq, + "leaves": len(revision.document)}, status=201) + + +@require_http_methods(["POST"]) +def restore(request, project_id, revision_id): + """Put a snapshot back: an ordinary write of every leaf that differs, so + everybody in the room receives it the way they receive any other.""" + try: + project = _writable(request, project_id) + revision = project.revisions.filter(id=revision_id).first() + if revision is None: + raise Bad("no such snapshot", status=404) + except Bad as exc: + return _error(exc) + with transaction.atomic(): + seq = project.bump() + existing = {leaf.path: leaf for leaf in project.leaves.all()} + deltas = {} + def delta(path): + cid = path.split("/")[1] + return deltas.setdefault(cid, {"cid": cid, "leaves": {}, "removed": [], + "blocks": revision.blocks.get(cid, [])}) + for path, value in revision.document.items(): + leaf = existing.get(path) + if leaf is None: + Leaf.objects.create(project=project, path=path, value=value, seq=seq) + elif leaf.value != value: + leaf.value, leaf.seq = value, seq + leaf.version += 1 + leaf.save(update_fields=["value", "version", "seq", "updated"]) + else: + continue + delta(path)["leaves"][path] = value + gone = [path for path in existing if path not in revision.document] + project.leaves.filter(path__in=gone).delete() + for path in gone: + delta(path)["removed"].append(path) + for cid, keys in revision.blocks.items(): + clip = project.clips.filter(cid=cid).first() + if clip: + clip.blocks.add(*Block.objects.filter(key__in=keys)) + by = request.user.get_username() + transaction.on_commit(lambda: broadcast(project.id, { + "seq": seq, "by": by, "name": project.name, "clips": list(deltas.values()), + })) + return JsonResponse({"seq": seq, "changed": sum(len(d["leaves"]) + len(d["removed"]) + for d in deltas.values())}) diff --git a/docs/animation-model.md b/docs/animation-model.md index 4c1a2bc..506ae01 100644 --- a/docs/animation-model.md +++ b/docs/animation-model.md @@ -79,6 +79,20 @@ this way. over which the node exists at all. Distinct from a `[:vis]` channel, which blinks an existing node on and off. +**Every node has the same two maps into its parent**, whatever kind it is: + +- **space** — the matrix its transform channels compose to, times a `:pinv` if + it has been moved in from elsewhere; +- **time** — `local = rate · (parent − at)`, from `:time :at` and `:rate`, + identity when absent. `:span` and every key are in the node's **own** frames. + +A move keeps a node's world maps and re-expresses them under its new parent: +the matrix becomes a `:pinv`, the time becomes a new `:at` and `:rate`, and its +channels, keys and span are not touched. Both maps are affine, so any depth of +nesting is one map and every move is one inverse. `node/time-of`, +`node/then-time` and `node/placed-span` are the time half; `clip/move-node` and +`clip/group` are the move. + ### Subjects and tracked features Scene nodes describe drawings, not tracking identity. A scene may also carry a @@ -415,9 +429,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 +443,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 +491,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..46b776e 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 @@ -675,6 +675,45 @@ and `~` is then refused inside a name. That is the whole of the escaping. That maps onto the tiers exactly — the server stores tier 1 and snapshots tier 1, with tiers 2 and 3 as content-addressed blobs beside it. +### As built + +- **Addresses.** `/` is the index of the projects you own or edit. A project + is only ever at `/p//`: the id finds it, the slug is its name and + follows a rename without a history entry. "new" makes the project on the + server first and opens it; a built-in example opened in the editor is saved + at once as a project of its own. There is no bare project. The address, the + title and the socket follow `[:project :id]` through one global interceptor + (`events/collab`). +- **Ownership.** Every project has an owner (`Project.owner`, not nullable) and + `editors`. Anyone with the link reads; the owner and editors write. Making a + project needs you signed in. A reader can make a copy of their own. + `/api/{me,login,signup,logout}`, `/api/projects//editors[/]`. +- **Every edit saves.** No save button. The same interceptor sees + `:paint/revision` move and saves; one request is in flight at a time, and an + edit made meanwhile goes when it lands — so a drag reaches the room as fast + as the round trip allows. A save sends only the leaves that differ from what + was last synced, and a clean document sends nothing. Analyses and blocks + already put on the server are not asked about again. +- **Saves are patches.** With `base` (the seq last caught up to) a save names + only its changed and removed leaves. A named leaf somebody else changed after + `base`, to something else, fails the whole save with 409. +- **The first write wins.** A remote write to a leaf with a local change not + yet sent waits in the entry's `:behind`; the next save, or a 409, puts theirs + on screen over ours and says so. Ours stays in the undo list. +- **The socket** (`/ws/projects/`, channels + daphne) carries presence, + the delta each committed write broadcasts, and `access` when the editor list + changes. A gap in `seq`, a welcome, or a 409 refetches the document. +- **Undo** is per person, recorded in `events/edit` as leaf befores and afters + (`domain/history`), and applied as an ordinary edit. A step undoes only if + every leaf it touched still holds what it left there: somebody else's edit + since refuses it rather than being undone with it. Edits to the same leaves + within a second, each starting where the last left off, are one step. +- **Snapshots** are named revisions (`/api/projects//revisions`), with each + clip's block keys. Restoring one is an ordinary write, broadcast like any. + +Not yet: follow mode, frame/selection in presence, the advisory `:editing` +lease, the durable outbox. + ### Why this model, and not a CRDT The usual reason to reach for Yjs or Automerge is automatic convergence without @@ -785,12 +824,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 62a7946..42dc4ba 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -88,18 +88,66 @@ cd frontend && mise exec -- npx shadow-cljs watch app ``` Then open ****. Django serves the page from -`clips/templates/clips/index.html`, and staticfiles serves the bundle out of -`static/arthur/js`, where `shadow-cljs` already writes it — so nothing copies files -between the two. +`clips/templates/clips/index.html`, its styles from `static/arthur/app.css`, and +the bundle out of `static/arthur/js`, where `shadow-cljs` already writes it — so +nothing copies files between the two. + +### The window + +One screen, five panes, no scrolling page. `src/arthur/ui/shell.cljs` is the grid +and nothing else; each pane owns its own subscriptions. + +``` + top the document: its name and last status, export, new / open / save + left media pool — the open document's symbols, and footage on the server + centre the palette strip (16 slots) above the stage + right inspector — the clip, the selected node, the tracked objects + bottom timeline — transport, ruler, a row per node +``` + +**It opens on a blank document**, and **new** makes another one. Nothing is +loaded until it is asked for. + +**Whole documents live under `open ▾`, not in the media pool**, and the split is +load-bearing rather than tidy. Opening a project REPLACES the stage; everything +in the pool is a thing to put ON it. Listing documents beside the symbols inside +one of them makes them read as two kinds of the same thing. The menu lists the +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. + +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. + +**Drop a video on the media pool** and it uploads, extracts and goes straight on +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 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 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 -Click **new polygon**, place at least three vertices on the stage, then click -**finish shape**. Select a shape to drag its vertices. Scrub to another frame and -click **new drawing key** to copy the visible outline there; the previous drawing -holds until that key. The numbered drawing-key buttons jump to editable keys. -The transition control between two drawing keys can switch that gap between a -hold and linear vertex tweening. Other gaps keep their own timing. Tweening works +Pick a tone from the palette strip, click **polygon**, place at least three +vertices on the stage, then click **finish**. Select a shape — on the stage, or by +its timeline row — to drag its vertices. Scrub to another frame and click +**drawing key here** in the inspector to copy the visible outline there; the +previous drawing holds until that key. The numbered key buttons jump to editable +keys. The transition control between two drawing keys can switch that gap between +a hold and linear vertex tweening. Other gaps keep their own timing. Tweening works best when the same vertex keeps the same meaning in every drawing. Paint shapes use the timeline clock directly, so the roto exposure grid does not delay a drawing key or step its @@ -109,7 +157,7 @@ tween. Use the project **save** button to persist the drawings. has used since step 5, when shadow-cljs's `:dev-http` did no directory-index resolution and the suite learned to ask for the file. -Four built-in clips, on buttons in the transport: +Four built-in clips, under **built-in examples** in the open menu: | | | | --- | --- | @@ -125,14 +173,14 @@ The demo scene itself is `src/arthur/demo/scene.edn`. Both the synthetic take 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. -**stage 8625** loads the locally saved `IMG_8625.MOV` project and places its -post-processed timeline twice. The stage layout is +**8625 stage study**, in the open menu, loads the locally saved `IMG_8625.MOV` project and places its +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 -frame 280. The button needs that saved 8625 project in the local server database. +frame 280. The row needs that saved 8625 project in the local server database. ### Projects and the EDN fixtures @@ -148,16 +196,17 @@ the same ClojureScript clip. The intended editor creates and changes that in-memory clip directly: a project browser and **new stage** action, timeline instance placement, node and channel editors, then the existing save path. EDN remains useful for checked-in examples -and reproducible studies. The current UI has save and open, but no project -browser, blank-stage action, or authoring controls yet; open chooses the most -recent project. +and reproducible studies. The UI now has the blank-stage action (**new**), a +project browser (`open ▾`), and placement by dragging a symbol out of the media +pool; node and channel editors are still to come — the inspector reports a +channel's shape but has nowhere to change its values. ### Real footage -Choose a video in the **footage** file input. The server probes it, re-encodes it +Drop a video on the media pool, or use its **+** button. The server probes it, re-encodes it to an H.264 proxy and a raw stream of the same coded frames, pulls WAV audio and one tracing JPEG per frame, -then makes the resulting footage selectable. Click **load frames** to detect and -freeze it. Extraction progress is currently read from `/api/extractions/`; a +then makes the resulting footage selectable and runs detection on it. **roto**, in +the pool's header, does the same for footage that is already there. Extraction progress is currently read from `/api/extractions/`; a future WebSocket can push the same job state. The uploaded bytes, extraction job, and decoded footage have separate records, so the same uploaded video can be reopened without decoding it again. @@ -216,14 +265,14 @@ the root — all of it is extraction output, and tier 3 does not belong in the r Loading detects one face per frame, measures the mouth, eyes and brows from landmarks and the teeth from source pixels, then freezes them into channels, -and adds a button for the footage clip. Detection happens once when you load; +and opens the footage clip. Detection happens once when you load; playback only resolves channels and paints. Frames without a detection remain marked absent even though their neighbouring poses are used to condition the track. The scene now records stable subject and feature IDs and explicit eye pairs; dense channels can mark one feature absent while another is observed. Current MediaPipe loading supplies only the full-face detection mask. The stage stays 320×200 regardless of the footage dimensions. Real -footage starts at the source picture rate. The **picture fps** buttons sample the +footage starts at the source picture rate. The **picture** buttons in the inspector sample the frozen roto at lower rates while the source track, duration and audio clock stay unchanged. Picking frames to trace into cels is a separate future editing step. **save** also stores the detection mask, dense landmarks and raw RGBA mouth crops @@ -252,7 +301,7 @@ runs the old JS tool on 8777, and the two are meant to run side by side. ## Saving -**save** and **open** in the transport. A save has three ordered stages: +**new**, **open** and **save** in the top bar. A save has three ordered stages: is the tier split: 1. the **analysis** record, so every block stored afterwards can name the detector @@ -269,10 +318,11 @@ unchanged document says `0 leaves · 0 blocks`, which is both halves of the addressing working at once — an unchanged leaf keeps its version, and a content-addressed block is already there. -Two things are deliberately visible as failures. Saving `swarm` is refused, -because its blocks have hand-written names and a document may only name content -addresses. And **open** takes the most recently updated project and shows its first -clip: there is no project browser, and the store holds one clip at a time. +Saving `swarm` is deliberately visible as a failure: its blocks have +hand-written names and a document may only name content addresses. + +`open ▾` lists every project the server holds, newest first, and shows the first +clip of whichever one is picked — the store holds one clip at a time. ## The oracle, which is finished @@ -313,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..06b2bcd 100644 --- a/frontend/src/arthur/audio/mix.cljs +++ b/frontend/src/arthur/audio/mix.cljs @@ -12,6 +12,7 @@ than the render being spelled once per consumer." (:require [arthur.domain.channel :as ch] [arthur.domain.clip :as clip] + [arthur.domain.nest :as nest] [arthur.domain.node :as node])) (defn wav-bytes @@ -91,22 +92,19 @@ (.setValueAtTime param (* factor v) (/ f fps))))))) (defn tracks-of - "The audio nodes of one of the clip's timelines. + "The sounds symbol `sid` plays, including those inside what it places — see + `nest/audio-tracks`. Playback mixes the open symbol's." + [document sid] + (nest/audio-tracks 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] - (let [[start end] (or (:span track) [0 frames]) + (let [[start end] (or (node/placed-span track) [0 frames]) start (max 0 start) end (min frames end) {:keys [buffer fps]} (get sources (get-in track [:source :footage])) @@ -133,20 +131,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 +160,29 @@ (.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))))) + +(defn clock! + "Promise of the URL the transport should play while symbol `sid` is open. + + The frame is derived from the audio element and from nothing else, so every + open symbol needs a sound exactly as long as it is. In order: its own placed + tracks, mixed; the document's audio file, for the symbol the document opens on + and only that one; and otherwise SILENCE of the symbol's length — a ten-frame + symbol played against the whole take's soundtrack would run ten frames and then + keep the clock going for minutes." + [document sid fallback-url store] + (-> (buffer! document sid store) + (.then (fn [buffer] + (cond + buffer (wav-url buffer) + (and fallback-url (= sid (clip/opens-on document))) fallback-url + :else (let [rate 44100 + n (max 1 (js/Math.ceil (* rate (/ (clip/frames document sid) + (:fps document)))))] + (wav-url (.createBuffer (js/OfflineAudioContext. 1 1 rate) 1 n rate)))))))) diff --git a/frontend/src/arthur/core.cljs b/frontend/src/arthur/core.cljs index e359902..5971b2b 100644 --- a/frontend/src/arthur/core.cljs +++ b/frontend/src/arthur/core.cljs @@ -4,12 +4,17 @@ port-plan step 3: the hand-written scene plays at 30fps against audio, scrubs, and runs at ½× and ¼×." (:require [arthur.db :as db] + [arthur.events.collab :as collab] [arthur.events.footage :as footage] + [arthur.events.history :as history] [arthur.events.playback] [arthur.events.paint] - [arthur.events.project] + [arthur.events.project :as project] + [arthur.events.ui] [arthur.subs.playback] [arthur.subs.render] + [arthur.subs.ui] + [arthur.ui.index :as index] [arthur.ui.player :as player] [arthur.ui.shell :as shell] [re-frame.core :as rf] @@ -24,14 +29,23 @@ ;; the loop would otherwise sit on an unchanged frame number and never redraw. (rf/clear-subscription-cache!) (player/refresh-subs!) - (rdc/render @root [shell/view])) + (rdc/render @root [:<> [shell/view] [index/view]])) (defn init [] (rf/dispatch-sync [::init]) + ;; A blank document, before the first render. Synchronous for the same reason + ;; `::init` is: the shell reads the clip's dimensions, and mounting against a + ;; db that has no clip in it yet is a frame of nothing for no reason. + (rf/dispatch-sync [::project/new]) ;; What the server already holds, asked for once. The list is small — a row per ;; ingested take — and having it before the first click is what lets the footage ;; picker be a picker rather than a path to type. (rf/dispatch [::footage/refresh]) + (rf/dispatch [::project/list-symbols]) + ;; After the blank document, so an address that names a project opens it over + ;; the blank one, and the blank one is what a bad address leaves on screen. + (collab/start!) + (history/install-keys!) (reset! root (rdc/create-root (js/document.getElementById "app"))) (mount) (player/start!)) diff --git a/frontend/src/arthur/db.cljs b/frontend/src/arthur/db.cljs index a116505..f4c0f8e 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 @@ -54,7 +51,14 @@ (def default {;; --- the document --- - :clip/current :take + ;; + ;; NOTHING IS LOADED. `core/init` dispatches `::project/new` before the first + ;; render, so the app opens on a blank stage rather than on whichever built-in + ;; scene happened to be convenient — the demos, the swarm and the two takes are + ;; rows in the media pool like anything else, and reference material is not a + ;; default. The values below are what a blank document is; they are replaced by + ;; that dispatch and exist so this map is a valid db on its own. + :clip/current nil :paint/revision 0 :palette :arthur/default ; a NAME; the ramp itself is project data @@ -64,19 +68,31 @@ ;; footage's. That is what deleting `makeXform` buys — the framing became a ;; 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 (select-keys (clip-entry :take) [:fps :frames :width :height :audio :display-fps]) + :clip (let [c (domain-clip/blank)] + {:fps (:fps c) + :width (:width c) :height (:height c) + :audio nil :display-fps (:fps c)}) ;; Which ingested footage to detect, and what the last load said. The list ;; comes from the server — tier 3 is the backend's since step 9 — so there is ;; no path to type any more. :footage {:id nil :label nil :loading? false :status nil - :available [] :chosen nil} + :available [] :chosen nil :uploaded #{}} + + ;; Every symbol in every saved project, for the pool's all-assets folder. Rows + ;; from `/api/symbols`, nothing loaded: a symbol from elsewhere is fetched when + ;; it is dropped. + :assets {:symbols [] :loading? false} ;; The document's own identity on the server. `:seq` is the monotonic project ;; version: a client that sees a delta with `seq > local + 1` refetches, which ;; is what will make staleness self-healing once there is a broadcast to miss. :project {:id nil :cid nil :name nil :seq nil :busy? false :status nil} + ;; What the server holds, for the open menu. A list of rows and nothing more — + ;; opening one fetches the document itself. + :projects {:items [] :loading? false} + ;; --- transport --- ;; ;; The playhead is in app-db like everything else. An earlier draft of @@ -92,11 +108,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 @@ -105,7 +122,48 @@ ;; Both for profiling: loop so a run at 4x lasts longer than the ;; clip, mute so sitting in one does not require enduring it. :loop? false - :muted? false}}) + :muted? false} + + ;; --- the editor's own state --- + ;; + ;; IN app-db, not in ratoms beside the components that read it. What is + ;; selected is asked by four panes at once — the params pane renders it, the + ;; timeline highlights its row, the stage draws its handles, the palette says + ;; which tone a new shape gets — and a `defonce` atom private to one namespace + ;; can only be shared by making the other three require that namespace for its + ;; state. It is also small and authored, which is the bar `arthur.db` sets. + ;; + ;; `:selection` is a vector whose first element says what kind of thing it + ;; 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 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 {:open nil + :tabs [] + :selection nil + :tone :skin-base + :tool nil + :draft [] + :knobs {} + :expanded #{}}}) (def rates "The transport's rates — all of them `playbackRate` on the audio element, so 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..35e0ade 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. @@ -47,11 +47,11 @@ :known (vec (sort-by str (keys by-id)))})))) nodes (into {:root {:id :root :name "stage" :kind :group :z "a1"}} - (map (fn [{:keys [uuid name z span at in center anchor drift phase]}] + (map (fn [{:keys [uuid name z span at 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} + :time {:mode :map :at at :rate 1} :channels {[:xform :pos] (if drift (position-track center anchor drift phase frames) (ch/framed (mapv - center anchor))) @@ -59,15 +59,15 @@ [:xform :scale] scale}}])) instances)) nodes (into nodes - (map (fn [{:keys [uuid linked-to z source span at in gain pan]}] + (map (fn [{:keys [uuid linked-to z source span at gain pan]}] [uuid {:id uuid :kind :audio :parent :root :z z :linked-to (uuid-of uuid linked-to) :source source :span span - :time {:mode :map :at at :in in :rate 1} + :time {:mode :map :at at :rate 1} :channels (cond-> {[:audio :gain] gain} 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/stage_8625.edn b/frontend/src/arthur/demo/stage_8625.edn index e75c21e..a1ecf64 100644 --- a/frontend/src/arthur/demo/stage_8625.edn +++ b/frontend/src/arthur/demo/stage_8625.edn @@ -19,16 +19,18 @@ :over []} ;; Audio placements are ordinary timeline nodes with channel parameters. ;; :linked-to is an editorial link; their spans and time maps are independent. + ;; A span is in the placement's OWN frames and :at is where its frame 0 lands on + ;; the stage, so every entrance below plays from its own start. :audio [{:id :voice-left :uuid #uuid "eeaa49c3-1238-469f-bf54-44929e379f6b" :linked-to :left :z "a3" :source {:footage "f8cace9e-4ad3-4796-973c-c62eeebe3d01"} - :span [0 280] :at 0 :in 0 + :at 0 :span [0 280] :gain {:animated? false :value 1.0}} {:id :voice-right :uuid #uuid "468239dd-0e3a-4e5c-ac6f-1858430a0355" :linked-to :right :z "a4" :source {:footage "f8cace9e-4ad3-4796-973c-c62eeebe3d01"} - :span [48 260] :at 48 :in 0 + :at 48 :span [0 212] :gain {:animated? true :interp :linear :keys {48 0.0, 60 1.0, 90 0.35, 115 0.9, 145 0.45, 170 1.0, 195 0.4, 220 0.85, 245 1.0, 259 0.0} @@ -49,29 +51,29 @@ :instances [{:id :left :uuid #uuid "ee7321c8-faf1-46d7-8029-37771898accb" :name "8625 left" :z "a1" - :span [0 280] :at 0 :in 0 + :at 0 :span [0 280] :center [40 40] :drift [3 2] :phase 0} {:id :right :uuid #uuid "1aa0da78-b4ed-4bb6-8d70-b09a3ec5e2c3" :name "8625 right" :z "a2" - :span [48 280] :at 48 :in 0 + :at 48 :span [0 232] :center [120 40] :drift [-3 2] :phase 17} {:id :top-third :uuid #uuid "23bb697d-eba7-4af6-a86c-606c50107088" :name "8625 top third" :z "a5" - :span [24 280] :at 24 :in 0 + :at 24 :span [0 256] :center [200 40] :drift [2 -3] :phase 31} {:id :top-fourth :uuid #uuid "f4f0241d-026e-4e50-9bea-a4ccde896d8a" :name "8625 top fourth" :z "a6" - :span [72 280] :at 72 :in 0 + :at 72 :span [0 208] :center [280 40] :drift [-2 -2] :phase 49} {:id :bottom-left :uuid #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218" :name "8625 bottom left" :z "a7" - :span [96 280] :at 96 :in 0 + :at 96 :span [0 184] :center [70 135] :drift [3 -2] :phase 63} {:id :bottom-middle :uuid #uuid "63f3fb32-9e94-4d68-a1c2-12e6de2d04b5" :name "8625 bottom middle" :z "a8" - :span [120 280] :at 120 :in 0 + :at 120 :span [0 160] :center [160 135] :drift [-2 3] :phase 81} {:id :bottom-right :uuid #uuid "fa338701-cb21-4d45-89f1-a5e706f045ec" :name "8625 bottom right" :z "a9" - :span [144 280] :at 144 :in 0 + :at 144 :span [0 136] :center [250 135] :drift [2 2] :phase 107}]} 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/bring.cljs b/frontend/src/arthur/domain/bring.cljs new file mode 100644 index 0000000..3152885 --- /dev/null +++ b/frontend/src/arthur/domain/bring.cljs @@ -0,0 +1,116 @@ +(ns arthur.domain.bring + "Bringing symbols into a clip from another: out of a saved project, or out of + a freeze of new footage. + + Copied, never linked. What comes in gets ids of its own where they are taken, + and editing it here does not touch where it came from. Tracking identities and + the analysis they were measured by come along only when the receiving clip can + hold them; otherwise what comes in is drawing, which plays but does not re-tune. + + Plain data in and out — documents, and in `placed` a document with its store — + so the events that fetch them are only fetching." + (:refer-clojure :exclude [take]) + (:require [arthur.domain.clip :as clip] + [clojure.string :as string])) + +(defn symbols + "Copy symbols `roots` of clip `other`, and every symbol they place, into + `clip`. Returns `{:clip :ids}`, where `:ids` maps each copied symbol's id in + `other` to its id here. + + AN ID THAT IS TAKEN IS RENAMED, never merged: two symbols that happen to share + an id are two drawings, and an instance's `:of` inside the copy is rewritten to + follow. `wanted` maps a root's id in `other` to the id it should preferably get, + which is how a symbol made from footage is called what the person typed rather + than `:main`. + + Only symbols travel. What else `other` holds — tracking identities, an analysis + — is the caller's decision, because whether it can come too depends on what + `clip` already has." + [clip other roots wanted] + (let [;; A tree walk is safe because placing cannot make a cycle. + reach (into #{} (mapcat #(tree-seq any? (partial clip/places other) %)) roots) + ids (reduce (fn [ids sid] + (let [taken? #(or (contains? (:symbols clip) %) + (some #{%} (vals ids)))] + (assoc ids sid (clip/free-id taken? (get wanted sid sid))))) + {} (sort-by str reach)) + copy (fn [sid] + (-> (clip/symbol other sid) + (assoc :id (ids sid)) + (update :nodes #(into {} (map (fn [[id n]] + [id (cond-> n (:of n) (update :of ids))])) + %))))] + {:clip (reduce (fn [c sid] (assoc-in c [:symbols (ids sid)] (copy sid))) clip reach) + :ids ids})) + +(defn symbol-id + "An id for a symbol a person has named: the name, lower-cased and hyphenated, + or `:symbol` when nothing of it survives." + [label] + (let [slug (-> (str label) string/lower-case + (string/replace #"[^a-z0-9]+" "-") + (string/replace #"^-+|-+$" ""))] + (keyword (if (seq slug) slug "symbol")))) + +(defn take + "Put `frozen`, a take, into `clip` as ONE symbol called `label`. Returns + `{:clip :sid :tracked?}`. + + `frozen` is what `flow/freeze/clip` makes: a `:main` that places one symbol per + tracked face. `:main` becomes the named symbol — it is what holds the faces in + stage pixels, so it is the thing worth placing — and it gets the take's SOUND + as an audio node of its own, source frames `range` of footage `footage-id`, so + wherever the symbol is placed it is heard. + + The tracking identities, and the analysis they were measured by, come along + only when `clip` has no analysis of its own and no face had to be renamed. A + document holds one analysis, and regeneration finds a face's symbol by its + subject id, so either condition failing means the take comes in as drawings + that play but cannot be re-tuned — `:tracked? false` says so." + [clip frozen label footage-id range] + (let [{c :clip ids :ids} (symbols clip frozen [:main] {:main (symbol-id label)}) + sid (ids :main) + source-fps (:fps frozen) + project-fps (:fps clip) + ;; The new symbol is played by the receiving project's clock. Keep its + ;; wall-clock duration by giving it project-rate frames, while its root + ;; maps those frames back onto the source timeline. A 30fps source in a + ;; 12fps project therefore has 2.5 source frames per project frame. + source-rate (if (and (number? source-fps) (pos? source-fps) + (number? project-fps) (pos? project-fps)) + (/ source-fps project-fps) + 1) + output-frames (max 1 (js/Math.ceil (/ (clip/frames c sid) source-rate))) + c (-> c + (assoc-in [:symbols sid :frames] output-frames) + (update-in [:symbols sid :nodes :root :time] + #(assoc (or % {}) :mode :map :at 0 :rate source-rate))) + tracked? (and (nil? (:analysis clip)) + (every? #(= % (ids %)) (keys (:subjects frozen))))] + {:sid sid + :tracked? tracked? + :clip (cond-> (-> c + (assoc-in [:symbols sid :name] (str label)) + (assoc-in [:symbols sid :nodes :sound] + {:id :sound :name "sound" :kind :audio :parent nil + :z "z-sound" :source {:footage footage-id} + ;; Source frame `start` plays on the symbol's 0. + :span range + :time {:mode :map + :at (/ (- (first range)) source-rate) + :rate source-rate}})) + tracked? (-> (assoc :analysis (:analysis frozen)) + (update :subjects merge (:subjects frozen)) + (update :features merge (:features frozen)) + (update :groups merge (:groups frozen))))})) + + +(defn placed + "`entry` — a document and its store — once `brought` holds the symbols brought + in and `store` their blocks: the stores merged and an instance of `sid` placed + in `host` at `frame`, its middle on stage pixel `point` or where it was drawn + when there is none. See `clip/place-symbol`." + [entry brought store sid host frame uuid point] + (let [st (merge (:store entry) store)] + (assoc entry :store st :clip (clip/place-symbol brought st host sid frame uuid point)))) diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index 1a80d6d..e854ca2 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -1,47 +1,48 @@ (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. + WHAT IS NOT HERE: how nested symbols' frames and coordinates relate, and + moving nodes between them, are `arthur.domain.nest`; bringing symbols in from + another clip is `arthur.domain.bring`. This namespace is the document and the + operations that only need the document. + + 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,46 +51,98 @@ 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 symbol + "One of the clip's symbols, by id." + [clip sid] + (get-in clip [:symbols sid])) -(defn root - "The timeline the clip plays." - [clip] - (timeline clip root-id)) +(defn symbol-name + "What to call a symbol: its `:name`, or its id when it has none." + [clip sid] + (or (:name (symbol clip sid)) (name 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 stage + "A symbol's stage as `[width height]`: its own, or the clip's where it has none. + Absent rather than copied in at creation, so a symbol nobody has sized follows + the project's size when that changes." + [clip sid] + (let [sym (symbol clip sid)] + [(or (:width sym) (:width clip)) (or (:height sym) (:height clip))])) + +(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, + which is long enough to key something into and short enough to scrub by hand." + 120) + +(defn blank + "A new, empty document: one empty symbol. + + `: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." + [] + {:name "untitled" + :fps 30 + :width 320 :height 200 + :subjects {} :features {} :groups {} + :symbols {:main {:id :main :frames blank-frames :nodes {}}}}) (defn- transform-op - "Put a symbol's already resolved mark into its instance's parent space." + "Put a symbol's already resolved mark into its instance's parent space. Its + name becomes its path of instances down to it, the path its timeline row has." [op m path] (let [at (fn [x y] [(+ (* (aget m 0) x) (* (aget m 2) y) (aget m 4)) (+ (* (aget m 1) x) (* (aget m 3) y) (aget m 5))]) scale (node/mean-scale m) - op (assoc op :node (conj path (:node op)))] + n (:node op) + op (assoc op :node (if (vector? n) (into path n) (conj path n)))] (case (:kind op) :poly (let [out (js/Float64Array. (.-length (:pts op)))] (dotimes [i (:n op)] @@ -105,35 +158,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))] @@ -141,10 +189,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?]) @@ -155,7 +203,109 @@ [])) (when-let [op (get by-id id)] [op])))) ids))))))] - (build root [] nil)))) + (build sid [] nil)))) + +(defn center + "The middle of everything symbol `sid` draws, over all its frames, in its own + coordinates. ALL frames rather than the first, so a symbol whose drawing + enters late, or travels, still has its middle where the drawing is. A symbol + that draws nothing gets the STAGE's middle, which is where a drawing made into + it will be, because drawings are made on the stage. + + What this feeds is a DEFAULT: `place-symbol` copies it into a new instance's + anchor and nothing ever updates it, as Flash's transformation point and After + Effects' anchor point are set once and left. A symbol that grows later keeps + its instances' pivots where they were, so nothing on screen moves." + [clip store sid] + (let [resolve (resolver clip store pal/index-of sid) + bounds (fn [[x0 y0 x1 y1 :as b] x y] + (if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y])) + [x0 y0 x1 y1] + (reduce + (fn [b {:keys [kind pts n cx cy r size]}] + (case kind + :poly (reduce (fn [b i] (bounds b (aget pts (* 2 i)) (aget pts (inc (* 2 i))))) + b (range n)) + :disc (-> b (bounds (- cx r) (- cy r)) (bounds (+ cx r) (+ cy r))) + :rect (let [h (/ size 2)] (-> b (bounds (- cx h) (- cy h)) (bounds (+ cx h) (+ cy h)))) + b)) + nil + (mapcat resolve (range (frames clip sid))))] + (if x0 + [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)] + (mapv #(/ % 2) (stage clip sid))))) + +(defn place-symbol + "An instance of symbol `sid`, inside symbol `host`, at `frame` of `host`. + + THE ANCHOR IS THE MIDDLE. Every instance pivots about the centre of what it + draws — see `center` — so rotating or scaling one turns it in place rather than + swinging it about a corner. At the identity transform the anchor moves nothing, + so where the drawing lands is `pos` alone: with `point`, a stage pixel, the + middle goes there; without one — a drop on the timeline — the drawing stays + where it was drawn. + + 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. + + The instance's own time starts where it was dropped: `:at frame` means frame 0 + of the symbol plays on `frame` of `host`, which is what dragging something onto + a playhead is asking for. Its `:span` is in its OWN frames — the whole symbol, + 0 to its length — wherever it was dropped; see `node/placed-span`. + + Refused, returning the clip unchanged, when it would make a cycle: a symbol + cannot be placed inside itself or inside anything it places." + [clip store host sid frame uuid point] + (let [target (symbol clip sid) + end (frames clip host)] + (if (or (nil? target) (nil? end) (nil? frame) (neg? frame) (>= frame end) + (contains-symbol? clip sid host)) + clip + (let [middle (center clip store sid)] + (update-symbol + clip host assoc-in [:nodes uuid] + {:id uuid + :name (symbol-name clip 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 sid)) + :span [0 (:frames target)] + :time {:mode :map :at frame :rate 1} + :channels {[:xform :pos] {:animated? false + :value (if point (mapv - point middle) [0 0])} + [:xform :anchor] {:animated? false :value middle}}}))))) + +(defn fresh-id + "The first `:symbol-N` the clip does not already hold. Readable because an id + shows up in saved leaf paths, and deterministic because this namespace is pure." + [clip] + (first (remove (:symbols clip) (map #(keyword (str "symbol-" %)) (iterate inc 1))))) + +(defn new-symbol + "A new, empty symbol `sid`, placed inside `host` at `frame` and running to the + end of it. Placed at the origin, so whatever is drawn into it lands where it was + drawn until the instance is moved." + [clip host sid frame uuid] + (let [end (frames clip host)] + (if (or (nil? end) (symbol clip sid) (nil? frame) (neg? frame) (>= frame end)) + clip + (-> clip + (assoc-in [:symbols sid] {:id sid :name (name sid) :frames (- end frame) :nodes {}}) + (place-symbol nil host sid frame uuid nil))))) + +(defn free-id + "`wanted`, or the first `wanted-2`, `wanted-3`… `taken?` does not claim. + Keeps the namespace, so `:sym/face` becomes `:sym/face-2`." + [taken? wanted] + (first (remove taken? + (cons wanted + (map #(keyword (namespace wanted) (str (name wanted) "-" %)) + (iterate inc 2)))))) (defn problems "Human-readable reasons this clip will not evaluate or save." @@ -164,28 +314,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))) @@ -194,11 +342,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/history.cljs b/frontend/src/arthur/domain/history.cljs new file mode 100644 index 0000000..2ff1dfa --- /dev/null +++ b/frontend/src/arthur/domain/history.cljs @@ -0,0 +1,130 @@ +(ns arthur.domain.history + "Undo, per person, as leaf writes. docs/architecture.md, \"Undo is per-user\". + + A step is the leaves one edit changed: what they held before, and what they + held after. Undoing writes the befores back as an ordinary edit, which the + next save sends like any other — so undo needs nothing from the server, and + nothing about it is shared. + + ONLY YOUR OWN CHANGES. A step undoes only if every leaf it touched still holds + what the step left there. Somebody else's write to one of them since — their + edit to the shape you made — refuses the step rather than taking their work + with it; it is dropped, and the next undo is the step before. With nobody else + in the document the values always match, and this is ordinary undo. + + A nil value is an absent leaf: a step that made a node has nil befores for its + leaves, so undoing it removes them." + (:require [clojure.string :as str])) + +(def gap-ms + "Edits to the same leaves closer together than this are one step: a drag + writes a vertex per pointermove, and is one thing to undo. Only when each + starts where the last left off — anything landing between them, a + collaborator's write included, makes the next edit a step of its own." + 1000) + +(def depth 200) + +(defn- changes + "`[before after]`, restricted to the paths that differ." + [before after] + (reduce (fn [[b a :as acc] path] + (let [x (get before path) + y (get after path)] + (if (= x y) acc [(assoc b path x) (assoc a path y)]))) + [{} {}] + (distinct (concat (keys before) (keys after))))) + +(defn- node-name [leaves path] + (let [[_ _ _ sid _ nid] (str/split path #"/") + node (get leaves (str/join "/" ["clip" "u" "symbol" sid "node" nid]))] + (or (:name node) (str/replace nid "~" "/")))) + +(defn- said + "What one changed leaf was, in words, and how much it outranks the others: + making or deleting a thing names the step before editing it does." + [before after path] + (let [[_ _ kind a b] (str/split path #"/") + leaves (merge before after)] + (case [kind b] + ["symbol" nil] [1 (str "symbol " (or (:name (get leaves path)) (str/replace a "~" "/")))] + ["symbol" "node"] + (cond (nil? (get before path)) [0 (str "add " (node-name leaves path))] + (nil? (get after path)) [0 (str "delete " (node-name leaves path))] + :else [1 (str "edit " (node-name leaves path))]) + (if (#{"channel" "measured"} b) + [1 (str "edit " (node-name leaves path))] + [2 (case kind + ("timing" "stage" "name") "project settings" + ("subject" "feature" "group") "tracking settings" + kind)])))) + +(defn label + "A step in words: \"add shape 3\", \"edit mouth, brow-l\"." + [before after paths] + (let [said (->> paths (map #(said before after %)) distinct sort) + top (first (first said)) + words (distinct (map second (filter #(= top (first %)) said)))] + (str (str/join ", " (take 2 words)) (when (< 2 (count words)) " …")))) + +(defn record + "History `h` with an edit from leaves `before` to `after` at time `now`." + [{:keys [done held?] :as h} before after now] + (let [[b a] (changes before after) + top (peek done)] + (cond + (empty? a) h + (and top (not (:closed? top)) (= b (:after top)) + (or held? (< (- now (:at top)) gap-ms))) + (assoc h :done (conj (pop done) (assoc top :after a :at now)) :undone []) + :else + (assoc h + :done (conj (vec (take-last (dec depth) done)) + {:before b :after a :at now :label (label before after (keys a))}) + :undone [])))) + +(defn- close [{:keys [done] :as h}] + (cond-> h (seq done) (assoc :done (conj (pop done) (assoc (peek done) :closed? true))))) + +(defn hold + "While a field has focus, everything typed into it is one step, however slowly + — the digits of 45 are seen as 4 and then 45, and undone as one. It starts a + step of its own rather than joining whatever came before." + [h] + (assoc (close h) :held? true)) + +(defn settle + "The field is done with: its step is finished, and nothing joins it." + [h] + (dissoc (close h) :held?)) + +(defn steps + "The labels, newest first: `:done` is what undo would take off, `:undone` + what redo would put back." + [h] + {:done (mapv :label (rseq (or (:done h) []))) + :undone (mapv :label (rseq (or (:undone h) [])))}) + +(defn- holds? [leaves m] + (every? (fn [[path v]] (= v (get leaves path))) m)) + +(defn- put-all [leaves m] + (reduce-kv (fn [ls path v] (if (nil? v) (dissoc ls path) (assoc ls path v))) leaves m)) + +(defn- move + "One step from `from` to `to`, if `leaves` still hold what it expects." + [h leaves from to expect write] + (when-let [step (peek (get h from))] + (let [h (update h from pop)] + (if (holds? leaves (expect step)) + {:leaves (put-all leaves (write step)) :history (update h to (fnil conj []) step)} + {:blocked step :history h})))) + +(defn undo + "`{:leaves :history}`, `{:blocked :history}` when somebody else has since + changed what the step touched, or nil with nothing to undo." + [h leaves] + (move h leaves :done :undone :after :before)) + +(defn redo [h leaves] + (move h leaves :undone :done :before :after)) diff --git a/frontend/src/arthur/domain/leaf.cljs b/frontend/src/arthur/domain/leaf.cljs index d5fbc6d..6269917 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 [:name :frames :width :height :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,16 @@ (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) + ;; `:nodes` is there before any node leaf is: an empty + ;; symbol has none, and is still a symbol. + nil (update-in acc [:symbols sid] #(merge {:nodes {}} % 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 +212,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 +240,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/nest.cljs b/frontend/src/arthur/domain/nest.cljs new file mode 100644 index 0000000..598f2e6 --- /dev/null +++ b/frontend/src/arthur/domain/nest.cljs @@ -0,0 +1,352 @@ +(ns arthur.domain.nest + "How nested symbols relate, and moving things between them. + + A row path — the ids from the open symbol down through instances, as the + timeline names a row — says where something is. Walking one answers three + questions at once, which is why there is one walk: what frame is showing down + there, what matrix takes its coordinates up to the open symbol's, and what + time map takes the open symbol's frames down to its own. + + ONE NESTING, AS FAR AS A PERSON IS CONCERNED. Putting a node inside another + symbol is how things are grouped: the symbol is a shared timeline, and its + instance is the handle that moves, retimes and transforms everything in it + together. Parent pointers inside a symbol stay — the roto rig is built on them + — but they are not something the timeline hands out. + + A MOVE CHANGES NEITHER THE PICTURE NOR THE TIMING. Every node has the same two + maps into its parent — the matrix of its transform, and `node/time-of` — and a + move keeps a node's world maps and re-expresses them under the new parent: the + matrix becomes a `:pinv`, Blender's parent-inverse, and the time a new `:at` + and `:rate`. Its channels, keys and span are untouched." + (:require [arthur.domain.clip :as clip] + [arthur.domain.node :as node] + [arthur.domain.palette :as pal] + [arthur.domain.symbol :as symbol])) + +(defn invert + "The inverse of a 2x3 affine, or nil when it has none — an instance scaled to + nothing has no inside to draw into." + [^js m] + (let [[a b c d e f] (array-seq m) + det (- (* a d) (* b c))] + (when-not (zero? det) + (js/Float64Array. #js [(/ d det) (/ (- b) det) (/ (- c) det) (/ a det) + (/ (- (* c f) (* d e)) det) (/ (- (* b e) (* a f)) det)])))) + +(defn- resolved + "Node `id` of symbol `sid`, resolved at `frame`: the resolver, which then + answers `symbol/world-of` and `symbol/frame-of` for it on that frame. + + Only its lineage is resolved, because where a node is depends on its parents + and nothing else in the symbol — and the whole symbol costs more than a frame + of the stage, which the editor asks for on every frame." + [clip store sid frame id] + (let [sym (clip/symbol clip sid) + sym (update sym :nodes select-keys (symbol/lineage (:nodes sym) id)) + r (symbol/resolver sym store pal/index-of nil {:source-fps (:fps clip)})] + (r frame) + r)) + +(defn inside + "Walk row path `path` down from symbol `sid`, whose frame `f` is showing, into + the node it ends at. Returns `{:sid :frame :matrix :time}`: the symbol that + node places (nil for one that places none), the frame of its own it is + showing, the matrix from its coordinates to `sid`'s, and the time map from + `sid`'s frames to its own — or nil when a node on the way is not on screen at + that frame, where there is no inside to be in. + + THE SAME STEP FOR EVERY NODE. Inside an instance is the symbol it places; + inside a shape is where its points and keys are. Either way it is the node's + own coordinates and frames, so a shape any depth down is edited through the + maps it is drawn with. + + The frame and the matrix come from RESOLVING each level, so they are the ones + the stage draws with, floors included. The time map is the affine part, floors + aside, and is nil through a looping node, whose frames come round again and do + not map one to one." + [clip store sid path f] + (reduce (fn [{:keys [sid frame matrix time]} id] + (let [r (resolved clip store sid frame id) + nodes (:nodes (clip/symbol clip sid)) + chain (map #(get nodes %) (rseq (symbol/lineage nodes id))) + m (symbol/world-of r id) + local (symbol/frame-of r id) + inner (get-in nodes [id :of])] + (if (and m (number? local) + (or (nil? inner) (< -1 local (clip/frames clip inner)))) + {:sid inner :frame (js/Math.floor local) + :matrix (node/mul! (node/mat) matrix m) + :time (when (and time (not-any? #(get-in % [:time :loop?]) chain)) + (reduce node/then-time time (map node/time-of chain)))} + (reduced nil)))) + {:sid sid :frame f :matrix (node/mat) :time {:at 0 :rate 1}} + path)) + +(defn drawn-inside + "Flat points drawn on symbol `sid`'s stage at frame `f`, re-expressed inside the + symbol `path` leads to, so a shape added there lands exactly where it was drawn. + `{:sid :frame :pts}`, or nil where `inside` finds nothing to be inside." + [clip store sid path f pts] + (when-let [{:keys [matrix] :as at} (inside clip store sid path f)] + (when-let [inv (invert matrix)] + (let [out (js/Float64Array. 2)] + (assoc (select-keys at [:sid :frame]) + :pts (into [] (mapcat (fn [[x y]] + (node/apply-pt! out 0 inv x y) + [(aget out 0) (aget out 1)])) + (partition 2 pts))))))) + +(defn audio-tracks + "Every sound symbol `sid` plays, as audio nodes in `sid`'s own frames: its own + and, recursively, those inside the instances it places. + + A sound inside a placed symbol is heard where the instance puts it, so each one + is carried OUT through the instance's time map — the same map a timeline row + draws with — and cut to the instance's own span, until it is in the frames of + the symbol being played. Keyed automation moves with it. What comes back is + what a mixer that only knows flat tracks can play as it is." + [clip sid] + (let [sym (clip/symbol clip sid)] + (into (vec (filter #(= :audio (:kind %)) (vals (:nodes sym)))) + (mapcat + (fn [inst] + (let [outer (node/time-of inst) + ->outer (fn [x] (+ (:at outer) (/ x (:rate outer)))) + [in out] (or (:span inst) [0 (clip/frames clip (:of inst))])] + (keep (fn [a] + (let [[p0 p1] (or (node/placed-span a) [in out]) + x0 (max p0 in) + x1 (min p1 out) + own (node/time-of a) + ->own (fn [x] (* (:rate own) (- x (:at own)))) + world (node/then-time outer own)] + (when (< x0 x1) + (-> a + (assoc :span [(->own x0) (->own x1)] + :time {:mode :map :at (:at world) :rate (:rate world)}) + (update :channels + (fn [chs] + (into {} (map (fn [[p ch]] + [p (cond-> ch (:keys ch) + (update :keys #(into {} (map (fn [[f v]] [(->outer f) v])) %)))])) + chs))))))) + (audio-tracks clip (:of inst))))) + (filter #(= :instance (:kind %)) (vals (:nodes sym))))))) + +(defn- retime + "Node `n` with its own time map replaced by `m`, and nothing else touched: its + span and keys are in its own frames, which a move does not change." + [n {:keys [at rate]}] + (assoc n :time (merge (:time n) {:mode :map :at at :rate rate :offset 0}))) + +(defn- subtree + "`id` and every node whose parent chain reaches it." + [nodes id] + (into #{} (filter #(some #{id} (symbol/lineage nodes %))) (keys nodes))) + +(defn delete-node + "Take node `id` out of symbol `sid`, with everything hanging off it." + [clip sid id] + (clip/update-symbol clip sid update :nodes #(apply dissoc % (subtree % id)))) + +(defn- transplant + "Move node `id` from symbol `host`, where frame `frame` is showing, into symbol + `target`, keeping where it is on screen and when. `carry` is the matrix from + `host`'s coordinates to `target`'s, and `back` the time map from `target`'s + frames to `host`'s. + + THE ONE RULE, for space and time alike: the node's new map is its old one + under what it leaves — its parents here, and the way from here to there — so + the picture and the timing through it do not change. For space that is a + `:pinv`, Blender's parent-inverse; for time it is a new `:at` and `:rate`. Its + channels, keys and span are untouched, and its children keep their parent + pointers and come with it. `{:clip}` or `{:refused why}`." + [clip store host frame id target carry back] + (let [nodes (:nodes (clip/symbol clip host)) + n (get nodes id) + moving (subtree nodes id) + ;; Its parents in this symbol, outermost first. + chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id)))) + parent (when-let [p (:parent n)] + (some-> (symbol/world-of (resolved clip store host frame p) p) + js/Float64Array.from))] + (cond + (= host target) {:refused "it is already there"} + (and (= :instance (:kind n)) (clip/contains-symbol? clip (:of n) target)) + {:refused "a symbol cannot go inside itself"} + (some (fn [m] (or (:measured (get nodes m)) + (some #(or (:dense %) (:generated %)) (vals (:channels (get nodes m)))))) + moving) + {:refused "generated parts stay with their take — move the instance that places it"} + (some (fn [[k m]] (and (:stencil m) + (not= (contains? moving k) (contains? moving (:stencil m))))) + nodes) + {:refused "a stencil and what it clips have to move together"} + (and (:parent n) (nil? parent)) + {:refused "its parent is not on screen at this frame"} + (some #(get-in % [:time :loop?]) chain) + {:refused "a looping parent is in the way"} + :else + (let [taken (:nodes (clip/symbol clip target)) + ids (into {} (map (fn [m] [m (clip/free-id #(contains? taken %) m)])) moving) + pinv (reduce #(node/mul! (node/mat) %1 %2) carry (keep identity [parent (node/pinv n)])) + ;; back · parents · own: target frames to the node's own. + time (reduce node/then-time back (concat (map node/time-of chain) + [(node/time-of n)])) + moved (for [m moving + :let [x (get nodes m)]] + (cond-> (-> x + (assoc :id (ids m)) + (update :parent #(get ids %))) + (:stencil x) (update :stencil ids) + (= m id) (-> (retime time) + (assoc :pinv (vec (array-seq pinv)) + :z (str "z" (js/Date.now) "-" (ids m))))))] + {:id (ids id) + :sid target + :clip (-> clip + (clip/update-symbol host update :nodes #(apply dissoc % moving)) + (clip/update-symbol target update :nodes (fnil into {}) + (map (juxt :id identity)) moved))})))) + +(defn move-node + "Move the node at row path `from` — its last id is the node, the rest the + instances down to where it lives — into the symbol placed by the instance at + row path `to`, or to the top of `open` when `to` is empty. Row paths start at + `open`, and `f` is its current frame, at which both have to be on screen. + `{:clip :sid :id}` — the symbol it landed in and its id there, renamed only if + that one was taken — or `{:refused why}`." + [clip store open from to f] + (let [here (inside clip store open (pop from) f) + there (inside clip store open to f) + a (:time here) + b (:time there) + inv (some-> there :matrix invert)] + (cond + (nil? (get-in clip [:symbols (:sid here) :nodes (peek from)])) + {:refused "nothing to move"} + (or (nil? here) (nil? there)) {:refused "both have to be on screen at this frame"} + (nil? (:sid there)) {:refused "only a symbol can take it"} + (not (and a b)) {:refused "a looping instance is in the way"} + (nil? inv) {:refused "the target is scaled to nothing"} + :else (transplant clip store (:sid here) (:frame here) (peek from) (:sid there) + (node/mul! (node/mat) inv (:matrix here)) + (node/then-time (node/invert-time b) a))))) + +(defn- down + "Walk row path `path` down from symbol `sid` by structure alone: `{:sid + :time}`, the symbol it leads to and the time map from `sid`'s frames to that + symbol's own, nil through a loop. + + `inside` without the frame. Which symbol a row is in and how fast it runs + there are the same on every frame, so asking needs nothing to be on screen; + only a move that keeps the PICTURE needs a frame, for the matrix." + [clip sid path] + (let [sids (reductions #(get-in clip [:symbols %1 :nodes %2 :of]) sid path) + ;; Every node on the way, outermost first: each instance, after its + ;; parents in the symbol it is in. + chain (mapcat (fn [sid id] + (let [nodes (:nodes (clip/symbol clip sid))] + (map #(get nodes %) (rseq (symbol/lineage nodes id))))) + sids path)] + {:sid (last sids) + :time (when (not-any? #(get-in % [:time :loop?]) chain) + (reduce node/then-time {:at 0 :rate 1} (map node/time-of chain)))})) + +(defn slide + "Move the node at row path `path` along its symbol's time by `df` frames of + `open`. `{:clip}` or `{:refused why}`. + + ONE WRITE TO `:at`, for every node alike: its span, keys and children are in + its own frames and come with it. `df` is carried down into the frames `:at` is + in — the symbol's, through each instance on the way, and its parents' there." + [clip open path df] + (let [here (down clip open (pop path)) + id (peek path) + nodes (:nodes (clip/symbol clip (:sid here)))] + (cond + (nil? (get nodes id)) {:refused "nothing to move"} + (nil? (:time here)) {:refused "a looping instance is in the way"} + :else + (let [chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id)))) + d (* df (:rate (reduce node/then-time (:time here) (map node/time-of chain))))] + {:clip (clip/update-symbol + clip (:sid here) update-in [:nodes id] + (fn [n] + (if (= :map (get-in n [:time :mode])) + (update-in n [:time :at] (fnil + 0) d) + (assoc n :time {:mode :map :at d :rate 1}))))})))) + +(defn restack + "Put the node at row path `from` just in front of the one at `to` when + `front?`, or just behind it — side by side in one symbol, as the timeline lists + them. `{:clip :sid :id}` or `{:refused why}`. + + ONE WRITE TO `:z`, between the two it lands between, so nothing else is + renumbered. Among the nodes that share its parent, because that is what `:z` + orders; a roto part's parent is the rig, and it restacks within that." + [clip open from to front?] + (let [{sid :sid} (down clip open (pop to)) + nodes (:nodes (clip/symbol clip sid)) + n (get nodes (peek from)) + t (get nodes (peek to)) + z #(or (:z %) "") + zs (->> nodes + (keep (fn [[k m]] (when (and (= (:parent m) (:parent t)) (not= k (peek from))) + (z m)))) + sort)] + (cond + (not= (pop from) (pop to)) {:refused "only things side by side can be restacked"} + (or (nil? n) (nil? t)) {:refused "nothing to restack"} + (not= (:parent n) (:parent t)) {:refused "they hang off different parents"} + :else + {:sid sid + :id (peek from) + :clip (clip/update-symbol + clip sid assoc-in [:nodes (peek from) :z] + (if front? + (symbol/z-between (z t) (first (filter #(pos? (compare % (z t))) zs))) + (symbol/z-between (last (filter #(neg? (compare % (z t))) zs)) (z t))))}))) + +(defn group + "Put the nodes at row paths `froms`, all side by side in one symbol, into a + NEW symbol `sid`, placed where they were by instance `uuid`. `{:clip}` or + `{:refused why}`. + + The new symbol starts where the earliest of them starts and ends where the + last one ends, so its instance's bar on the timeline covers exactly theirs. + Its instance sits at the identity, so nothing moves, and pivots about the + middle of what it now holds." + [clip store open froms sid uuid f] + (let [host-path (pop (first froms)) + {host :sid frame :frame} (inside clip store open host-path f) + nodes (:nodes (clip/symbol clip host))] + (cond + (nil? host) {:refused "they have to be on screen at this frame"} + (not-every? #(= host-path (pop %)) froms) {:refused "only things side by side can be grouped"} + (some #(nil? (get nodes (peek %))) froms) {:refused "nothing to group"} + :else + (let [whole [0 (clip/frames clip host)] + spans (for [from froms + :let [n (get nodes (peek from))]] + (or (node/placed-span n) + (when (= :instance (:kind n)) + (node/placed-span (assoc n :span [0 (clip/frames clip (:of n))]))) + whole)) + start (js/Math.floor (max 0 (apply min (map first spans)))) + end (min (second whole) (apply max (map second spans))) + made (-> clip + (assoc-in [:symbols sid] {:id sid :name (name sid) + :frames (max 1 (js/Math.ceil (- end start))) + :nodes {}}) + (clip/place-symbol store host sid start uuid nil)) + back (node/invert-time (node/time-of (get-in made [:symbols host :nodes uuid]))) + moved (reduce (fn [acc from] + (let [r (transplant (:clip acc) store host frame (peek from) sid + (node/mat) back)] + (if (:refused r) (reduced r) r))) + {:clip made} froms)] + (cond-> moved + (:clip moved) (update :clip assoc-in + [:symbols host :nodes uuid :channels [:xform :anchor] :value] + (clip/center (:clip moved) store sid))))))) diff --git a/frontend/src/arthur/domain/node.cljs b/frontend/src/arthur/domain/node.cljs index bc2d432..ad36b3c 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 @@ -70,6 +70,30 @@ [n] (merge defaults (:channels n))) +(defn set-channel + "Write `v` into channel `path`: a key on the node's own frame `f` when the + channel is keyed, its one value when it is not." + [n path f v] + (let [c (get (channels n) path)] + (assoc-in n [:channels path] + (if (:keys c) (assoc-in c [:keys f] v) (ch/framed v))))) + +(defn toggle-key + "Key channel `path` on the node's own frame `f` with the value it has there, or + take the key there off. The first key starts the channel animating and taking + the last one off leaves it that one value. A boolean holds; anything else tweens." + [n path f] + (let [c (get (channels n) path) + v (ch/value-at c f) + ks (dissoc (:keys c) f)] + (assoc-in n [:channels path] + (cond + (not (:keys c)) (ch/keyed {f v} (if (boolean? v) :hold :linear)) + (not (contains? (:keys c) f)) (assoc-in c [:keys f] v) + (seq ks) (cond-> (assoc c :keys ks) + (:segments c) (update :segments dissoc f)) + :else (ch/framed v))))) + ;; --------------------------------------------------------------------------- ;; time maps ;; @@ -103,6 +127,43 @@ (/ source-fps picture-fps)))) f)) +(defn time-of + "A node's own time as the affine map it is: `{:at a :rate r}`, meaning a frame + `p` of its parent is frame `r·(p − a)` of its own. THE SAME FOR EVERY NODE. A + node with no time map is `{:at 0 :rate 1}`, reading its parent's frames as its + own; a mouth lead's `:offset` is folded into `:at`. Exposure and picture + sampling are floors, not part of the map, and are left out: this is the map a + move preserves and a timeline row draws with, and `local-frame` is what reads + a frame, floors and the lead in their load-bearing order." + [n] + (let [{:keys [mode at rate offset] :or {at 0 rate 1 offset 0}} (:time n)] + (if (= mode :map) + {:at (- at (/ offset rate)) :rate rate} + {:at 0 :rate 1}))) + +(defn then-time + "`outer` then `inner`: the map from `outer`'s parent straight to `inner`'s own + frames. Time maps compose like matrices do, which is what makes a nesting of + any depth one map." + [{a1 :at r1 :rate} {a2 :at r2 :rate}] + {:at (+ a1 (/ a2 r1)) :rate (* r1 r2)}) + +(defn invert-time [{:keys [at rate]}] + {:at (- (* at rate)) :rate (/ 1 rate)}) + +(defn placed-span + "Where a node exists, as `[in out)` in its PARENT's frames, or nil for always. + + A `:span` is in the node's OWN frames — which of its frames exist — for every + node alike, and its time map says where they land in the parent. For a node + with no time map the two are the same frames, so a shape's span reads as it + always did. Moving a node along its parent is then one write to `:at`, and the + span, which says what the node IS, does not change when it is moved." + [n] + (when-let [[in out] (:span n)] + (let [{:keys [at rate]} (time-of n)] + [(+ at (/ in rate)) (+ at (/ out rate))]))) + (defn local-frame "Apply a node's time map to the frame it was handed by its parent. @@ -111,29 +172,24 @@ 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 to performance nodes and not to the plate, which is the entire point of it." [n f] - (let [{:keys [mode offset rate at in source-fps sample-fps] - ex :expose :or {mode :inherit}} (:time n)] + (let [{:keys [mode source-fps sample-fps] ex :expose :or {mode :inherit}} (:time n)] (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" - {: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)) - (+ (or in 0) (* (or rate 1) (- f (or at 0)))) - f) - sample-fps (sample-frame source-fps sample-fps) - ex (expose ex) - offset (+ offset)))))) + (let [{:keys [at rate offset] :or {at 0 rate 1}} (:time n)] + (cond-> (* rate (- f at)) + sample-fps (sample-frame source-fps sample-fps) + ex (expose ex) + offset (+ offset))))))) ;; --------------------------------------------------------------------------- ;; the transform @@ -265,15 +321,17 @@ (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 (some? (get-in n [:time :rate])) (not (pos? (get-in n [:time :rate])))) - (conj "an instance's :rate must be positive") + (conj ":time :rate must be positive") (nil? (:z n)) (conj "no :z — draw order is authored per scene, not implied by the tree") (and (:span n) (not= 2 (count (:span n)))) - (conj ":span must be [in out]")) + (conj ":span must be [in out]") + (some? (get-in n [:time :in])) + (conj ":time has an :in — an instance's first frame is the start of its own :span")) (into (when valid (for [[path _] (:channels n) 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..9d83172 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 @@ -83,19 +89,27 @@ :state (when state (wire/base64 state))})) (block-keys leaves)))}))) +(defn tier1 + "A response's leaves object -> `{path value}`, the shape `leaf/leaves` returns." + [^js leaves] + (into {} (map (fn [path] [path (wire/decode-json (aget leaves path))])) + (js-keys leaves))) + +(defn store + "Fetched blocks -> the store `save` reads them back out of." + [blocks] + (into {} + (map (fn [^js b] + [(.-key b) + (cond-> {:descriptor (.-descriptor b) + :data (wire/typed (block-type (.-descriptor b)) + (.-data b))} + (.-state b) (assoc :state (wire/bytes-of (.-state b))))])) + (array-seq (or blocks #js [])))) + (defn load "The parsed response -> `{:clip :store}`, which is what `flow/freeze` returns and therefore what the player already knows how to play." [cid ^js doc] - (let [leaves (.-leaves doc) - tier1 (into {} (map (fn [path] [path (wire/decode-json (aget leaves path))])) - (js-keys leaves))] - {:clip (leaf/clip cid tier1) - :store (into {} - (map (fn [^js b] - [(.-key b) - (cond-> {:descriptor (.-descriptor b) - :data (wire/typed (block-type (.-descriptor b)) - (.-data b))} - (.-state b) (assoc :state (wire/bytes-of (.-state b))))])) - (array-seq (or (.-blocks doc) #js [])))})) + {:clip (leaf/clip cid (tier1 (.-leaves doc))) + :store (store (.-blocks doc))}) 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 78% rename from frontend/src/arthur/domain/timeline.cljs rename to frontend/src/arthur/domain/symbol.cljs index 09017ae..23fbb70 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 @@ -112,6 +110,30 @@ [nodes id] (mapv #(:z (get nodes %)) (rseq (lineage nodes id)))) +(defn z-between + "A `:z` that sorts strictly between `a` and `b`, which must be in order; nil + for either is no bound on that side. What makes restacking one write. + + The midpoint of the first character they differ in, when there is room. When + there is not, anything that starts with `a` and is longer sorts after it, and + before `b` too unless `a` is a prefix of `b` — and then the room is found one + character further into `b`. \"0\" is the floor, and nothing this makes ends + in it, so there is always a further character to go to; only an authored key + ending in \"0\" leaves none, and then one character less than it does." + [a b] + (let [a (or a "")] + (if (nil? b) + (str a "m") + (let [i (count (take-while true? (map = a b))) + hi (.charCodeAt b i) + lo (if (< i (count a)) (.charCodeAt a i) 48) + mid (quot (+ lo hi) 2)] + (cond + (> mid lo) (str (subs b 0 i) (char mid)) + (< i (count a)) (str a "m") + (< (inc i) (count b)) (str (subs b 0 (inc i)) (z-between nil (subs b (inc i)))) + :else (str (subs b 0 i) (char (dec hi)) "m")))))) + (defn- z-lex "Lexicographic compare of two z paths, a prefix sorting first. @@ -129,9 +151,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 +168,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. @@ -168,10 +190,11 @@ (defn- in-span? "`:span` is Lottie's ip/op and Flash's PlaceObject/RemoveObject: the range over which the node EXISTS, tested in the PARENT's frame space and therefore before - the node's own time map runs. Distinct from `[:vis]`, which blinks an existing - node on and off. Half-open, so two adjacent spans do not both own a frame." + the node's own time map runs — an instance's own-time span is mapped out by + `node/placed-span`. Distinct from `[:vis]`, which blinks an existing node on and + off. Half-open, so two adjacent spans do not both own a frame." [n f] - (if-let [[in out] (:span n)] + (if-let [[in out] (node/placed-span n)] (and (>= f in) (< f out)) true)) @@ -229,7 +252,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 +294,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 +334,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 +409,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 +479,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 +530,26 @@ ;; --------------------------------------------------------------------------- -(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}) + write. + + `:name` is what a person calls it, and is not its id: an id is what instances + and saved leaves point at, so renaming a symbol must not change it. + + `:width` and `:height` are the symbol's own stage, and are absent until someone + sets them: a symbol without them uses the clip's — see `clip/stage`." + #{:id :name :frames :width :height :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 +559,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 +570,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 +583,23 @@ :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 (for [k [:width :height] + :let [v (get sym k)] + :when (and (some? v) (not (and (integer? v) (pos? v))))] + (str k " is " (pr-str v) " — a symbol stage dimension must be a positive integer"))) (into (try (doall (map #(depth nodes %) (keys nodes))) nil diff --git a/frontend/src/arthur/events/collab.cljs b/frontend/src/arthur/events/collab.cljs new file mode 100644 index 0000000..ace6d1d --- /dev/null +++ b/frontend/src/arthur/events/collab.cljs @@ -0,0 +1,438 @@ +(ns arthur.events.collab + "Everything that makes a document somewhere other people are: its address, who + you are, who else is in it, and their writes arriving while you work. + + docs/architecture.md, Collaboration, and tl's model with the four additions it + asks for. Writes stay on HTTP; the socket carries presence and the deltas the + server broadcasts after a write commits. + + ONE RULE FOR THE ADDRESS AND THE ROOM. They follow `[:project :id]`, whatever + event changed it — open, save, new, a copy — through one interceptor, so no + event that loads a document has to remember to join its room. + + THE OUTBOX RULE, without an outbox. A remote leaf lands unless we have a change + to that leaf the server has not seen — a leaf whose local value differs from the + last value we synced. Otherwise their write would snap our unsaved edit back. + The next save sends ours, and if theirs moved since, it answers 409 and we catch + up, and the save after that is ours." + (:require [arthur.domain.leaf :as leaf] + [arthur.domain.project :as project] + [arthur.events.edit :as edit] + [arthur.events.playback :as pb] + [arthur.events.project :as events.project] + [arthur.footage.store :as store] + [arthur.fx.http :as http] + [clojure.string :as str] + [re-frame.core :as rf])) + +;; --------------------------------------------------------------------------- +;; the address + +(defn- path-id + "The project a path names: `/p//`. The slug is for people; the + id is what finds it." + [path] + (second (re-matches #"/p/([0-9a-fA-F-]{36})(?:/.*)?" path))) + +(defn slug [name] + (or (not-empty (-> (str/lower-case (or name "")) + (str/replace #"[^a-z0-9]+" "-") + (str/replace #"^-+|-+$" ""))) + "untitled")) + +(defn project-path [id name] (str "/p/" id "/" (slug name))) + +(defn- route! [] + (rf/dispatch [::routed (path-id (.. js/window -location -pathname))])) + +(defn navigate! [path] + (.pushState js/history nil "" path) + (route!)) + +(rf/reg-event-fx + ::routed + ;; `/` is the index of your projects; a project is only ever at its address. + (fn [{:keys [db]} [_ id]] + (cond + (nil? id) {:db (assoc db :route :index) + :dispatch [::events.project/list]} + (= id (get-in db [:project :id])) {:db (assoc db :route [:project id])} + :else {:db (assoc db :route [:project id]) + :dispatch [::events.project/open id]}))) + +(rf/reg-sub ::route (fn [db _] (:route db))) + +(rf/reg-fx + ::create! + (fn [name] + (-> (http/POST "/api/projects" #js {:name name}) + (.then (fn [^js made] (navigate! (project-path (.-id made) (.-name made))))) + (.catch #(rf/dispatch [::refused (ex-message %)]))))) + +(rf/reg-event-fx ::create (fn [_ [_ name]] {::create! (or name "untitled")})) + +;; --------------------------------------------------------------------------- +;; the socket + +(defonce ^:private socket (atom nil)) +(defonce ^:private conn (atom {:id nil :tries 0 :timer nil})) + +(defn- ws-url [id] + (str (if (= "https:" (.. js/window -location -protocol)) "wss://" "ws://") + (.. js/window -location -host) "/ws/projects/" id)) + +(declare open!) + +(defn- retry-later! [id] + (let [tries (:tries @conn) + delay (min 30000 (* 500 (js/Math.pow 2 tries)))] + (swap! conn assoc :tries (inc tries) + :timer (js/setTimeout #(when (= id (:id @conn)) (open! id)) delay)))) + +(defn- open! [id] + (let [s (js/WebSocket. (ws-url id))] + (reset! socket s) + (set! (.-onopen s) (fn [_] (swap! conn assoc :tries 0))) + (set! (.-onmessage s) (fn [e] (rf/dispatch [::message (js/JSON.parse (.-data e))]))) + ;; Only the CURRENT socket clears the roster and retries: closing the last + ;; project's on a switch must not wipe the new one's. + (set! (.-onclose s) (fn [_] + (when (identical? s @socket) + (reset! socket nil) + (rf/dispatch [::peers-reset]) + (retry-later! id)))))) + +(defn- connect! [id] + (some-> (:timer @conn) js/clearTimeout) + (when-let [s @socket] (set! (.-onclose s) nil) (.close s)) + (reset! socket nil) + (reset! conn {:id id :tries 0 :timer nil}) + (rf/dispatch [::peers-reset]) + (when id (open! id))) + +(rf/reg-fx + ::follow! + (fn [{:keys [id name]}] + (when id + (let [here (.. js/window -location -pathname) + path (project-path id name)] + (cond + (= path here) nil + ;; Renamed: the same page, a new slug, and no new history entry. + (= id (path-id here)) (.replaceState js/history nil "" path) + :else (.pushState js/history nil "" path)))) + (set! (.-title js/document) (if name (str name " — arthur") "arthur")) + (when (not= id (:id @conn)) + (connect! id)))) + +(rf/reg-fx ::reconnect! (fn [_] (connect! (:id @conn)))) + +(def autosave? + "Every edit saves. Off only for tests that need an edit held unsaved." + true) + +(def ^:private follow + "The address, the title and the room follow the open project, and the + document saves itself on every edit — `:paint/revision` is what moves when + the document does. A save with nothing to send sends nothing, and one made + while another is in flight goes when it lands. + + THERE IS NO BARE PROJECT. A document with no id on screen at a project's + address — a built-in example, opened from the menu — is saved at once, and + becomes a project with an address of its own." + (rf/->interceptor + :id ::follow + :after (fn [ctx] + (let [db (get-in ctx [:effects :db] (get-in ctx [:coeffects :db])) + before (get-in ctx [:coeffects :db :project]) + after (:project db)] + (cond-> ctx + (not= (select-keys before [:id :name]) (select-keys after [:id :name])) + (update-in [:effects :fx] (fnil conj []) + [::follow! (select-keys after [:id :name])]) + + (and autosave? (:id after) (vector? (:route db)) + (not= (:paint/revision db) (get-in ctx [:coeffects :db :paint/revision]))) + (update-in [:effects :fx] (fnil conj []) + [:dispatch [::events.project/save {:auto? true}]]) + + (and (nil? (:id after)) (vector? (:route db)) + (or (:id before) (not= (:cid before) (:cid after)))) + (update-in [:effects :fx] (fnil conj []) + [:dispatch [::events.project/save]])))))) + +;; --------------------------------------------------------------------------- +;; presence + +(rf/reg-event-db ::peers-reset (fn [db _] (assoc db :peers {}))) + +(defn- peer [^js m] {:cid (.-cid m) :user (.-user m)}) + +(rf/reg-event-fx + ::message + (fn [{:keys [db]} [_ ^js m]] + (case (.-kind m) + "welcome" {:db (assoc db :peers {} :peer-cid (.-cid m)) + ;; Anything written between our GET and our joining the room + ;; was broadcast to a room we were not in yet. + :dispatch [::catch-up]} + "roster" {:db (update db :peers into (map (fn [^js p] [(.-cid p) (peer p)])) + (array-seq (.-peers m)))} + ("join" "state") {:db (assoc-in db [:peers (.-cid m)] (peer m))} + "leave" {:db (update db :peers dissoc (.-cid m))} + "delta" {:dispatch [::delta m]} + "access" {:dispatch [::catch-up]} + {}))) + +(rf/reg-sub + ::peers + (fn [db _] + (->> (vals (:peers db)) + (remove #(= (:cid %) (:peer-cid db))) + (sort-by (juxt (comp nil? :user) :user))))) + +;; --------------------------------------------------------------------------- +;; their writes + +(defn- put [m path v] (if (nil? v) (dissoc m path) (assoc m path v))) + +(defn- landed + "Their change laid over ours, as `[local synced behind]`; a nil value is a + removal. + + A leaf we have changed and not saved keeps our value, and theirs waits in + `behind` rather than in `synced`: `synced` is what we have SEEN, and putting + theirs there would let our next save overwrite it without a word. `take?` is + the first write winning — theirs was, so it goes on screen over ours." + [local synced behind theirs take?] + (let [pending? #(not= (get local %) (get synced %))] + (reduce-kv (fn [[now seen behind] path v] + (cond + (not (pending? path)) [(put now path v) (put seen path v) behind] + take? [(put now path v) (put seen path v) (dissoc behind path)] + :else [now seen (assoc behind path v)])) + [local synced behind] theirs))) + +(rf/reg-fx + ::fetch-blocks! + (fn [{:keys [keys then]}] + (-> (js/Promise.all (into-array (map #(http/GET (str "/api/blocks/" %)) keys))) + (.then #(rf/dispatch (conj then (project/store %)))) + (.catch #(rf/dispatch [::events.project/failed (ex-message %)]))))) + +(rf/reg-event-fx + ::remote + ;; `written` and `removed` against what we last synced; `blocks` is the store + ;; of any the new leaves name that we do not hold, once fetched. + (fn [{:keys [db]} [_ {:keys [by written removed take?] at :seq :as change} blocks]] + (let [cid (get-in db [:project :cid]) + entry (store/entry (:clip/current db)) + local (leaf/leaves cid (:clip entry)) + theirs (merge written (zipmap removed (repeat nil))) + lost (if take? + (count (filter #(not= (get local %) (get (:synced entry) %)) (keys theirs))) + 0) + [now synced behind] (landed local (:synced entry) (:behind entry) theirs take?) + have (merge (:store entry) blocks) + lack (remove #(contains? have %) (project/block-keys now)) + status (fn [now synced] + (if (pos? lost) + (str lost (if (= 1 lost) " change" " changes") + " of yours lost to someone else's at the same moment — in your undo list") + (str (or by "someone") " saved r" at + (when (not= now synced) " · yours unsaved"))))] + (cond + (seq lack) + {::fetch-blocks! {:keys lack :then [::remote change]}} + + (= now local) + {:db (-> db + (update :clip/current + #(or (store/edit-entry! % (fn [e] (assoc e :synced synced + :behind behind))) + %)) + (assoc-in [:project :seq] at) + ;; Our own write, back from the room, changes nothing to say. + (cond-> (or take? (not= by (get-in db [:me :username]))) + (assoc-in [:project :status] (status now synced))))} + + :else + (let [clip (leaf/clip cid now) + ;; Theirs, so not a step of ours to undo. + db' (-> (edit/replace-entry db #(-> % + (assoc :clip clip :synced synced + :behind behind) + (update :store merge blocks))) + (edit/transport clip) + (update :project merge + {:seq at :status (status now synced)}))] + (cond-> {:db db'} + (not= (:fps clip) (get-in db [:clip :fps])) + (assoc ::pb/seek! [(:fps clip) (pb/frames db') (get-in db [:playback :frame])]))))))) + +(defn- ours + "The clip in a delta or a document that is the one open here." + [db clips] + (let [cid (get-in db [:project :cid])] + (first (filter #(= cid (.-cid ^js %)) (array-seq clips))))) + +(rf/reg-event-fx + ::delta + (fn [{:keys [db]} [_ ^js m]] + (let [local (get-in db [:project :seq]) + seq (.-seq m)] + (cond + (or (nil? local) (<= seq local)) {} + ;; A missed delta is a stale document forever, unless it is noticed. + (> seq (inc local)) {:dispatch [::catch-up]} + :else + (let [^js c (ours db (.-clips m))] + (cond-> {:db (cond-> (assoc-in db [:project :seq] seq) + (.-name m) (assoc-in [:project :name] (.-name m)))} + c (assoc :dispatch [::remote {:seq seq :by (.-by m) + :written (project/tier1 (.-leaves c)) + :removed (vec (.-removed c))}]))))))) + +(rf/reg-fx + ::catch-up! + (fn [[id take?]] + (-> (http/GET (str "/api/projects/" id)) + (.then #(rf/dispatch [::caught-up % take?])) + (.catch #(js/console.warn "catching up failed" %))))) + +(rf/reg-event-fx + ::catch-up + (fn [{:keys [db]} [_ take?]] + (if-let [id (get-in db [:project :id])] + {::catch-up! [id take?]} + {}))) + +(rf/reg-event-fx + ::caught-up + ;; The whole document, diffed against what we last synced: which leaves they + ;; wrote, and which they deleted. + (fn [{:keys [db]} [_ ^js loaded take?]] + (let [^js c (ours db (.-clips loaded)) + synced (:synced (store/entry (:clip/current db))) + theirs (when c (project/tier1 (.-leaves c))) + access {:owner (.-owner loaded) :editors (vec (.-editors loaded)) + :can-edit? (.-can_edit loaded)}] + (cond-> {:db (update db :project merge access)} + (and c (or take? (not= (.-seq loaded) (get-in db [:project :seq])))) + (assoc :dispatch [::remote {:seq (.-seq loaded) :by nil :take? take? + :written (into {} (remove (fn [[p v]] (= v (get synced p)))) + theirs) + :removed (remove #(contains? theirs %) (keys synced))}]))))) + +;; --------------------------------------------------------------------------- +;; who you are, and who else may write + +(rf/reg-fx + ::request! + (fn [{:keys [method url body then]}] + (-> (http/request! method url body) + (.then #(rf/dispatch (conj then %))) + (.catch #(rf/dispatch [::refused (ex-message %)]))))) + +(rf/reg-event-fx ::who (fn [_ _] {::request! {:method "GET" :url "/api/me" :then [::signed]}})) + +(rf/reg-event-fx + ::sign-in + (fn [_ [_ mode username password]] + {::request! {:method "POST" :url (str "/api/" (name mode)) + :body #js {:username username :password password} + :then [::signed]}})) + +(rf/reg-event-fx + ::sign-out + (fn [_ _] {::request! {:method "POST" :url "/api/logout" :then [::signed]}})) + +(rf/reg-event-fx + ::signed + ;; Who you are changes what you may write and what the room calls you. + (fn [{:keys [db]} [_ ^js who]] + (let [username (.-username who) + changed? (not= username (get-in db [:me :username]))] + (cond-> {:db (assoc db :me {:username username})} + (and changed? (contains? db :me)) (assoc ::reconnect! nil + :fx [[:dispatch [::catch-up]] + [:dispatch [::events.project/list]]]))))) + +(rf/reg-event-db ::refused (fn [db [_ message]] (assoc-in db [:me :error] message))) + +(rf/reg-sub ::me (fn [db _] (:me db))) + +(rf/reg-event-fx + ::add-editor + (fn [{:keys [db]} [_ username]] + {::request! {:method "POST" :url (str "/api/projects/" (get-in db [:project :id]) "/editors") + :body #js {:username username} :then [::editors]}})) + +(rf/reg-event-fx + ::remove-editor + (fn [{:keys [db]} [_ username]] + {::request! {:method "DELETE" + :url (str "/api/projects/" (get-in db [:project :id]) "/editors/" + (js/encodeURIComponent username)) + :then [::editors]}})) + +(rf/reg-event-db + ::editors + (fn [db [_ ^js answer]] + (-> (assoc-in db [:project :editors] (vec (.-editors answer))) + (update :me dissoc :error)))) + +;; --------------------------------------------------------------------------- +;; snapshots: named versions, now that every edit saves itself + +(defn- snapshots-url [db] (str "/api/projects/" (get-in db [:project :id]) "/revisions")) + +(rf/reg-event-fx + ::snapshots + (fn [{:keys [db]} _] + {::request! {:method "GET" :url (snapshots-url db) :then [::snapshots-listed]}})) + +(rf/reg-event-db + ::snapshots-listed + (fn [db [_ ^js answer]] + (assoc db :snapshots + (mapv (fn [^js r] {:id (.-id r) :name (.-summary r) :author (.-author r) + :seq (.-seq r) :created (.-created r)}) + (array-seq (.-revisions answer)))))) + +(rf/reg-sub ::snapshot-list (fn [db _] (:snapshots db))) + +(rf/reg-event-fx + ::snapshot + (fn [{:keys [db]} [_ name]] + {::request! {:method "POST" :url (snapshots-url db) :body #js {:summary name} + :then [::snapshotted name]}})) + +(rf/reg-event-fx + ::snapshotted + (fn [{:keys [db]} [_ name _]] + {:db (assoc-in db [:project :status] (str "snapshot \"" name "\" taken")) + :dispatch [::snapshots]})) + +(rf/reg-event-fx + ::restore + ;; An ordinary write on the server, which comes back to every open tab — + ;; this one included — as a delta. + (fn [{:keys [db]} [_ {:keys [id name]}]] + {::request! {:method "POST" :url (str (snapshots-url db) "/" id "/restore") + :then [::restored name]}})) + +(rf/reg-event-db + ::restored + (fn [db [_ name _]] (assoc-in db [:project :status] (str "restored \"" name "\"")))) + +;; --------------------------------------------------------------------------- + +(defn start! + "Follow the open project from now on, show what the address names — the + index, or a project — and answer the back button." + [] + (rf/reg-global-interceptor follow) + (rf/dispatch [::who]) + (route!) + (.addEventListener js/window "popstate" route!)) diff --git a/frontend/src/arthur/events/edit.cljs b/frontend/src/arthur/events/edit.cljs new file mode 100644 index 0000000..1730285 --- /dev/null +++ b/frontend/src/arthur/events/edit.cljs @@ -0,0 +1,75 @@ +(ns arthur.events.edit + "The one way an event changes the loaded document. + + Three things have to happen together and the bug is any one of them being + forgotten: the clip in `footage/store` is edited, the id app-db refers to it by + is updated — `edit-clip!` may INSTALL A COPY, because a built-in clip is a + delayed value that must stay reusable — and `:paint/revision` is bumped so the + layer-3 subs downstream of `::render/clip` recompute. The revision exists + because the clip itself is behind a handle: app-db holds an id, the id does not + change when the document does, and a sub keyed only on the id would never see + the edit. + + It started life private inside `events/paint`, which was right while polygons + were the only thing anyone could edit. They are not. + + It is also where UNDO is recorded, for the same reason: being the one way a + person changes the document, it is the one place that sees every change they + make — and nothing else. A collaborator's write and an undo itself go through + `replace-entry`, which is this without the recording." + (:require [arthur.domain.history :as history] + [arthur.domain.leaf :as leaf] + [arthur.footage.store :as store])) + +(defn leaves + "The clip as leaves, which is what a history step is made of; nil for a clip + that has no leaf form." + [clip] + (try (leaf/leaves "u" clip) (catch :default _ nil))) + +(defn- recorded [f] + (fn [entry] + (let [after (f entry) + b (when-not (identical? (:clip entry) (:clip after)) (leaves (:clip entry))) + a (when b (leaves (:clip after)))] + (cond-> after + a (assoc :history (history/record (:history entry) b a (js/Date.now))))))) + +(defn replace-entry + "Apply `f` to the loaded entry without recording it as a step of yours." + [db f] + (let [id (store/edit-entry! (:clip/current db) f)] + (if id + (-> db + (assoc :clip/current id) + (update :paint/revision (fnil inc 0)) + (update :project merge {:status "edited · unsaved"})) + db))) + +(defn edit-entry + "Apply `f` to the loaded ENTRY — the document and the blocks, footage and + source tracks beside it — and return the new db. For an edit that brings tier-2 + data in with it, which a document edit alone cannot." + [db f] + (replace-entry db (recorded f))) + +(defn transport + "App-db's copy of what the transport reads off the clip, after the clip was + replaced under it — as `::events.project/project-setting` writes it." + [db clip] + (cond-> (update db :clip merge (select-keys clip [:width :height])) + (not= (:fps clip) (get-in db [:clip :fps])) + (update :clip merge {:fps (:fps clip) :display-fps (:fps clip)}))) + +(defn history + "Apply `f` to the loaded entry's undo history, which is not an edit: nothing + is redrawn and nothing becomes unsaved." + [db f] + (if-let [id (store/edit-entry! (:clip/current db) #(update % :history f))] + (assoc db :clip/current id) + db)) + +(defn edit + "Apply `f` to the loaded clip and return the new db." + [db f] + (edit-entry db #(update % :clip f))) 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 `