Symbols, not timelines; no symbol is special

Everything that holds nodes is a symbol (domain/timeline -> domain/symbol,
:timelines -> :symbols) and a node that places one is :kind :instance. The
reserved :main root is gone: which symbol is on screen is editor state
([:ui :open]), every domain function that needs a symbol is told which, and
a document opens on the longest symbol nothing else places.

Saved projects move to schema 2 through migration 0007, which rewrites leaf
paths, instance kinds and the feature :symbol key; the client refuses a
schema it does not read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-29 12:46:42 -04:00
parent 179770d7d4
commit 5dff490162
61 changed files with 1587 additions and 1431 deletions

View file

@ -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/<cid>/timeline/... -> clip/<cid>/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),
]

View file

@ -206,7 +206,7 @@ class Project(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=200, default="untitled") 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) seq = models.PositiveBigIntegerField(default=0)
palette = models.CharField(max_length=64, default="arthur/default") palette = models.CharField(max_length=64, default="arthur/default")
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)

View file

@ -382,13 +382,13 @@ class DocumentTests(TestCase):
# cache marker, keyword keys, and a frame-keyed inner map. # cache marker, keyword keys, and a frame-keyed inner map.
return { return {
"clip/c1/timing": ["^ ", "~:fps", 30], "clip/c1/timing": ["^ ", "~:fps", 30],
"clip/c1/timeline/main": ["^ ", "~:frames", 48], "clip/c1/symbol/main": ["^ ", "~:frames", 48],
"clip/c1/timeline/main/node/mouth": ["^ ", "~:id", "~:mouth", "~:z", "a1"], "clip/c1/symbol/main/node/mouth": ["^ ", "~:id", "~:mouth", "~:z", "a1"],
"clip/c1/timeline/main/channel/mouth/geom.pts": [ "clip/c1/symbol/main/channel/mouth/geom.pts": [
"^ ", "~:animated?", True, "~:dense", "^ ", "~:animated?", True, "~:dense",
["^ ", "~:store", self.block, "~:offset", 0, "~:stride", 16], ["^ ", "~:store", self.block, "~:offset", 0, "~:stride", 16],
], ],
"clip/c1/timeline/main/channel/mouth-in/vis": [ "clip/c1/symbol/main/channel/mouth-in/vis": [
"^ ", "~:animated?", True, "~:keys", ["^ ", "~i0", True, "~i12", False], "^ ", "~:animated?", True, "~:keys", ["^ ", "~i0", True, "~i12", False],
], ],
} }
@ -407,7 +407,7 @@ class DocumentTests(TestCase):
self.assertEqual(5, len(response.json()["written"])) self.assertEqual(5, len(response.json()["written"]))
loaded = self.client.get(f"/api/projects/{self.project.id}").json() loaded = self.client.get(f"/api/projects/{self.project.id}").json()
self.assertEqual(1, loaded["schema_version"]) self.assertEqual(2, loaded["schema_version"])
self.assertEqual(1, len(loaded["clips"])) self.assertEqual(1, len(loaded["clips"]))
clip = loaded["clips"][0] clip = loaded["clips"][0]
self.assertEqual("c1", clip["cid"]) self.assertEqual("c1", clip["cid"])
@ -424,22 +424,22 @@ class DocumentTests(TestCase):
self.save() self.save()
first = {leaf.path: leaf.version for leaf in Leaf.objects.all()} first = {leaf.path: leaf.version for leaf in Leaf.objects.all()}
moved = self.leaves() moved = self.leaves()
moved["clip/c1/timeline/main/channel/mouth-in/vis"] = [ moved["clip/c1/symbol/main/channel/mouth-in/vis"] = [
"^ ", "~:animated?", True, "~:keys", ["^ ", "~i0", False], "^ ", "~:animated?", True, "~:keys", ["^ ", "~i0", False],
] ]
response = self.save(moved) response = self.save(moved)
self.assertEqual(["clip/c1/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"]) self.assertEqual(4, response.json()["unchanged"])
after = {leaf.path: leaf.version for leaf in Leaf.objects.all()} after = {leaf.path: leaf.version for leaf in Leaf.objects.all()}
self.assertEqual(2, after["clip/c1/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"]) self.assertEqual(first["clip/c1/timing"], after["clip/c1/timing"])
def test_a_removed_node_removes_its_leaf(self): def test_a_removed_node_removes_its_leaf(self):
self.save() self.save()
fewer = {k: v for k, v in self.leaves().items() 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) 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()) self.assertEqual(4, Leaf.objects.count())
def test_a_save_does_not_disturb_another_clip(self): def test_a_save_does_not_disturb_another_clip(self):
@ -472,7 +472,7 @@ class DocumentTests(TestCase):
def test_a_leaf_write_carries_an_etag(self): def test_a_leaf_write_carries_an_etag(self):
self.save() self.save()
url = f"/api/projects/{self.project.id}/leaves/clip/c1/timeline/main/node/mouth" url = f"/api/projects/{self.project.id}/leaves/clip/c1/symbol/main/node/mouth"
got = self.client.get(url) got = self.client.get(url)
self.assertEqual('"1"', got["ETag"]) self.assertEqual('"1"', got["ETag"])
@ -488,7 +488,7 @@ class DocumentTests(TestCase):
# take-theirs. A PUT that replaced unconditionally is the bug where the # take-theirs. A PUT that replaced unconditionally is the bug where the
# loser's work disappears silently. # loser's work disappears silently.
self.save() self.save()
url = f"/api/projects/{self.project.id}/leaves/clip/c1/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"') self.put(url, {"value": ["^ ", "~:z", "a2"]}, HTTP_IF_MATCH='"1"')
stale = self.put(url, {"value": ["^ ", "~:z", "a3"]}, HTTP_IF_MATCH='"1"') stale = self.put(url, {"value": ["^ ", "~:z", "a3"]}, HTTP_IF_MATCH='"1"')
self.assertEqual(409, stale.status_code) self.assertEqual(409, stale.status_code)

View file

@ -415,9 +415,11 @@ different rules:
*not* to the plate, which is the whole point of it — so the offset genuinely *not* to the plate, which is the whole point of it — so the offset genuinely
belongs at the node, not the clip. 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 ```clojure
{:frames 91 {:frames 91
@ -427,9 +429,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**: That is the whole type, and **everything that holds nodes is one of these**:
- a clip's **scene** is its root timeline, - what a document opens on is a symbol, and **no symbol is reserved** — a new
- a **symbol** in the library is a timeline, document's is called `main` only because it has to be called something,
- a node with `:kind :symbol` is an **instance** of one. - 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 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. two structures with the same fields and never said they were the same thing.
@ -474,7 +477,7 @@ for all three is the same — **their own**:
### Instances ### 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, 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 offset or retimed at each placement — that is how a three-frame blink is reused
at frames 40, 88 and 200 without copying it. at frames 40, 88 and 200 without copying it.

View file

@ -639,10 +639,10 @@ is what step 9 implemented, for the subset that exists:
clip/<cid>/name clip/<cid>/subject/<sid> clip/<cid>/name clip/<cid>/subject/<sid>
clip/<cid>/timing clip/<cid>/feature/<fid> clip/<cid>/timing clip/<cid>/feature/<fid>
clip/<cid>/stage clip/<cid>/group/<gid> clip/<cid>/stage clip/<cid>/group/<gid>
clip/<cid>/source clip/<cid>/timeline/<tid> clip/<cid>/source clip/<cid>/symbol/<sid>
clip/<cid>/timeline/<tid>/node/<nid> clip/<cid>/symbol/<sid>/node/<nid>
clip/<cid>/timeline/<tid>/measured/<nid> clip/<cid>/symbol/<sid>/measured/<nid>
clip/<cid>/timeline/<tid>/channel/<nid>/<prop> clip/<cid>/symbol/<sid>/channel/<nid>/<prop>
``` ```
Settings live on subject, feature and group leaves. Each feature has one area, so Settings live on subject, feature and group leaves. Each feature has one area, so
@ -785,12 +785,12 @@ clip/:cid/timing clip rate
clip/:cid/subject/:sid tracked subject and settings clip/:cid/subject/:sid tracked subject and settings
clip/:cid/feature/:fid tracked feature and settings clip/:cid/feature/:fid tracked feature and settings
clip/:cid/group/:gid shared settings for an eye pair clip/:cid/group/:gid shared settings for an eye pair
clip/:cid/timeline/:tid frame count, palette clip/:cid/symbol/:sid frame count, palette
clip/:cid/timeline/:tid/node/:nid one node: parent, stencil, z, time clip/:cid/symbol/:sid/node/:nid one node: parent, stencil, z, time
clip/:cid/timeline/:tid/channel/:nid/:prop clip/:cid/symbol/:sid/channel/:nid/:prop
clip/:cid/timeline/:tid/measured/:nid clip/:cid/symbol/:sid/measured/:nid
clip/:cid/timeline/:tid/cel/:nid/:frame clip/:cid/symbol/:sid/cel/:nid/:frame
clip/:cid/timeline/:tid/overrides/:nid/:prop clip/:cid/symbol/:sid/overrides/:nid/:prop
``` ```
Each feature and node has its own leaf, so tuning separate features and adding Each feature and node has its own leaf, so tuning separate features and adding

View file

@ -8,11 +8,11 @@ symbol instance. Timelines already provide local node names, independent playbac
and persistence. No new kind of scene container is needed. and persistence. No new kind of scene container is needed.
```clojure ```clojure
:timelines :symbols
{:main {:nodes {:root {:time {:mode :map :expose 2}} {:main {:nodes {:root {:time {:mode :map :expose 2}}
:face {:parent :root :channels <source-to-stage placement>} :face {:parent :root :channels <source-to-stage placement>}
:face-1 {:kind :symbol :of :face-1 :parent :face :z "a0"} :face-1 {:kind :instance :of :face-1 :parent :face :z "a0"}
:face-2 {:kind :symbol :of :face-2 :parent :face :z "a1"}}} :face-2 {:kind :instance :of :face-2 :parent :face :z "a1"}}}
:face-1 {:nodes {:head {...} :mouth {:parent :head ...} ...}} :face-1 {:nodes {:head {...} :mouth {:parent :head ...} ...}}
:face-2 {:nodes {:head {...} :mouth {:parent :head ...} ...}}} :face-2 {:nodes {:head {...} :mouth {:parent :head ...} ...}}}

View file

@ -70,7 +70,7 @@ handling and the relevant key whitelist if its storage location requires it.
- `freeze/performance-nodes` marks generated animated channels with - `freeze/performance-nodes` marks generated animated channels with
`:pose-sampled?` and local `:pose-group` names. This includes keyed visibility `:pose-sampled?` and local `:pose-group` names. This includes keyed visibility
as well as dense geometry. `:generated` remains provenance for regeneration. 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 picture sampling to marked channels. Playback and export both use
`clip/resolver` with `:picture-fps`; there is no need for a second sampling `clip/resolver` with `:picture-fps`; there is no need for a second sampling
implementation. Export's pose count is still a rate-based estimate. implementation. Export's pose count is still a rate-based estimate.

View file

@ -116,8 +116,14 @@ 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 italic, and are not projects — they are compiled into the bundle and the server
has never heard of them. has never heard of them.
Selection lives in app-db under `:ui`, as `[:node <timeline> <node>]`, Everything that holds nodes is a **symbol**, and none is special: a new document
`[:timeline <id>]` or `[:subject|:feature|:group <id>]` — four panes ask what is 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> <node>]`,
`[:symbol <id>]` or `[:subject|:feature|:group <id>]` — four panes ask what is
selected, and a ratom private to one of them can only be shared by making the selected, and a ratom private to one of them can only be shared by making the
other three require it. other three require it.
@ -125,12 +131,12 @@ other three require it.
into detection. Dragging a symbol out of the pool onto the stage places an into detection. Dragging a symbol out of the pool onto the stage places an
instance of it at the playhead. instance of it at the playhead.
The timeline's rows are the open clip's nodes, front-most first, with a dot per 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 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 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 a **symbol** row than the bar does. Opening a row shows its channels; opening an **instance** row
shows the timeline it instances, with every frame number mapped back into the shows the symbol it places, with every frame number mapped back into the open
stage's own frame space — see the namespace docstring in `ui/timeline.cljs`, which symbol's frame space — see the namespace docstring in `ui/timeline.cljs`, which
is where that mapping is argued. is where that mapping is argued.
### Paint sketch ### Paint sketch
@ -168,9 +174,9 @@ 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. `src/arthur/flow/freeze.cljs` for the landmark-to-channel conversion.
**8625 stage study**, in the open menu, loads the locally saved `IMG_8625.MOV` project and places its **8625 stage study**, in the open menu, loads the locally saved `IMG_8625.MOV` project and places its
post-processed timeline twice. The stage layout is post-processed face symbol twice. The stage layout is
`src/arthur/demo/stage_8625.edn`: the right picture and sound start at frame 48, `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 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 channels. The right sound swells and pans across the stage, then fades out at
frame 260 while its picture continues to frame 260 while its picture continues to
@ -357,12 +363,12 @@ them is `clips/templates/clips/index.html`.
## Two evaluators, on purpose ## 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: 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. 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 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. one point buffer per node, so a frame allocates the op maps and nothing else.

View file

@ -91,18 +91,15 @@
(.setValueAtTime param (* factor v) (/ f fps))))))) (.setValueAtTime param (* factor v) (/ f fps)))))))
(defn tracks-of (defn tracks-of
"The audio nodes of one of the clip's timelines. "The audio nodes of one of the clip's symbols. Any symbol may carry its own
sound, and playback mixes the open one's."
[document sid]
(filter #(= :audio (:kind %)) (vals (:nodes (clip/symbol document sid)))))
A timeline parameter rather than always the root, because a symbol is a (defn- render! [document sid sources store]
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]
(let [fps (:fps document) (let [fps (:fps document)
frames (:frames (clip/timeline document tid)) frames (:frames (clip/symbol document sid))
tracks (tracks-of document tid) tracks (tracks-of document sid)
output (js/OfflineAudioContext. output (js/OfflineAudioContext.
2 (js/Math.ceil (* (/ frames fps) 44100)) 44100)] 2 (js/Math.ceil (* (/ frames fps) 44100)) 44100)]
(doseq [track tracks] (doseq [track tracks]
@ -133,20 +130,20 @@
(.startRendering output))) (.startRendering output)))
(defn buffer! (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. when it has none.
The raw product. `mix!` packages it as a WAV URL for the transport and 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 `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." it is, which is why this is the function the others are written in terms of."
([document tid] (buffer! document tid nil)) ([document sid] (buffer! document sid nil))
([document tid store] ([document sid store]
(let [tracks (tracks-of document tid)] (let [tracks (tracks-of document sid)]
(if (empty? tracks) (if (empty? tracks)
(js/Promise.resolve nil) (js/Promise.resolve nil)
(-> (js/Promise.all (-> (js/Promise.all
(into-array (map source! (distinct (map #(get-in % [:source :footage]) tracks))))) (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! (defn decode!
"Promise of the `AudioBuffer` behind a URL. What a clip whose audio is a plain "Promise of the `AudioBuffer` behind a URL. What a clip whose audio is a plain
@ -162,9 +159,9 @@
(.decodeAudioData (js/OfflineAudioContext. 1 1 44100) bytes))))) (.decodeAudioData (js/OfflineAudioContext. 1 1 44100) bytes)))))
(defn mix! (defn mix!
"Promise of a mixed WAV URL, or the original URL for a clip without audio "Promise of a mixed WAV URL for symbol `sid`, or the original URL when it has
tracks. Each track can be trimmed and faded independently of its linked picture." no audio tracks. Each track can be trimmed and faded independently of its
([document fallback-url] (mix! document fallback-url nil)) linked picture."
([document fallback-url store] [document sid fallback-url store]
(-> (buffer! document clip/root-id store) (-> (buffer! document sid store)
(.then (fn [buffer] (if buffer (wav-url buffer) fallback-url)))))) (.then (fn [buffer] (if buffer (wav-url buffer) fallback-url)))))

View file

@ -20,10 +20,8 @@
Read OFF the clip rather than written again beside it: copying a number by hand 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. 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 There is no `:frames` here, because a length belongs to a symbol and which
split `arthur.domain.clip` exists to make — a timeline is a frame space, a clip symbol is open is the editor's state — see `events/playback/frames`."
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."
[label-key label clip store] [label-key label clip store]
(merge {:label label :clip clip :store store (merge {:label label :clip clip :store store
;; A static asset since step 9, and not the repo root's `audio.wav`. ;; 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. ;; that the clock has something to run against with no footage ingested.
:audio "/static/arthur/audio.wav" :audio "/static/arthur/audio.wav"
:cid (name label-key) :cid (name label-key)
:display-fps (:fps clip) :display-fps (:fps clip)}
:frames (domain-clip/frames clip)}
(select-keys clip [:fps :width :height]))) (select-keys clip [:fps :width :height])))
(def clips (def clips
@ -72,7 +69,7 @@
;; transform on a node, so nothing downstream of the freeze knows the frame ;; transform on a node, so nothing downstream of the freeze knows the frame
;; size — and it is why ui/player no longer hardcodes 320x200. ;; size — and it is why ui/player no longer hardcodes 320x200.
:clip (let [c (domain-clip/blank)] :clip (let [c (domain-clip/blank)]
{:fps (:fps c) :frames (domain-clip/frames c) {:fps (:fps c)
:width (:width c) :height (:height c) :width (:width c) :height (:height c)
:audio nil :display-fps (:fps c)}) :audio nil :display-fps (:fps c)})
@ -106,11 +103,12 @@
;; machinery that would share it. ;; machinery that would share it.
;; --- export --- ;; --- 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 ;; 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. ;; 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. ;; `:isolate` is the placement to render alone, or nil for the whole symbol;
:export {:timeline :main :isolate nil :zoom 4 :busy? false :done 0 :total 0 ;; `:symbol` nil means whichever symbol is open.
:export {:symbol nil :isolate nil :zoom 4 :busy? false :done 0 :total 0
:status nil} :status nil}
:playback {:frame 0 :playback {:frame 0
@ -134,20 +132,28 @@
;; names, so a pane dispatches on it rather than on which of several ;; names, so a pane dispatches on it rather than on which of several
;; "selected-x" keys happens to be non-nil: ;; "selected-x" keys happens to be non-nil:
;; ;;
;; [:node <timeline> <node>] a shape or a placement ;; [:node <symbol> <node>] a shape or an instance
;; [:timeline <id>] a timeline, root or library ;; [:symbol <id>] a symbol
;; [:subject <id>] [:feature <id>] [:group <id>] a tracked object ;; [:subject <id>] [:feature <id>] [:group <id>] a tracked object
;; ;;
;; `:draft` is the polygon being clicked out, flat [x y x y …] as geometry is ;; `: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 ;; 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. ;; 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 ;; `: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 ;; 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 ;; 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 old value — and a slider reading from the clip would spring back under
;; the user's finger on every frame of the drag. ;; the user's finger on every frame of the drag.
:ui {:selection nil :ui {:open nil
:tabs []
:selection nil
:tone :skin-base :tone :skin-base
:tool nil :tool nil
:draft [] :draft []

View file

@ -6,7 +6,7 @@
validates would not be the one that renders, and the model would be validated validates would not be the one that renders, and the model would be validated
against a scene nobody ever looked at." against a scene nobody ever looked at."
(:require [arthur.domain.clip :as domain-clip] (:require [arthur.domain.clip :as domain-clip]
[arthur.domain.timeline :as timeline] [arthur.domain.symbol :as symbol]
[cljs.reader :as reader] [cljs.reader :as reader]
[shadow.resource :as rc])) [shadow.resource :as rc]))
@ -14,15 +14,15 @@
(def clip (reader/read-string source)) (def clip (reader/read-string source))
(def timeline (def main
"The clip's root timeline: what an evaluator takes. `clip` is the document." "The scene's one symbol: what an evaluator takes. `clip` is the document."
(domain-clip/root clip)) (domain-clip/symbol clip :main))
(def fps (:fps clip)) (def fps (:fps clip))
(def frames (domain-clip/frames clip)) (def frames (domain-clip/frames clip :main))
(defn ops-at (defn ops-at
"Draw ops for one frame, via the specification path. The page uses "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] [f]
(timeline/eval-frame timeline f)) (symbol/eval-frame main f))

View file

@ -32,7 +32,7 @@
:width 320 :width 320
:height 200 :height 200
:timelines :symbols
{:main {:main
{:id :main {:id :main
:frames 229 :frames 229

View file

@ -35,7 +35,7 @@
(let [{:keys [name width height frames symbol instances audio scale]} layout (let [{:keys [name width height frames symbol instances audio scale]} layout
default-anchor (or (:anchor layout) default-anchor (or (:anchor layout)
[(/ (:width source) 2) (/ (:height source) 2)]) [(/ (: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 ;; Authored id -> uuid, so the `:linked-to` in the EDN resolves to the
;; identity the document uses. Built before either pass because the audio ;; identity the document uses. Built before either pass because the audio
;; nodes refer to the instances. ;; nodes refer to the instances.
@ -49,7 +49,7 @@
{:root {:id :root :name "stage" :kind :group :z "a1"}} {: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 in center anchor drift phase]}]
(let [anchor (or anchor default-anchor)] (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 :parent :root :z z :span span
:time {:mode :map :at at :in in :rate 1} :time {:mode :map :at at :in in :rate 1}
:channels {[:xform :pos] (if drift :channels {[:xform :pos] (if drift
@ -68,6 +68,6 @@
pan (assoc [:audio :pan] pan))}]) pan (assoc [:audio :pan] pan))}])
audio))] audio))]
(assoc source :name name :width width :height height (assoc source :name name :width width :height height
:timelines (assoc (:timelines source) :symbols (assoc (:symbols source)
:main {:id :main :frames frames :nodes nodes} :main {:id :main :frames frames :nodes nodes}
symbol (assoc original :id symbol))))) symbol (assoc original :id symbol)))))

View file

@ -153,7 +153,7 @@
:fps fps :fps fps
:width 320 :width 320
:height 200 :height 200
:timelines :symbols
{:main {:main
{:id :main {:id :main
:frames frames :frames frames

View file

@ -12,7 +12,7 @@
│ │
FREEZE ──▶ channels on nodes 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 — 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 anchor fit is knob-free. Conditioning smooths its four parameters. The rings are

View file

@ -1,47 +1,43 @@
(ns arthur.domain.clip (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\" {:name \"take\"
:fps 30 :fps 30
:width 320 :height 200 :width 320 :height 200
:analysis {...} :analysis {...}
:subjects {...} :features {...} :groups {...} :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 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 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 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 `:nodes`. The cost of leaving them together was not untidiness. It was that a
map only because there is one clip per scene today\". The cost of leaving them SYMBOL had nowhere to live: a symbol is a bag of nodes with a frame space and
together was not untidiness. It was that a SYMBOL had nowhere to live: a library nothing else, so under the old shape it would have had to be a clip with seven
timeline is a bag of nodes with a frame space and nothing else, so under the old meaningless fields.
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.
Now there is one node-holding type — `arthur.domain.timeline` — and a clip holds Now there is one node-holding type — `arthur.domain.symbol` — and a clip holds
a MAP of them. A `:kind :symbol` instance names a timeline in `:timelines`, a MAP of them. A `:kind :instance` node places one symbol inside another, and
and the clip resolver gives each placement its own reading heads. the clip resolver gives each instance its own reading heads.
THE ROOT TIMELINE HAS A RESERVED ID, `:main`, rather than the clip carrying a NO SYMBOL IS SPECIAL. There is no reserved root and no pointer to one: which
pointer to it. A pointer is a field that can be wrong — it can name a timeline symbol is on screen is the editor's state, not the document's, and every
that is not there, and then every reader needs a fallback — where a reserved name function here that needs a symbol is told which. A new document has one symbol
can only be absent, which `problems` reports once. Flash reserves `_root` the called `:main` because it has to be called something, and that is all the name
same way and for the same reason. Nothing else about `:main` is special: it is an means — it can be renamed, placed inside another symbol or deleted like any of
ordinary entry in the map, and a symbol is another one. 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 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 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] (:require [arthur.domain.feature :as feature]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.pose :as pose] [arthur.domain.pose :as pose]
[arthur.domain.timeline :as timeline])) [arthur.domain.symbol :as symbol]))
(def ^:const root-id
"The reserved id of the timeline a clip plays. See the namespace docstring."
:main)
(def clip-keys (def clip-keys
"Every top-level field of a clip, and the reason `arthur.domain.leaf` refuses "Every top-level field of a clip, and the reason `arthur.domain.leaf` refuses
@ -50,38 +46,56 @@
that loses something on every round trip, which is the one bug a persistence 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 layer must not be able to have. Add the field here and to `leaf/leaves` and
`leaf/clip` in the same commit." `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 (defn symbol
"One of the clip's timelines, by id." "One of the clip's symbols, by id."
[clip id] [clip sid]
(get-in clip [:timelines id])) (get-in clip [:symbols sid]))
(defn root
"The timeline the clip plays."
[clip]
(timeline clip root-id))
(defn frames (defn frames
"The clip's length, which is its root timeline's frame space and is not written "A symbol's length. Read off the symbol, never copied beside it."
down twice. Reading it off the root is what stops the two from disagreeing." [clip sid]
(:frames (symbol clip sid)))
(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] [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 (defn opens-on
"Apply f to one timeline in place." "The symbol a document opens on: the longest one nothing else places, ties
[clip id f & args] broken by id. The symbol that contains everything else is the longest of the
(apply update-in clip [:timelines id] f args)) unplaced ones in every document made so far, and a reserved name is what this
replaces."
(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."
[clip] [clip]
(:nodes (root clip))) (first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)])
(unplaced clip))))
(def ^:const blank-frames (def ^:const blank-frames
"How long a new document is before anything says otherwise. Four seconds at 30, "How long a new document is before anything says otherwise. Four seconds at 30,
@ -89,9 +103,9 @@
120) 120)
(defn blank (defn blank
"A new, empty document. "A new, empty document: one empty symbol.
`:nodes` is empty rather than seeded with a layer, because an empty timeline is `: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 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 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." field that is sometimes absent is a field every reader needs a fallback for."
@ -100,39 +114,39 @@
:fps 30 :fps 30
:width 320 :height 200 :width 320 :height 200
:subjects {} :features {} :groups {} :subjects {} :features {} :groups {}
:timelines {root-id {:id root-id :frames blank-frames :nodes {}}}}) :symbols {:main {:id :main :frames blank-frames :nodes {}}}})
(defn place-symbol (defn place-symbol
"An instance of library timeline `tid`, on the root timeline, at `frame`. "An instance of symbol `sid`, inside symbol `into`, at `frame` of `into`.
THE UUID IS AN ARGUMENT. A placement's identity is the key it has in the node 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 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 generating one in here would make this function's result depend on when it was
called, and this namespace is the pure one. `demo/stage_8625.edn` authors its called, and this namespace is the pure one.
placements' uuids by hand for the same reason, in more words.
The instance's own time starts where it was dropped: `:at frame` with `:in 0` The instance's own time starts where it was dropped: `:at frame` with `:in 0`
means local frame 0 of the symbol plays on `frame` of the stage, which is what means local frame 0 of the symbol plays on `frame` of `into`, which is what
dragging something onto a playhead is asking for. `:span` runs to the end of dragging something onto a playhead is asking for.
the root's frame space rather than to the symbol's length, because a symbol
shorter than the space it is placed in should hold its last frame rather than Refused, returning the clip unchanged, when it would make a cycle: a symbol
disappear." cannot be placed inside itself or inside anything it places."
[clip tid frame uuid [x y]] [clip into sid frame uuid [x y]]
(let [target (timeline clip tid) (let [target (symbol clip sid)
end (frames clip)] end (frames clip into)]
(if (or (nil? target) (= root-id tid) (nil? frame) (neg? frame) (>= frame end)) (if (or (nil? target) (nil? end) (nil? frame) (neg? frame) (>= frame end)
(contains-symbol? clip sid into))
clip clip
(update-root (update-symbol
clip assoc-in [:nodes uuid] clip into assoc-in [:nodes uuid]
{:id uuid {:id uuid
:name (name tid) :name (name sid)
:kind :symbol :kind :instance
:of tid :of sid
:parent nil :parent nil
;; Lexicographic draw order, as `domain/paint` does it: a placement made ;; Lexicographic draw order, as `domain/paint` does it: a placement made
;; later sits above one made earlier, and neither has to renumber. ;; later sits above one made earlier, and neither has to renumber.
:z (str "z" (js/Date.now) "-" (name tid)) :z (str "z" (js/Date.now) "-" (name sid))
:span [frame end] :span [frame (min end (+ frame (:frames target)))]
:time {:mode :map :at frame :in 0 :rate 1} :time {:mode :map :at frame :in 0 :rate 1}
:channels {[:xform :pos] {:animated? false :value [x y]}}})))) :channels {[:xform :pos] {:animated? false :value [x y]}}}))))
@ -158,35 +172,30 @@
op))) op)))
(defn resolver (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 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. Any symbol can be resolved and none is the default: the frame space is the
Passing a symbol's id is the whole of \"render that symbol\": a library timeline resolved symbol's own `:frames`, and nested instances inside it still resolve,
and the clip's own are the same type, so a symbol resolves by being rooted because this is the function that knows how to do that."
rather than by a second code path — which is the return on collapsing the two ([clip store palette sid] (resolver clip store palette sid nil))
into `domain/timeline`. Its frame space is its own `:frames`, and nested symbols ([clip store palette sid {:keys [picture-fps] :as opts}]
inside it still resolve, because this is the function that knows how to do that." (letfn [(build [sid chain pose-tracks]
([clip store] (resolver clip store pal/index-of root-id)) (when (some #{sid} chain)
([clip store palette] (resolver clip store palette root-id)) (throw (ex-info "symbol cycle" {:chain (conj chain sid)})))
([clip store palette root] (resolver clip store palette root nil)) (let [sym (or (symbol clip sid)
([clip store palette root {:keys [picture-fps] :as opts}] (throw (ex-info "an instance names a missing symbol" {:symbol sid})))
(letfn [(build [tid chain pose-tracks] nodes (:nodes sym)
(when (some #{tid} chain) rank (symbol/draw-rank nodes (symbol/order nodes))
(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))
ids (sort-by rank (keys 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))) (assoc opts :source-fps (:fps clip)))
children (into {} children (into {}
(for [[id n] nodes :when (= :symbol (:kind n))] (for [[id n] nodes :when (= :instance (:kind n))]
[id (build (:of n) (conj chain tid) [id (build (:of n) (conj chain sid)
(get-in n [:playback :tracks]))]))] (get-in n [:playback :tracks]))]))]
(fn [f] (fn [f]
(let [by-id (into {} (map (juxt :node identity)) (own f))] (let [by-id (into {} (map (juxt :node identity)) (own f))]
@ -194,10 +203,10 @@
(mapcat (mapcat
(fn [id] (fn [id]
(let [n (get nodes id)] (let [n (get nodes id)]
(if (= :symbol (:kind n)) (if (= :instance (:kind n))
(let [m (timeline/world-of own id) (let [m (symbol/world-of own id)
local (timeline/frame-of own id) local (symbol/frame-of own id)
target (timeline clip (:of n)) target (symbol clip (:of n))
length (:frames target) length (:frames target)
frame (when (and m (number? local)) frame (when (and m (number? local))
(if (get-in n [:time :loop?]) (if (get-in n [:time :loop?])
@ -208,7 +217,7 @@
[])) []))
(when-let [op (get by-id id)] [op])))) (when-let [op (get by-id id)] [op]))))
ids))))))] ids))))))]
(build root [] nil)))) (build sid [] nil))))
(defn problems (defn problems
"Human-readable reasons this clip will not evaluate or save." "Human-readable reasons this clip will not evaluate or save."
@ -217,28 +226,26 @@
(concat (concat
(for [k (remove clip-keys (keys clip))] (for [k (remove clip-keys (keys clip))]
(str "clip has a field with no leaf to save it in: " (pr-str k))) (str "clip has a field with no leaf to save it in: " (pr-str k)))
(when-not (map? (:timelines clip)) (when-not (map? (:symbols clip))
[":timelines must be a map of id -> timeline"]) [":symbols must be a map of id -> symbol"])
(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 (or (nil? (:fps clip)) (and (number? (:fps clip)) (pos? (:fps clip)))) (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")]) [(str ":fps is " (pr-str (:fps clip)) " — a rate is a positive number")])
(for [[id tl] (:timelines clip) (for [[id sym] (:symbols clip)
:when (not= id (:id tl))] :when (not= id (:id sym))]
(str "timeline under key " (pr-str id) " has :id " (pr-str (:id tl)))) (str "symbol under key " (pr-str id) " has :id " (pr-str (:id sym))))
(for [[id tl] (:timelines clip) (for [[id sym] (:symbols clip)
p (timeline/problems tl)] p (symbol/problems sym)]
(str "timeline " (pr-str id) ": " p)) (str "symbol " (pr-str id) ": " p))
(for [[tid tl] (:timelines clip) (for [[sid sym] (:symbols clip)
[id n] (:nodes tl) [id n] (:nodes sym)
:when (and (= :symbol (:kind n)) :when (and (= :instance (:kind n))
(not (contains? (:timelines clip) (:of n))))] (not (contains? (:symbols clip) (:of n))))]
(str "timeline " (pr-str tid) " symbol " (pr-str id) (str "symbol " (pr-str sid) " instance " (pr-str id)
" names missing timeline " (pr-str (:of n)))) " names missing symbol " (pr-str (:of n))))
(for [[tid tl] (:timelines clip) (for [[sid sym] (:symbols clip)
[id n] (:nodes tl) [id n] (:nodes sym)
:when (= :symbol (:kind n)) :when (= :instance (:kind n))
:let [target (get-in clip [:timelines (:of n)]) :let [target (get-in clip [:symbols (:of n)])
active (filter (fn [node] active (filter (fn [node]
(some :pose-sampled? (vals (:channels node)))) (some :pose-sampled? (vals (:channels node))))
(vals (:nodes target))) (vals (:nodes target)))
@ -247,11 +254,11 @@
(map #(vector :node (:id %)) active)))] (map #(vector :node (:id %)) active)))]
p (pose/problems (get-in n [:playback :tracks]) p (pose/problems (get-in n [:playback :tracks])
(:frames target) groups)] (:frames target) groups)]
(str "timeline " (pr-str tid) " symbol " (pr-str id) ": " p)) (str "symbol " (pr-str sid) " instance " (pr-str id) ": " p))
(for [[tid tl] (:timelines clip) (for [[sid sym] (:symbols clip)
[id n] (:nodes tl) [id n] (:nodes sym)
:when (and (= :audio (:kind n)) (:linked-to n) :when (and (= :audio (:kind n)) (:linked-to n)
(not (contains? (:nodes tl) (:linked-to n))))] (not (contains? (:nodes sym) (:linked-to n))))]
(str "timeline " (pr-str tid) " audio " (pr-str id) (str "symbol " (pr-str sid) " audio " (pr-str id)
" links to missing node " (pr-str (:linked-to n)))) " links to missing node " (pr-str (:linked-to n))))
(feature/problems clip)))) (feature/problems clip))))

View file

@ -1,6 +1,6 @@
(ns arthur.domain.feature (ns arthur.domain.feature
"Tracked subjects, feature ownership, and eye-pair settings. "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])) (:require [arthur.domain.params :as params]))
(defn owned (defn owned
@ -44,14 +44,14 @@
clip)) clip))
(defn problems (defn problems
"Check tracked identities and timeline-local node ownership." "Check tracked identities and symbol-local node ownership."
[clip] [clip]
(let [subjects (:subjects clip) (let [subjects (:subjects clip)
features (:features clip) features (:features clip)
groups (:groups clip) groups (:groups clip)
memberships (mapcat (comp :members val) groups) memberships (mapcat (comp :members val) groups)
node-owners (for [[_ f] features n (:nodes f)] node-owners (for [[_ f] features n (:nodes f)]
[(:timeline f) n])] [(:symbol f) n])]
(vec (vec
(concat (concat
(for [[id s] subjects :when (not= id (:id s))] (for [[id s] subjects :when (not= id (:id s))]
@ -60,8 +60,8 @@
:when (not (params/valid-settings? :subject (or (:params s) {})))] :when (not (params/valid-settings? :subject (or (:params s) {})))]
(str "subject " (pr-str id) " has invalid settings")) (str "subject " (pr-str id) " has invalid settings"))
(for [[id _] subjects (for [[id _] subjects
:when (not (seq (get-in clip [:timelines id :nodes :head :measured])))] :when (not (seq (get-in clip [:symbols id :nodes :head :measured])))]
(str "subject " (pr-str id) " has no measured head in its timeline")) (str "subject " (pr-str id) " has no measured head in its symbol"))
(for [[id f] features :when (not= id (:id f))] (for [[id f] features :when (not= id (:id f))]
(str "feature " (pr-str id) " has a different :id")) (str "feature " (pr-str id) " has a different :id"))
(for [[id f] features :when (not (contains? subjects (:subject f)))] (for [[id f] features :when (not (contains? subjects (:subject f)))]
@ -72,10 +72,10 @@
:when (not (params/valid-settings? (:area f) (or (:params f) {})))] :when (not (params/valid-settings? (:area f) (or (:params f) {})))]
(str "feature " (pr-str id) " has invalid settings for " (pr-str (:area f)))) (str "feature " (pr-str id) " has invalid settings for " (pr-str (:area f))))
(for [[id f] features (for [[id f] features
:when (not (contains? (:timelines clip) (:timeline f)))] :when (not (contains? (:symbols clip) (:symbol f)))]
(str "feature " (pr-str id) " names a missing timeline")) (str "feature " (pr-str id) " names a missing symbol"))
(for [[id f] features node-id (:nodes f) (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))] :when (not (contains? owned-nodes node-id))]
(str "feature " (pr-str id) " refers to missing node " (pr-str node-id))) (str "feature " (pr-str id) " refers to missing node " (pr-str node-id)))
(for [[id n] (frequencies node-owners) :when (> n 1)] (for [[id n] (frequencies node-owners) :when (> n 1)]

View file

@ -11,21 +11,21 @@
clip/<cid>/timing fps clip/<cid>/timing fps
clip/<cid>/stage width, height clip/<cid>/stage width, height
clip/<cid>/source the analysis record this came out of clip/<cid>/source the analysis record this came out of
clip/<cid>/subject/<sid> a tracked subject and its params clip/<cid>/subject/<subj> a tracked subject and its params
clip/<cid>/feature/<fid> one feature: area, nodes, params clip/<cid>/feature/<fid> one feature: area, nodes, params
clip/<cid>/group/<gid> an eye pair and its shared params clip/<cid>/group/<gid> an eye pair and its shared params
clip/<cid>/timeline/<tid> frames, and a palette one day clip/<cid>/symbol/<sid> frames, and a palette one day
clip/<cid>/timeline/<tid>/node/<nid> kind, parent, stencil, z, time clip/<cid>/symbol/<sid>/node/<nid> kind, parent, stencil, z, time
clip/<cid>/timeline/<tid>/channel/<nid>/<prop> clip/<cid>/symbol/<sid>/channel/<nid>/<prop>
clip/<cid>/timeline/<tid>/measured/<nid> the channels a re-freeze owns clip/<cid>/symbol/<sid>/measured/<nid> the channels a re-freeze owns
WHY NODES SIT UNDER A TIMELINE. A clip holds a library of timelines. Its root WHY NODES SIT UNDER A SYMBOL. A clip holds a library of symbols and each has
and each symbol have their own nodes, so the timeline id is a path segment. its own nodes, so the symbol id is a path segment. No symbol has a reserved
The root is `main`, and a symbol's nodes use the same path shape. 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 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, 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 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." it is one character rather than a scheme."
(:require [arthur.domain.clip :as clip] (:require [arthur.domain.clip :as clip]
[arthur.domain.sha256 :as sha] [arthur.domain.sha256 :as sha]
[arthur.domain.timeline :as timeline] [arthur.domain.symbol :as symbol]
[clojure.string :as str])) [clojure.string :as str]))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
@ -124,11 +124,11 @@
(when (seq unknown) (when (seq unknown)
(throw (ex-info "the clip has a field with no leaf to save it in; see arthur.domain.clip/clip-keys" (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))})))) {:unknown (vec (sort-by str unknown))}))))
(doseq [[id tl] (:timelines clip)] (doseq [[id sym] (:symbols clip)]
(let [unknown (remove timeline/timeline-keys (keys tl))] (let [unknown (remove symbol/symbol-keys (keys sym))]
(when (seq unknown) (when (seq unknown)
(throw (ex-info "a timeline has a field with no leaf to save it in; see arthur.domain.timeline/timeline-keys" (throw (ex-info "a symbol has a field with no leaf to save it in; see arthur.domain.symbol/symbol-keys"
{:timeline id :unknown (vec (sort-by str unknown))}))))) {:symbol id :unknown (vec (sort-by str unknown))})))))
(let [at (fn [& parts] (str/join "/" (into ["clip" (segment cid)] parts))) (let [at (fn [& parts] (str/join "/" (into ["clip" (segment cid)] parts)))
some-leaf (fn [path v] (when (seq v) {path v}))] some-leaf (fn [path v] (when (seq v) {path v}))]
(apply merge (apply merge
@ -140,30 +140,30 @@
(for [[id v] (:subjects clip)] {(at "subject" (segment id)) v}) (for [[id v] (:subjects clip)] {(at "subject" (segment id)) v})
(for [[id v] (:features clip)] {(at "feature" (segment id)) v}) (for [[id v] (:features clip)] {(at "feature" (segment id)) v})
(for [[id v] (:groups clip)] {(at "group" (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 ;; into the value as well would be the one field a rename could
;; disagree with itself about; `clip` puts it back. ;; disagree with itself about; `clip` puts it back.
(for [[tid tl] (:timelines clip)] (for [[sid sym] (:symbols clip)]
{(at "timeline" (segment tid)) {(at "symbol" (segment sid))
(select-keys tl [:frames :palette])}) (select-keys sym [:frames :palette])})
(for [[tid tl] (:timelines clip) (for [[sid sym] (:symbols clip)
[id n] (:nodes tl)] [id n] (:nodes sym)]
{(at "timeline" (segment tid) "node" (segment id)) {(at "symbol" (segment sid) "node" (segment id))
(apply dissoc n node-channel-keys)}) (apply dissoc n node-channel-keys)})
(for [[tid tl] (:timelines clip) (for [[sid sym] (:symbols clip)
[id n] (:nodes tl) [id n] (:nodes sym)
:when (seq (:measured n))] :when (seq (:measured n))]
{(at "timeline" (segment tid) "measured" (segment id)) (:measured n)}) {(at "symbol" (segment sid) "measured" (segment id)) (:measured n)})
(for [[tid tl] (:timelines clip) (for [[sid sym] (:symbols clip)
[id n] (:nodes tl) [id n] (:nodes sym)
[prop ch] (:channels n)] [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 (defn clip
"The inverse of `leaves`, for one clip. Paths belonging to another clip are "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. 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 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." claim to be the id are two places for one fact."
[cid leaves] [cid leaves]
@ -173,14 +173,14 @@
(let [[_ found kind a b c] (str/split path #"/")] (let [[_ found kind a b c] (str/split path #"/")]
(if-not (= want found) (if-not (= want found)
acc acc
(if (= "timeline" kind) (if (= "symbol" kind)
(let [tid (unsegment a) (let [sid (unsegment a)
acc (assoc-in acc [:timelines tid :id] tid)] acc (assoc-in acc [:symbols sid :id] sid)]
(case b (case b
nil (update-in acc [:timelines tid] merge v) nil (update-in acc [:symbols sid] merge v)
"node" (update-in acc [:timelines tid :nodes (unsegment c)] merge v) "node" (update-in acc [:symbols sid :nodes (unsegment c)] merge v)
"measured" (assoc-in acc [:timelines tid :nodes (unsegment c) :measured] v) "measured" (assoc-in acc [:symbols sid :nodes (unsegment c) :measured] v)
"channel" (assoc-in acc [:timelines tid :nodes (unsegment c) "channel" (assoc-in acc [:symbols sid :nodes (unsegment c)
:channels (path->prop (nth (str/split path #"/") 6))] :channels (path->prop (nth (str/split path #"/") 6))]
v) v)
(throw (ex-info "not a leaf path" {:path path})))) (throw (ex-info "not a leaf path" {:path path}))))
@ -210,20 +210,20 @@
content-addressed is that it does not have to travel with tier 1 to be found." content-addressed is that it does not have to travel with tier 1 to be found."
[leaves] [leaves]
(let [parts (into {} (map (juxt identity #(vec (str/split % #"/")))) (keys 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 ;; A node leaf, by (clip, symbol, node). Under a symbol id, because two
;; symbol and the root may both hold a `:mouth` and a channel of one is not ;; symbols may both hold a `:mouth` and a channel of one is not a channel
;; a channel of the other. ;; of the other.
nodes (into #{} (keep (fn [[_ p]] 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))) (= "node" (nth p 4)))
[(nth p 1) (nth p 3) (nth p 5)]))) [(nth p 1) (nth p 3) (nth p 5)])))
parts) parts)
;; Which segment index holds the kind, and what shapes are legal. ;; Which segment index holds the kind, and what shapes are legal.
legal? (fn [p] legal? (fn [p]
(and (= "clip" (first p)) (second p) (and (= "clip" (first p)) (second p)
(if (= "timeline" (nth p 2 nil)) (if (= "symbol" (nth p 2 nil))
(case (count p) (case (count p)
4 true ; the timeline itself 4 true ; the symbol itself
6 (#{"node" "measured"} (nth p 4)) 6 (#{"node" "measured"} (nth p 4))
7 (= "channel" (nth p 4)) 7 (= "channel" (nth p 4))
false) false)
@ -238,13 +238,13 @@
:when (not (legal? p))] :when (not (legal? p))]
(str (pr-str path) " is not a leaf path")) (str (pr-str path) " is not a leaf path"))
(for [[path p] (sort-by key parts) (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)) (#{"channel" "measured"} (nth p 4))
(not (contains? nodes [(nth p 1) (nth p 3) (nth p 5)])))] (not (contains? nodes [(nth p 1) (nth p 3) (nth p 5)])))]
(str (pr-str path) " addresses a node with no node leaf")) (str (pr-str path) " addresses a node with no node leaf"))
(for [[path p] (sort-by key parts) (for [[path p] (sort-by key parts)
:let [v (get leaves path)] :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)))))] (:dense v) (not (sha/key? (:store (:dense v)))))]
(str (pr-str path) " names tier 2 as " (pr-str (: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")))))) " — a dense channel in a saved document names a content address"))))))

View file

@ -21,9 +21,9 @@
"`:bitmap` is in the vocabulary and not implemented; it is "`: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 here so that a scene that names one fails as \"not implemented\" rather than as
\"not a kind\"." \"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 (def xform-paths
"In composition order, which is also the order they have to be sampled in. "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." change to this spec silently change what gets drawn."
(let [base (into #{[:vis]} xform-paths)] (let [base (into #{[:vis]} xform-paths)]
{:group base {:group base
:symbol base :instance base
:audio (into base [[:audio :gain] [:audio :pan] [:audio :rate]]) :audio (into base [[:audio :gain] [:audio :pan] [:audio :rate]])
:poly (into base [[:geom :pts] [:style :color]]) :poly (into base [[:geom :pts] [:style :color]])
;; A disc's radius is framed in practice — iris size is a knob, not a ;; A disc's radius is framed in practice — iris size is a knob, not a
@ -111,7 +111,7 @@
it on most frames, so the lead slider reads as doing nothing at exposures above it on most frames, so the lead slider reads as doing nothing at exposures above
1, which is indistinguishable from the slider being unwired. 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, 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 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 as two performances; offset is PER-NODE by design, because mouth lead applies
@ -122,13 +122,13 @@
(if (= mode :inherit) (if (= mode :inherit)
f f
(do (do
(when (and (not (#{:symbol :audio} (:kind n))) rate (not= rate 1.0) (not= rate 1)) (when (and (not (#{:instance :audio} (:kind n))) rate (not= rate 1.0) (not= rate 1))
(throw (ex-info "time map :rate belongs to a symbol or audio instance" (throw (ex-info "time map :rate belongs to an instance or an audio node"
{:node (:id n) :time (:time n)}))) {:node (:id n) :time (:time n)})))
(when (and sample-fps (not (and source-fps (pos? source-fps)))) (when (and sample-fps (not (and source-fps (pos? source-fps))))
(throw (ex-info "picture sampling needs a positive source fps" (throw (ex-info "picture sampling needs a positive source fps"
{:node (:id n) :time (:time n)}))) {:node (:id n) :time (:time n)})))
(cond-> (if (#{:symbol :audio} (:kind n)) (cond-> (if (#{:instance :audio} (:kind n))
(+ (or in 0) (* (or rate 1) (- f (or at 0)))) (+ (or in 0) (* (or rate 1) (- f (or at 0))))
f) f)
sample-fps (sample-frame source-fps sample-fps) sample-fps (sample-frame source-fps sample-fps)
@ -265,10 +265,10 @@
(not (contains? implemented-kinds k))) (not (contains? implemented-kinds k)))
(conj (str ":kind " k " is in the vocabulary but not implemented")) (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]))) (and (= k :audio) (nil? (get-in n [:source :footage])))
(conj "an audio instance needs :source :footage") (conj "an audio instance needs :source :footage")
(and (#{:symbol :audio} k) (some? (get-in n [:time :rate])) (and (#{:instance :audio} k) (some? (get-in n [:time :rate]))
(not (pos? (get-in n [:time :rate])))) (not (pos? (get-in n [:time :rate]))))
(conj "an instance's :rate must be positive") (conj "an instance's :rate must be positive")
(nil? (:z n)) (conj "no :z — draw order is authored per scene, not implied by the tree") (nil? (:z n)) (conj "no :z — draw order is authored per scene, not implied by the tree")

View file

@ -1,12 +1,13 @@
(ns arthur.domain.paint (ns arthur.domain.paint
"Small authored polygon operations. Paint nodes read timeline frames directly; "Small authored polygon operations, each on a named symbol. Paint nodes read
the roto root's exposure and picture sampling must not quantise a hand edit." 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])) (:require [arthur.domain.channel :as channel]))
(def geometry [:geom :pts]) (def geometry [:geom :pts])
(defn shapes [clip] (defn shapes [clip sid]
(->> (get-in clip [:timelines :main :nodes]) (->> (get-in clip [:symbols sid :nodes])
(filter (fn [[_ node]] (:paint? node))) (filter (fn [[_ node]] (:paint? node)))
(sort-by (comp :z val)) (sort-by (comp :z val))
vec)) vec))
@ -15,21 +16,21 @@
(let [frames (sort (keys (:keys ch)))] (let [frames (sort (keys (:keys ch)))]
(or (last (take-while #(<= % frame) frames)) (first frames)))) (or (last (take-while #(<= % frame) frames)) (first frames))))
(defn new-shape [clip id frame points color] (defn new-shape [clip sid id frame points color]
(let [end (get-in clip [:timelines :main :frames]) (let [end (get-in clip [:symbols sid :frames])
z (str "z" (js/Date.now) "-" (name id))] z (str "z" (js/Date.now) "-" (name id))]
(if (and (<= 0 frame) (< frame end) (>= (count points) 6) (if (and (<= 0 frame) (< frame end) (>= (count points) 6)
(even? (count points))) (even? (count points)))
(assoc-in clip [:timelines :main :nodes id] (assoc-in clip [:symbols sid :nodes id]
{:id id :name (str "shape " (inc (count (shapes clip)))) {:id id :name (str "shape " (inc (count (shapes clip sid))))
:kind :poly :paint? true :parent nil :z z :kind :poly :paint? true :parent nil :z z
:span [frame end] :span [frame end]
:channels {geometry (channel/keyed {frame points}) :channels {geometry (channel/keyed {frame points})
[:style :color] (channel/framed color)}}) [:style :color] (channel/framed color)}})
clip))) clip)))
(defn add-key [clip id frame] (defn add-key [clip sid id frame]
(let [path [:timelines :main :nodes id] (let [path [:symbols sid :nodes id]
node (get-in clip path) node (get-in clip path)
ch (get-in node [:channels geometry]) ch (get-in node [:channels geometry])
[start end] (:span node)] [start end] (:span node)]
@ -38,20 +39,20 @@
(vec (channel/value-at ch frame))) (vec (channel/value-at ch frame)))
clip))) clip)))
(defn set-vertex [clip id key-frame vertex [x y]] (defn set-vertex [clip sid id key-frame vertex [x y]]
(let [path [:timelines :main :nodes id :channels geometry :keys key-frame] (let [path [:symbols sid :nodes id :channels geometry :keys key-frame]
points (get-in clip path) points (get-in clip path)
i (* 2 vertex)] i (* 2 vertex)]
(if (and points (< (inc i) (count points))) (if (and points (< (inc i) (count points)))
(assoc-in clip path (-> points (assoc i x) (assoc (inc i) y))) (assoc-in clip path (-> points (assoc i x) (assoc (inc i) y)))
clip))) clip)))
(defn set-segment-interp [clip id key-frame interp] (defn set-segment-interp [clip sid id key-frame interp]
(let [node (get-in clip [:timelines :main :nodes id]) (let [node (get-in clip [:symbols sid :nodes id])
keys (get-in node [:channels geometry :keys])] keys (get-in node [:channels geometry :keys])]
(if (and (:paint? node) (contains? keys key-frame) (if (and (:paint? node) (contains? keys key-frame)
(some #(< key-frame %) (clojure.core/keys keys)) (some #(< key-frame %) (clojure.core/keys keys))
(#{:hold :linear} interp)) (#{: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) :segments key-frame] interp)
clip))) clip)))

View file

@ -88,7 +88,7 @@
(defn encoder (defn encoder
"(fn [raster ramp] -> promise of PNG bytes), for one stage size and one zoom. "(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 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 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 per-frame allocation of that size is the one thing that would make a long export

View file

@ -32,16 +32,17 @@
default-frame)) default-frame))
(defn put-cut (defn put-cut
"Set one held pose on a symbol instance. Earlier motion stays untouched." "Set one held pose on an instance inside symbol `sid`. Earlier motion stays
[clip instance group at source] untouched."
(let [node (get-in clip [:timelines :main :nodes instance]) [clip sid instance group at source]
symbol (get-in clip [:timelines (:of node)]) (let [node (get-in clip [:symbols sid :nodes instance])
length (:frames symbol) placed (get-in clip [:symbols (:of node)])
length (:frames placed)
active (filter (fn [n] (some :pose-sampled? (vals (:channels n)))) active (filter (fn [n] (some :pose-sampled? (vals (:channels n))))
(vals (:nodes symbol))) (vals (:nodes placed)))
groups (set (map #(or (:pose-group %) (:id %)) active)) groups (set (map #(or (:pose-group %) (:id %)) active))
ids (set (map :id active))] ids (set (map :id active))]
(when-not (and (= :symbol (:kind node)) (when-not (and (= :instance (:kind node))
(or (contains? groups group) (or (contains? groups group)
(and (vector? group) (= 2 (count group)) (and (vector? group) (= 2 (count group))
(= :node (first group)) (= :node (first group))
@ -50,17 +51,17 @@
(integer? source) (<= 0 source) (< source length)) (integer? source) (<= 0 source) (< source length))
(throw (ex-info "invalid stage pose cut" (throw (ex-info "invalid stage pose cut"
{:instance instance :group group :at at :source source}))) {: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)))) #(assoc (or % {}) at source))))
(defn remove-cut (defn remove-cut
"Remove a cut; an empty track again follows the normal generated motion." "Remove a cut; an empty track again follows the normal generated motion."
[clip instance group at] [clip sid instance group at]
(let [path [:timelines :main :nodes instance :playback :tracks group]] (let [path [:symbols sid :nodes instance :playback :tracks group]]
(if-let [entries (get-in clip path)] (if-let [entries (get-in clip path)]
(if-let [remaining (not-empty (dissoc entries at))] (if-let [remaining (not-empty (dissoc entries at))]
(assoc-in clip path remaining) (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)) dissoc group))
clip))) clip)))

View file

@ -29,6 +29,12 @@
(:require [arthur.domain.leaf :as leaf] (:require [arthur.domain.leaf :as leaf]
[arthur.domain.wire :as wire])) [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 (defn block-keys
"Every tier-2 key a leaf map names, in a stable order." "Every tier-2 key a leaf map names, in a stable order."
[leaves] [leaves]
@ -53,8 +59,8 @@
round-trip a clip through `JSON.parse(JSON.stringify(...))` and be running the round-trip a clip through `JSON.parse(JSON.stringify(...))` and be running the
same conversion the network runs, rather than a CLJS-shaped rehearsal of it. The same conversion the network runs, rather than a CLJS-shaped rehearsal of it. The
one thing a keywordising `js->clj` would quietly break is the leaf paths — one thing a keywordising `js->clj` would quietly break is the leaf paths —
`:clip/c1/timeline/main/node/mouth` is a keyword whose `name` is `:clip/c1/symbol/main/node/mouth` is a keyword whose `name` is
\"c1/timeline/main/node/mouth\", so the \"c1/symbol/main/node/mouth\", so the
\"clip/\" would be lost on the way back in. \"clip/\" would be lost on the way back in.
Refuses a document `domain/leaf` calls unaddressable, which is where a hand-made Refuses a document `domain/leaf` calls unaddressable, which is where a hand-made

View file

@ -30,7 +30,7 @@
edge landing exactly on a pixel boundary resolves consistently. edge landing exactly on a pixel boundary resolves consistently.
Flat and preallocated because this is the per-frame path: fixed topology means 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 buffer back every frame and a frame allocates nothing. At 30fps per-frame
allocation is the only thing that will make this stutter. allocation is the only thing that will make this stutter.

View file

@ -1,38 +1,36 @@
(ns arthur.domain.timeline (ns arthur.domain.symbol
"A TIMELINE: an ordered bag of nodes in its own frame space, and the two ways to "A SYMBOL: an ordered bag of nodes in its own frame space, and the two ways to
evaluate it at a frame. evaluate it at a frame.
{:id :main :frames 229 :nodes {id -> node} :palette nil} {:id :main :frames 229 :nodes {id -> node} :palette nil}
That is the whole type, and EVERYTHING THAT HOLDS NODES IS ONE OF THESE. A That is the whole type, and EVERYTHING THAT HOLDS NODES IS ONE OF THESE. What
clip's root timeline is one; a symbol in the library is one; a `:kind :symbol` a document opens on is a symbol; what a `:kind :instance` node places is a
node is an INSTANCE of one. An earlier arrangement had the clip's node tree and symbol; there is no second structure. An earlier arrangement had a root node
a library symbol as two structures with the same fields and never said they were tree and a library entry as two structures with the same fields and never said
the same thing — the clip map carried `:fps`, `:width`, `:height`, `:analysis` they were the same thing. Flash's `_root` is a MovieClip and After Effects'
and the tracking identities alongside `:nodes`, so a symbol had nowhere to live pre-comp is just a layer; collapsing them is what makes nesting arbitrary and
that was not a clip with seven meaningless fields. Flash's `_root` is a free rather than a feature to be added.
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 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 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 pointers\" sound contradictory when they are not. Parent/child is transform
composition WITHIN one timeline and is stored flat with pointers. Instance is a composition WITHIN one symbol and is stored flat with pointers. Instance is a
timeline inside another timeline and is stored by reference into the library. symbol inside another symbol and is stored by reference into the library.
Each timeline is flat; timelines nest. Every argument for flat storage — Each symbol is flat; symbols nest. Every argument for flat storage —
addressability, one-field reparenting, structural sharing, per-node sync leaves — addressability, one-field reparenting, structural sharing, per-node sync leaves —
is about the first axis and is untouched by the second. is about the first axis and is untouched by the second.
Two ways to evaluate one at a frame: 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 obviously correct. Use it in tests and for a
one-off render. 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 topological order and the z paths, holds one
CURSOR per channel and one PREALLOCATED point CURSOR per channel and one PREALLOCATED point
buffer per node, so a frame allocates the op buffer per node, so a frame allocates the op
@ -42,7 +40,7 @@
read and where points are written. That is deliberate: two independent read and where points are written. That is deliberate: two independent
implementations of frame evaluation would drift, and the drift would look like implementations of frame evaluation would drift, and the drift would look like
a rendering bug rather than like two functions disagreeing. What differs 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. 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: 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))] (when-let [p (:parent (get nodes i))]
(if (contains? nodes p) (if (contains? nodes p)
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}))))) {:node i :parent p})))))
chain (into [] (comp (take-while some?) (take (inc (count nodes)))) chain (into [] (comp (take-while some?) (take (inc (count nodes))))
(iterate up id))] (iterate up id))]
(when (> (count chain) (count nodes)) (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)) chain))
(defn depth (defn depth
@ -129,9 +127,9 @@
"id -> its position in draw order. "id -> its position in draw order.
Computed ONCE. Draw order is a function of the z paths, which are structural — 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 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 `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." parent-before-child order without a tiebreak field on every op."
@ -146,9 +144,9 @@
"Tone keyword -> the index the raster writes, in a given palette. "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 `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 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 `:palette` channel, the walk carries the palette in scope exactly as it already
carries the parent transform and the local frame. carries the parent transform and the local frame.
@ -229,7 +227,7 @@
flow/freeze writes it KEYED, because a threshold crossing is a handful of 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 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 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 Absence is not a boolean and is not an error: a subject that is not on the
frame has nothing to show." frame has nothing to show."
@ -271,13 +269,13 @@
:rd rd}))))))) :rd rd})))))))
(defn- emit (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." rasterization, so enclosing symbol transforms can still scale them."
[{:keys [palette buf-for]} n {:keys [m rd]} base] [{:keys [palette buf-for]} n {:keys [m rd]} base]
(let [colour #(colour-index palette (rd [:style :color]))] (let [colour #(colour-index palette (rd [:style :color]))]
(case (:kind n) (case (:kind n)
:group nil :group nil
:symbol nil :instance nil
:audio nil :audio nil
:poly :poly
@ -311,20 +309,20 @@
{:node (:id n) :kind (:kind n)}))))) {:node (:id n) :kind (:kind n)})))))
(defn- nodes-of (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 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 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 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 flattering number. It happened once while the split was being made, which is why
this is a guard and not a comment." this is a guard and not a comment."
[tl] [sym]
(let [nodes (:nodes tl)] (let [nodes (:nodes sym)]
(when-not (map? nodes) (when-not (map? nodes)
(throw (ex-info (str "not a timeline: :nodes is " (pr-str nodes) (throw (ex-info (str "not a symbol: :nodes is " (pr-str nodes)
" — a clip is not a timeline, its `:timelines` hold them") " — a clip is not a symbol, its `:symbols` hold them")
{:keys (vec (sort-by str (keys tl)))}))) {:keys (vec (sort-by str (keys sym)))})))
nodes)) nodes))
(defn- channel-frame (defn- channel-frame
@ -386,18 +384,18 @@
;; the specification ;; the specification
(defn eval-frame (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; `f` is in THIS symbol's frame space. For the symbol on screen that is the
inside an instance it is the instance's own space, and the instance boundary is transport's frame; inside an instance it is the instance's own space, and the instance boundary is
the only place the space changes. the only place the space changes.
This is the definition of what a frame means. `resolver` is what plays it." This is the definition of what a frame means. `resolver` is what plays it."
([tl f] (eval-frame tl f nil pal/index-of)) ([sym f] (eval-frame sym f nil pal/index-of))
([tl f store] (eval-frame tl f store pal/index-of)) ([sym f store] (eval-frame sym f store pal/index-of))
([tl f store palette] (eval-frame tl f store palette nil nil)) ([sym f store palette] (eval-frame sym f store palette nil nil))
([tl f store palette pose-tracks opts] ([sym f store palette pose-tracks opts]
(let [nodes (nodes-of tl) (let [nodes (nodes-of sym)
choices (pose/prepare pose-tracks) choices (pose/prepare pose-tracks)
anchors (prepared-anchors nodes) anchors (prepared-anchors nodes)
{:keys [source-fps picture-fps]} opts {:keys [source-fps picture-fps]} opts
@ -456,12 +454,12 @@
The op maps themselves are allocated fresh, and deliberately: there are a dozen 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 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." nothing and cost the ability to hand an op list around as plain data."
([tl] (resolver tl nil pal/index-of nil nil)) ([sym] (resolver sym nil pal/index-of nil nil))
([tl store] (resolver tl store pal/index-of nil nil)) ([sym store] (resolver sym store pal/index-of nil nil))
([tl store palette] (resolver tl store palette nil nil)) ([sym store palette] (resolver sym store palette nil nil))
([tl store palette pose-tracks] (resolver tl store palette pose-tracks nil)) ([sym store palette pose-tracks] (resolver sym store palette pose-tracks nil))
([tl store palette pose-tracks {:keys [source-fps picture-fps]}] ([sym store palette pose-tracks {:keys [source-fps picture-fps]}]
(let [nodes (nodes-of tl) (let [nodes (nodes-of sym)
choices (pose/prepare pose-tracks) choices (pose/prepare pose-tracks)
anchors (prepared-anchors nodes) anchors (prepared-anchors nodes)
ord (order nodes) ord (order nodes)
@ -507,20 +505,20 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(def timeline-keys (def symbol-keys
"Every field a timeline may carry, and the reason `arthur.domain.leaf` refuses "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 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. saves silently and comes back missing.
`:palette` is in the vocabulary and nothing writes one yet. A timeline is where `:palette` is in the vocabulary and nothing writes one yet. A symbol is where
a ramp belongs — `domain/timeline` takes the palette as a PARAMETER rather than a ramp belongs — `domain/symbol` takes the palette as a PARAMETER rather than
reaching for a global precisely so that a nested timeline can carry its own — 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 and leaving the field out would make the first one a migration instead of a
write." write."
#{:id :frames :nodes :palette}) #{:id :frames :nodes :palette})
(defn problems (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 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 the CLIP's and are checked by `arthur.domain.clip/problems`, which is not a
@ -530,8 +528,8 @@
Total by construction — it reports a cycle rather than looping on one — because 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 its whole job is to be safe to run over authored data before that data is
trusted." trusted."
[tl] [sym]
(let [nodes (:nodes tl)] (let [nodes (:nodes sym)]
(if-not (map? nodes) (if-not (map? nodes)
[":nodes must be a map of id -> node"] [":nodes must be a map of id -> node"]
(-> [] (-> []
@ -541,11 +539,11 @@
(into (for [[id n] nodes (into (for [[id n] nodes
:when (and (:parent n) (not (contains? nodes (:parent n))))] :when (and (:parent n) (not (contains? nodes (:parent n))))]
(str "node " (pr-str id) " has :parent " (pr-str (: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 (into (for [[id n] nodes
:when (and (:stencil n) (not (contains? nodes (:stencil n))))] :when (and (:stencil n) (not (contains? nodes (:stencil n))))]
(str "node " (pr-str id) " has :stencil " (pr-str (: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 (into (for [[id n] nodes
p (node/problems n)] p (node/problems n)]
(str "node " (pr-str id) ": " p))) (str "node " (pr-str id) ": " p)))
@ -554,19 +552,19 @@
:let [anchors (:anchors n)] :let [anchors (:anchors n)]
:when (some? anchors) :when (some? anchors)
:when (not (and (map? anchors) (contains? anchors 0) :when (not (and (map? anchors) (contains? anchors 0)
(integer? (:frames tl)) (integer? (:frames sym))
(every? #(and (integer? %) (<= 0 %) (every? #(and (integer? %) (<= 0 %)
(< % (:frames tl))) (< % (:frames sym)))
(concat (keys anchors) (vals anchors))) (concat (keys anchors) (vals anchors)))
(seq (:measured n)) (seq (:measured n))
(= (:channels n) (:measured n))))] (= (:channels n) (:measured n))))]
(str "node " (pr-str id) (str "node " (pr-str id)
": :anchors must start at frame 0, name valid measured frames, and read that node's own measured channels"))) ": :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))] (into (for [k (remove symbol-keys (keys sym))]
(str "timeline has a field with no leaf to save it in: " (pr-str k)))) (str "symbol 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)))) (into (when-not (or (nil? (:frames sym)) (and (integer? (:frames sym)) (pos? (:frames sym))))
[(str ":frames is " (pr-str (:frames tl)) [(str ":frames is " (pr-str (:frames sym))
" — a timeline is a frame SPACE, so its length is a positive integer")])) " — a symbol is a frame SPACE, so its length is a positive integer")]))
(into (try (into (try
(doall (map #(depth nodes %) (keys nodes))) (doall (map #(depth nodes %) (keys nodes)))
nil nil

View file

@ -2,7 +2,7 @@
"Export, as intents and one effect. "Export, as intents and one effect.
The walk is not an event and must not become one: it is a promise chain that 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 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 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. arrangement `events/project`'s save uses, and for the same reason.
@ -44,84 +44,88 @@
(defn target-value (defn target-value
"An export target as a `<select>` option value. "An export target as a `<select>` option value.
Two kinds, told apart by a leading letter: `t:<timeline>` is a whole timeline, Two kinds, told apart by a leading letter: `s:<symbol>` is a whole symbol,
`n:<timeline>:<node>` is one placement inside one. The parts are joined with `:` `n:<symbol>:<node>` is one instance inside one. The parts are joined with `:`
because neither a timeline id nor a uuid contains one. because neither a symbol id nor a uuid contains one.
IT CARRIES THE NAMESPACE. `(name :sym/face-8625)` is \"face-8625\", and a value IT CARRIES THE NAMESPACE. `(name :sym/face-8625)` is \"face-8625\", and a value
written that way cannot be read back: `keyword` on it gives `:face-8625`, which written that way cannot be read back: `keyword` on it gives `:face-8625`, which
is not a key in `:timelines`, so the plan silently becomes nil and the export is not a key in `:symbols`, so the plan silently becomes nil and the export
throws \"there is no such timeline\" from inside re-frame's `:do-fx`. That throws \"there is no such symbol\" from inside re-frame's `:do-fx`. That
presented as the tab locking up rather than as an error — see `::run!` below for presented as the tab locking up rather than as an error — see `::run!` below for
the other half of why — and it is the reason this is a named pair of functions the other half of why — and it is the reason this is a named pair of functions
with a test rather than `name` and `keyword` at the two ends of a select." with a test rather than `name` and `keyword` at the two ends of a select."
[{:keys [timeline isolate]}] [{sid :symbol isolate :isolate}]
(let [tl (subs (str (or timeline :main)) 1)] (let [s (subs (str sid) 1)]
(if isolate (str "n:" tl ":" isolate) (str "t:" tl)))) (if isolate (str "n:" s ":" isolate) (str "s:" s))))
(defn target-id (defn target-id
"The inverse of `target-value`. `keyword` splits on the `/` itself, so a "The inverse of `target-value`. `keyword` splits on the `/` itself, so a
namespaced timeline id survives; a placement comes back a uuid, which is what namespaced symbol id survives; an instance comes back a uuid, which is what
the node map is keyed by." the node map is keyed by."
[v] [v]
(let [[kind tl node] (str/split v #":")] (let [[kind s node] (str/split v #":")]
(cond-> {:timeline (keyword tl)} (cond-> {:symbol (keyword s)}
(= "n" kind) (assoc :isolate (uuid node))))) (= "n" kind) (assoc :isolate (uuid node)))))
(defn targets (defn targets
"Everything an export can be pointed at, in the order the picker lists them. "Everything an export can be pointed at, in the order the picker lists them.
THREE KINDS, and the distinction is the point. `:main` is the clip. A symbol TWO KINDS, and the distinction is the point. A symbol is the DRAWING — one file
timeline is the DRAWING — one file however many times it is placed, in its own however many times it is placed, in its own frame space. An instance is that
frame space. A placement is that drawing WHERE IT SITS: the stage's length and drawing WHERE IT SITS in the open symbol: that symbol's length and rate, with
rate, with the other placements removed, which is why seven instances of one the other instances removed, which is why seven instances of one symbol are
symbol are seven different exports rather than seven copies of one. seven different exports rather than seven copies of one.
Placements are ordered and labelled by `:name`, never by id: a uuid sorts at Instances are ordered and labelled by `:name`, never by id: a uuid sorts at
random and means nothing to read." random and means nothing to read."
[clip] [clip open]
(let [libs (cons :main (sort-by str (remove #{:main} (keys (:timelines clip))))) (let [instances (->> (get-in clip [:symbols open :nodes])
placements (->> (get-in clip [:timelines :main :nodes]) (filter (comp #{:instance} :kind val))
(filter (comp #{:symbol} :kind val))
(sort-by (fn [[id n]] [(or (:name n) "") (str id)])))] (sort-by (fn [[id n]] [(or (:name n) "") (str id)])))]
(into (mapv (fn [tid] (into (mapv (fn [sid] {:symbol sid :label (name sid)})
{:timeline tid (sort-by str (keys (:symbols clip))))
:label (if (= :main tid) "main (the clip)" (name tid))})
libs)
(mapv (fn [[id n]] (mapv (fn [[id n]]
{:timeline :main :isolate id {:symbol open :isolate id
:label (or (:name n) (str id))}) :label (or (:name n) (str id))})
placements)))) instances))))
(defn target
"What the export is pointed at. A nil symbol is whichever one is open, so a
new document exports what is on screen without anyone choosing."
[db]
(let [{sid :symbol isolate :isolate} (:export db)]
{:symbol (or sid (get-in db [:ui :open])) :isolate isolate}))
(defn- label-of (defn- label-of
"The label of the target `db` currently points at, for the filename." "The label of the target `db` currently points at, for the filename."
[clip {:keys [timeline isolate]}] [clip open {sid :symbol isolate :isolate}]
(:label (or (first (filter #(and (= timeline (:timeline %)) (:label (or (first (filter #(and (= sid (:symbol %)) (= isolate (:isolate %)))
(= isolate (:isolate %))) (targets clip open)))
(targets clip))) {:label (some-> sid name)})))
{:label (some-> timeline name)})))
(rf/reg-sub ::state (fn [db _] (:export db))) (rf/reg-sub ::state (fn [db _] (assoc (:export db) :target (target db))))
(rf/reg-sub (rf/reg-sub
::targets ::targets
(fn [db _] (fn [db _]
(targets (:clip (store/entry (:clip/current db)))))) (targets (:clip (store/entry (:clip/current db))) (get-in db [:ui :open]))))
(rf/reg-sub (rf/reg-sub
::plan ::plan
(fn [db _] (fn [db _]
(let [{:keys [clip]} (store/entry (:clip/current db)) (let [{:keys [clip]} (store/entry (:clip/current db))
{:keys [timeline zoom isolate]} (:export db)] {sid :symbol isolate :isolate} (target db)]
(export/plan {:clip clip :timeline timeline :zoom zoom :isolate isolate (export/plan {:clip clip :symbol sid :zoom (get-in db [:export :zoom])
:isolate isolate
:picture-fps (get-in db [:clip :display-fps])})))) :picture-fps (get-in db [:clip :display-fps])}))))
(rf/reg-event-db (rf/reg-event-db
::set-target ::set-target
;; Both keys always, so switching from a placement back to a whole timeline ;; Both keys always, so switching from an instance back to a whole symbol
;; clears the isolate rather than leaving it to filter the new target. ;; clears the isolate rather than leaving it to filter the new target.
(fn [db [_ {:keys [timeline isolate]}]] (fn [db [_ {sid :symbol isolate :isolate}]]
(update db :export merge {:timeline (or timeline :main) :isolate isolate}))) (update db :export merge {:symbol sid :isolate isolate})))
(rf/reg-event-db (rf/reg-event-db
::set-zoom ::set-zoom
@ -134,16 +138,17 @@
{} {}
(let [id (:clip/current db) (let [id (:clip/current db)
entry (store/entry id) entry (store/entry id)
{:keys [timeline zoom isolate]} (:export db)] {sid :symbol isolate :isolate} (target db)
zoom (get-in db [:export :zoom])]
{:db (update db :export merge {:busy? true :done 0 {:db (update db :export merge {:busy? true :done 0
:total (:frames (export/plan :total (:frames (export/plan
{:clip (:clip entry) {:clip (:clip entry)
:timeline timeline :symbol sid
:isolate isolate :isolate isolate
:zoom zoom})) :zoom zoom}))
:status "rendering…"}) :status "rendering…"})
::run! {:clip (:clip entry) ::run! {:clip (:clip entry)
:timeline timeline :symbol sid
:isolate isolate :isolate isolate
:store (:store entry) :store (:store entry)
;; The same palette and ramp the preview resolves and blits ;; The same palette and ramp the preview resolves and blits
@ -156,8 +161,8 @@
:picture-fps (get-in db [:clip :display-fps]) :picture-fps (get-in db [:clip :display-fps])
:audio-url (:audio entry) :audio-url (:audio entry)
:name (stem (:label entry) :name (stem (:label entry)
(label-of (:clip entry) (label-of (:clip entry) (get-in db [:ui :open])
{:timeline timeline :isolate isolate}))}})))) {:symbol sid :isolate isolate}))}}))))
(rf/reg-event-db (rf/reg-event-db
::progress ::progress
@ -197,7 +202,7 @@
::run! ::run!
(fn [spec] (fn [spec]
;; THE CALL IS GUARDED because `export/run!` validates its request BEFORE it ;; THE CALL IS GUARDED because `export/run!` validates its request BEFORE it
;; returns a promise, so a bad timeline id throws synchronously — here, inside ;; returns a promise, so a bad symbol id throws synchronously — here, inside
;; re-frame's `:do-fx` interceptor. An uncaught throw there never reaches the ;; re-frame's `:do-fx` interceptor. An uncaught throw there never reaches the
;; `.catch` below, so `::failed` never dispatches and `:busy?` stays true: the ;; `.catch` below, so `::failed` never dispatches and `:busy?` stays true: the
;; button sits disabled on \"rendering…\" and the readout on \"frame 0 /\" ;; button sits disabled on \"rendering…\" and the readout on \"frame 0 /\"

View file

@ -142,7 +142,6 @@
source-blocks (source/pack-subjects (:id (:analysis built)) subjects) source-blocks (source/pack-subjects (:id (:analysis built)) subjects)
_ (mark! "build-clip: pack source blocks")] _ (mark! "build-clip: pack source blocks")]
(assoc (select-keys built [:fps :width :height]) (assoc (select-keys built [:fps :width :height])
:frames (clip/frames built)
:display-fps (:fps built) :display-fps (:fps built)
:clip built :store (:store frozen) :clip built :store (:store frozen)
:source-blocks source-blocks :source-blocks source-blocks
@ -389,11 +388,7 @@
::loaded ::loaded
(fn [{:keys [db]} [_ id summary]] (fn [{:keys [db]} [_ id summary]]
(let [clip (store/entry id)] (let [clip (store/entry id)]
{:db (-> db {:db (-> (pb/show db id)
(assoc :clip/current id (assoc :footage (assoc (:footage db) :id id :label (:label clip)
:clip (select-keys clip [:fps :frames :width :height :audio :display-fps]) :loading? false :status summary)))
:footage (assoc (:footage db) :id id :label (:label clip)
:loading? false :status summary))
(assoc-in [:playback :frame] 0)
(assoc-in [:playback :playing?] false))
::pb/pause! nil}))) ::pb/pause! nil})))

View file

@ -7,20 +7,20 @@
(rf/reg-event-db (rf/reg-event-db
::new-shape ::new-shape
(fn [db [_ id points color]] (fn [db [_ sid id points color]]
(edit/edit db #(paint/new-shape % id (get-in db [:playback :frame]) points color)))) (edit/edit db #(paint/new-shape % sid id (get-in db [:playback :frame]) points color))))
(rf/reg-event-db (rf/reg-event-db
::add-key ::add-key
(fn [db [_ id]] (fn [db [_ sid id]]
(edit/edit db #(paint/add-key % id (get-in db [:playback :frame]))))) (edit/edit db #(paint/add-key % sid id (get-in db [:playback :frame])))))
(rf/reg-event-db (rf/reg-event-db
::set-vertex ::set-vertex
(fn [db [_ id key-frame vertex point]] (fn [db [_ sid id key-frame vertex point]]
(edit/edit db #(paint/set-vertex % id key-frame vertex point)))) (edit/edit db #(paint/set-vertex % sid id key-frame vertex point))))
(rf/reg-event-db (rf/reg-event-db
::set-segment-interp ::set-segment-interp
(fn [db [_ id key-frame interp]] (fn [db [_ sid id key-frame interp]]
(edit/edit db #(paint/set-segment-interp % id key-frame interp)))) (edit/edit db #(paint/set-segment-interp % sid id key-frame interp))))

View file

@ -11,11 +11,33 @@
a small app-db. If global interceptors are added later they are added to a a small app-db. If global interceptors are added later they are added to a
chain these events are excluded from, not to `reg-global-interceptor`." chain these events are excluded from, not to `reg-global-interceptor`."
(:require [arthur.clock :as clock] (:require [arthur.clock :as clock]
[arthur.domain.clip :as clip]
[arthur.footage.store :as footage] [arthur.footage.store :as footage]
[re-frame.core :as rf])) [re-frame.core :as rf]))
(defn- fps [db] (get-in db [:clip :fps])) (defn- fps [db] (get-in db [:clip :fps]))
(defn- frames [db] (get-in db [:clip :frames]))
(defn frames
"The open symbol's length. Derived from the document every time, never kept in
the db beside it, because an edit can change it."
[db]
(or (some-> (footage/entry (:clip/current db)) :clip
(clip/frames (get-in db [:ui :open])))
1))
(defn show
"Put loaded clip `id` on screen, open on the symbol it opens on, with the
playhead home. What every way of loading a document ends in, so that none of
them can forget which symbol is open."
[db id]
(let [entry (footage/entry id)
sid (clip/opens-on (:clip entry))]
(-> db
(assoc :clip/current id
:clip (select-keys entry [:fps :width :height :audio :display-fps]))
(update :ui merge {:open sid :tabs (if sid [sid] [])})
(assoc-in [:playback :frame] 0)
(assoc-in [:playback :playing?] false))))
(rf/reg-event-db (rf/reg-event-db
::tick ::tick
@ -99,21 +121,15 @@
;; Changing the clip changes the resolver, the frame count and the rate all ;; Changing the clip changes the resolver, the frame count and the rate all
;; at once, so the playhead goes home rather than being left pointing at a ;; at once, so the playhead goes home rather than being left pointing at a
;; frame the new clip may not have. ;; frame the new clip may not have.
(let [{:keys [fps frames] :as clip} (footage/entry id)] (let [{:keys [label cid]} (footage/entry id)
{:db (-> db db (-> (show db id)
(assoc :clip/current id)
;; The stage travels with the clip: two clips may be different
;; sizes, and the raster the loop paints into is the clip's, not
;; the app's.
(assoc :clip (select-keys clip [:fps :frames :width :height :audio :display-fps]))
;; The document's identity goes with it. A built-in scene has no ;; The document's identity goes with it. A built-in scene has no
;; project on the server, so this CLEARS the id rather than keeping ;; project on the server, so this CLEARS the id rather than
;; the last one — saving a fixture must create a document of its ;; keeping the last one — saving a fixture must create a
;; own, not overwrite whatever happened to be open before it. ;; document of its own, not overwrite whatever was open before.
(assoc :project {:id nil :cid (:cid clip) :name (:label clip) (assoc :project {:id nil :cid cid :name label
:seq nil :busy? false :seq nil :busy? false
:status "built-in example · not a saved project"}) :status "built-in example · not a saved project"}))]
(assoc-in [:playback :frame] 0) {:db db
(assoc-in [:playback :playing?] false))
::pause! nil ::pause! nil
::seek! [fps frames 0]}))) ::seek! [(fps db) (frames db) 0]})))

View file

@ -109,13 +109,13 @@
built (:clip loaded)] built (:clip loaded)]
(let [entry (merge (select-keys built [:fps :width :height]) (let [entry (merge (select-keys built [:fps :width :height])
{:label (str (or (.-name clip-json) cid) " (saved)") {:label (str (or (.-name clip-json) cid) " (saved)")
:cid cid :frames (clip/frames built) :cid cid
:display-fps (:fps built) :display-fps (:fps built)
:clip built :store (:store loaded) :clip built :store (:store loaded)
:footage-id footage-id :footage-id footage-id
:audio (if footage (.-audio footage) :audio (if footage (.-audio footage)
"/static/arthur/audio.wav")})] "/static/arthur/audio.wav")})]
(-> (mix/mix! built (:audio entry) (:store entry)) (-> (mix/mix! built (clip/opens-on built) (:audio entry) (:store entry))
(.then (fn [audio] (assoc entry :audio audio))))))))))) (.then (fn [audio] (assoc entry :audio audio)))))))))))
(rf/reg-fx (rf/reg-fx
@ -188,6 +188,11 @@
(.then (fn [^js row] (http/GET (str "/api/projects/" (.-id row))))) (.then (fn [^js row] (http/GET (str "/api/projects/" (.-id row)))))
(.then (fn [^js loaded] (.then (fn [^js loaded]
(let [^js clip-json (first (array-seq (.-clips loaded)))] (let [^js clip-json (first (array-seq (.-clips loaded)))]
(when (not= project/schema-version (.-schema_version loaded))
(throw (ex-info (str "that project is stored as schema "
(.-schema_version loaded) " and this client reads "
project/schema-version)
{})))
(when-not clip-json (when-not clip-json
(throw (ex-info "that project has no clips" {}))) (throw (ex-info "that project has no clips" {})))
(-> (opened-entry! clip-json) (-> (opened-entry! clip-json)
@ -213,9 +218,9 @@
(.then (fn [entry] (.then (fn [entry]
(let [built (stage/compose (:clip entry)) (let [built (stage/compose (:clip entry))
entry (assoc entry :clip built :label (:name built) entry (assoc entry :clip built :label (:name built)
:cid "stage-8625" :frames (clip/frames built) :cid "stage-8625"
:width (:width built) :height (:height built))] :width (:width built) :height (:height built))]
(-> (mix/mix! built (:audio entry) (:store entry)) (-> (mix/mix! built (clip/opens-on built) (:audio entry) (:store entry))
(.then (fn [audio] (.then (fn [audio]
(rf/dispatch (rf/dispatch
[::stage-opened [::stage-opened
@ -379,7 +384,6 @@
;; it, and two untitled documents saved from two tabs are two documents. ;; it, and two untitled documents saved from two tabs are two documents.
:cid (str (random-uuid)) :cid (str (random-uuid))
:display-fps (:fps c) :display-fps (:fps c)
:frames (clip/frames c)
:fps (:fps c) :width (:width c) :height (:height c)})) :fps (:fps c) :width (:width c) :height (:height c)}))
(rf/reg-event-fx (rf/reg-event-fx
@ -390,14 +394,10 @@
;; for, and the built-in scenes are rows in the media pool like anything else. ;; for, and the built-in scenes are rows in the media pool like anything else.
(let [entry (blank-entry) (let [entry (blank-entry)
id (store/install! entry "new")] id (store/install! entry "new")]
{:db (-> db {:db (-> (assoc db :ui (:ui db/default))
(assoc :clip/current id (pb/show id)
:clip (select-keys entry [:fps :frames :width :height :audio :display-fps]))
(assoc :project {:id nil :cid (:cid entry) :name nil :seq nil (assoc :project {:id nil :cid (:cid entry) :name nil :seq nil
:busy? false :status "new document"}) :busy? false :status "new document"}))
(assoc :ui (:ui db/default))
(assoc-in [:playback :frame] 0)
(assoc-in [:playback :playing?] false))
::pb/pause! nil}))) ::pb/pause! nil})))
(rf/reg-event-fx (rf/reg-event-fx
@ -447,15 +447,11 @@
(rf/reg-event-fx (rf/reg-event-fx
::stage-opened ::stage-opened
(fn [{:keys [db]} [_ clip-id]] (fn [{:keys [db]} [_ clip-id]]
(let [entry (store/entry clip-id)] (let [db (-> (pb/show db clip-id)
{:db (-> db
(assoc :clip/current clip-id
:clip (select-keys entry [:fps :frames :width :height :audio :display-fps]))
(assoc :project {:id nil :cid nil :name nil :seq nil (assoc :project {:id nil :cid nil :name nil :seq nil
:busy? false :status "loaded 8625 stage study"}) :busy? false :status "loaded 8625 stage study"}))]
(assoc-in [:playback :frame] 0) {:db db
(assoc-in [:playback :playing?] false)) ::pb/seek! [(get-in db [:clip :fps]) (pb/frames db) 0]})))
::pb/seek! [(:fps entry) (:frames entry) 0]})))
(rf/reg-event-db (rf/reg-event-db
::saved ::saved
@ -469,17 +465,13 @@
(rf/reg-event-fx (rf/reg-event-fx
::opened ::opened
(fn [{:keys [db]} [_ clip-id project-id name seq]] (fn [{:keys [db]} [_ clip-id project-id name seq]]
(let [clip (store/entry clip-id)] {:db (-> (pb/show db clip-id)
{:db (-> db
(assoc :clip/current clip-id
:clip (select-keys clip [:fps :frames :width :height :audio :display-fps]))
(update :project merge (update :project merge
{:id project-id :name name :seq seq :cid (:cid clip) {:id project-id :name name :seq seq
:cid (:cid (store/entry clip-id))
:busy? false :busy? false
:status (str "opened " name " r" seq)}) :status (str "opened " name " r" seq)}))
(assoc-in [:playback :frame] 0) ::pb/pause! nil}))
(assoc-in [:playback :playing?] false))
::pb/pause! nil})))
(rf/reg-event-db (rf/reg-event-db
::failed ::failed

View file

@ -58,9 +58,10 @@
;; out: a node's id is its identity in the saved document, so the pure ;; out: a node's id is its identity in the saved document, so the pure
;; layer must be handed one rather than invent one. If replaying the event ;; layer must be handed one rather than invent one. If replaying the event
;; log ever has to reproduce a document exactly, this becomes a cofx. ;; log ever has to reproduce a document exactly, this becomes a cofx.
(let [id (keyword (str "paint-" (random-uuid)))] (let [id (keyword (str "paint-" (random-uuid)))
{:db (update db :ui merge {:tool nil :draft [] :selection [:node :main id]}) sid (get-in db [:ui :open])]
:dispatch [::paint/new-shape id draft (get-in db [:ui :tone])]}))))) {:db (update db :ui merge {:tool nil :draft [] :selection [:node sid id]})
:dispatch [::paint/new-shape sid id draft (get-in db [:ui :tone])]})))))
(rf/reg-event-db (rf/reg-event-db
::set-knob ::set-knob
@ -72,8 +73,9 @@
(rf/reg-event-db (rf/reg-event-db
::place-symbol ::place-symbol
(fn [db [_ tid [x y]]] (fn [db [_ sid [x y]]]
(let [uuid (random-uuid)] (let [uuid (random-uuid)
into (get-in db [:ui :open])]
(-> db (-> db
(edit/edit #(clip/place-symbol % tid (get-in db [:playback :frame]) uuid [x y])) (edit/edit #(clip/place-symbol % into sid (get-in db [:playback :frame]) uuid [x y]))
(assoc-in [:ui :selection] [:node :main uuid]))))) (assoc-in [:ui :selection] [:node into uuid])))))

View file

@ -12,7 +12,7 @@
reachable and neither should be the other's special case. reachable and neither should be the other's special case.
What is genuinely shared is everything above the sink, and it is most of the What is genuinely shared is everything above the sink, and it is most of the
work: rooting the resolver at the chosen timeline, generated-channel picture work: rooting the resolver at the chosen symbol, generated-channel picture
sampling, sampling,
the raster, the frame loop, the audio mix, the progress reporting and the the raster, the frame loop, the audio mix, the progress reporting and the
yielding that lets the page paint. So `run!` owns all of that and calls three yielding that lets the page paint. So `run!` owns all of that and calls three
@ -20,7 +20,7 @@
TWO RULES THE WALK ENFORCES, both about sync: TWO RULES THE WALK ENFORCES, both about sync:
Every frame of the timeline's frame space is emitted, at the CLIP's rate. A Every frame of the symbol's frame space is emitted, at the CLIP's rate. A
lower picture rate holds a pose across several frames — it never drops them — lower picture rate holds a pose across several frames — it never drops them —
so the exported duration matches the audio no matter what the picture rate is. so the exported duration matches the audio no matter what the picture rate is.
Decimating instead is how an export silently runs short and the sound slides Decimating instead is how an export silently runs short and the sound slides
@ -37,14 +37,14 @@
[arthur.domain.raster :as raster])) [arthur.domain.raster :as raster]))
(defprotocol Exporter (defprotocol Exporter
"A sink for a rendered timeline. Implementations live under `arthur.export.*`. "A sink for a rendered symbol. Implementations live under `arthur.export.*`.
Called in this order, once, per export: `begin!`, then `frame!` for every frame Called in this order, once, per export: `begin!`, then `frame!` for every frame
in order from 0, then `finish!`. Any of them may return a promise and the walk in order from 0, then `finish!`. Any of them may return a promise and the walk
waits for it, which is what keeps a slow encoder from being fed faster than it waits for it, which is what keeps a slow encoder from being fed faster than it
drains and what gives the page a chance to paint between frames. drains and what gives the page a chance to paint between frames.
`Exporter` rather than `IExporter`, which is what `domain/timeline`'s `Exporter` rather than `IExporter`, which is what `domain/symbol`'s
`IResolver` would suggest, because it names a role a thing plays rather than a `IResolver` would suggest, because it names a role a thing plays rather than a
capability a value has." capability a value has."
@ -60,7 +60,7 @@
:fps frames per second of the finished file — the CLIP's rate :fps frames per second of the finished file — the CLIP's rate
:frames how many frames will arrive :frames how many frames will arrive
:ramp index -> [r g b], the palette to expand through :ramp index -> [r g b], the palette to expand through
:audio an AudioBuffer, or nil when the timeline has no sound :audio an AudioBuffer, or nil when the symbol has no sound
The ramp and the audio are here rather than on `frame!` because neither The ramp and the audio are here rather than on `frame!` because neither
changes across an export, and a muxer has to declare its tracks before it changes across an export, and a muxer has to declare its tracks before it
@ -71,7 +71,7 @@
THE RASTER IS REUSED and must be consumed before this returns (or before the THE RASTER IS REUSED and must be consumed before this returns (or before the
promise it returns settles). The walk hands back the same buffer every frame, promise it returns settles). The walk hands back the same buffer every frame,
for the same reason `timeline/resolver` reuses its point buffers: a 900-frame for the same reason `symbol/resolver` reuses its point buffers: a 900-frame
export that allocates a stage per frame is a tab that swaps. A sink that wants export that allocates a stage per frame is a tab that swaps. A sink that wants
to keep pixels has to copy or encode them here.") to keep pixels has to copy or encode them here.")
@ -92,7 +92,7 @@
not the thing that was asked for. not the thing that was asked for.
Siblings go. That is the whole point: what comes out is one placement, where it Siblings go. That is the whole point: what comes out is one placement, where it
sits, on the timeline it sits on." sits, in the symbol it sits in."
[nodes id] [nodes id]
(let [up (loop [i id acc #{}] (let [up (loop [i id acc #{}]
(if (or (nil? i) (contains? acc i)) (if (or (nil? i) (contains? acc i))
@ -114,7 +114,7 @@
k)))) k))))
(defn isolate (defn isolate
"The timeline with only `id` and its kin kept. `nil` leaves it alone. "The symbol with only `id` and its kin kept. `nil` leaves it alone.
The FRAME SPACE IS UNTOUCHED, which is what makes this different from exporting The FRAME SPACE IS UNTOUCHED, which is what makes this different from exporting
the symbol a placement plays. Rooting at `:sym/face-8625` renders the drawing in the symbol a placement plays. Rooting at `:sym/face-8625` renders the drawing in
@ -122,10 +122,10 @@
renders the STAGE — its length, its rate, the placement's span, drift and scale renders the STAGE — its length, its rate, the placement's span, drift and scale
— with the other six removed. The first is the drawing; the second is that face — with the other six removed. The first is the drawing; the second is that face
on the stage, and they are different deliverables." on the stage, and they are different deliverables."
[tl id] [sym id]
(if (and id (get-in tl [:nodes id])) (if (and id (get-in sym [:nodes id]))
(update tl :nodes select-keys (kin (:nodes tl) id)) (update sym :nodes select-keys (kin (:nodes sym) id))
tl)) sym))
(defn- yield! (defn- yield!
"Hand the event loop a turn between frames. "Hand the event loop a turn between frames.
@ -140,64 +140,64 @@
(defn audio! (defn audio!
"Promise of the AudioBuffer to export alongside the picture, or nil. "Promise of the AudioBuffer to export alongside the picture, or nil.
A timeline's own placed audio tracks win. Failing that, the ROOT timeline — and A symbol's own placed audio tracks win. Failing that, the symbol the document
only the root — falls back to the clip's audio file, which is where a take's OPENS ON — and only that one — falls back to the clip's audio file, which is
sound lives before anyone has placed a track. A symbol exports silence rather where a take's sound lives before anyone has placed a track. Any other symbol
than the whole clip's soundtrack, because a symbol's frame space is its own and exports silence rather than the whole clip's soundtrack, because its frame
the clip's audio is not a fact about it." space is its own and the clip's audio is not a fact about it."
[clip-doc tid store fallback-url] [clip-doc sid store fallback-url]
(-> (mix/buffer! clip-doc tid store) (-> (mix/buffer! clip-doc sid store)
(.then (fn [buffer] (.then (fn [buffer]
(cond (cond
buffer buffer buffer buffer
(and (= tid clip/root-id) fallback-url) (mix/decode! fallback-url) (and (= sid (clip/opens-on clip-doc)) fallback-url) (mix/decode! fallback-url)
:else nil))))) :else nil)))))
(defn plan (defn plan
"What an export of `tid` will produce, without producing any of it. "What an export of `sid` will produce, without producing any of it.
Separate from `run!` so the UI can show the size and length it is about to Separate from `run!` so the UI can show the size and length it is about to
commit to, and so the arithmetic is assertable without a sink." commit to, and so the arithmetic is assertable without a sink."
[{:keys [clip timeline zoom picture-fps] isolate-id :isolate}] [{:keys [clip zoom picture-fps] sid :symbol isolate-id :isolate}]
(let [tl (some-> (clip/timeline clip timeline) (isolate isolate-id)) (let [sym (some-> (clip/symbol clip sid) (isolate isolate-id))
zoom (max 1 (js/Math.floor (or zoom 1)))] zoom (max 1 (js/Math.floor (or zoom 1)))]
(when tl (when sym
{:frames (:frames tl) {:frames (:frames sym)
:fps (:fps clip) :fps (:fps clip)
:zoom zoom :zoom zoom
:width (* (:width clip) zoom) :width (* (:width clip) zoom)
:height (* (:height clip) zoom) :height (* (:height clip) zoom)
:seconds (/ (:frames tl) (:fps clip)) :seconds (/ (:frames sym) (:fps clip))
;; The unedited picture-grid count. A per-instance pose track can add or ;; The unedited picture-grid count. A per-instance pose track can add or
;; remove changes, so this is only the grid's nominal count. ;; remove changes, so this is only the grid's nominal count.
:poses (if (and picture-fps (< picture-fps (:fps clip))) :poses (if (and picture-fps (< picture-fps (:fps clip)))
(js/Math.ceil (* (/ (:frames tl) (:fps clip)) picture-fps)) (js/Math.ceil (* (/ (:frames sym) (:fps clip)) picture-fps))
(:frames tl))}))) (:frames sym))})))
(defn run! (defn run!
"Render `timeline` into `exporter`. Promise of `{:filename :blob}`. "Render symbol `sid` into `exporter`. Promise of `{:filename :blob}`.
`on-progress` is called with `[done total]` as frames complete, and is where a `on-progress` is called with `[done total]` as frames complete, and is where a
UI hangs its readout." UI hangs its readout."
[{:keys [clip timeline store palette ramp zoom picture-fps name audio-url] [{:keys [clip store palette ramp zoom picture-fps name audio-url]
isolate-id :isolate} sid :symbol isolate-id :isolate}
exporter on-progress] exporter on-progress]
(let [tl (some-> (clip/timeline clip timeline) (isolate isolate-id))] (let [sym (some-> (clip/symbol clip sid) (isolate isolate-id))]
(when-not tl (when-not sym
(throw (ex-info "there is no such timeline to export" (throw (ex-info "there is no such symbol to export"
{:timeline timeline {:symbol sid
:timelines (vec (sort-by str (keys (:timelines clip))))}))) :symbols (vec (sort-by str (keys (:symbols clip))))})))
(let [{:keys [frames fps zoom]} (plan {:clip clip :timeline timeline :zoom zoom (let [{:keys [frames fps zoom]} (plan {:clip clip :symbol sid :zoom zoom
:isolate isolate-id}) :isolate isolate-id})
;; Rooted at the chosen timeline, so exporting a symbol is exporting a ;; Rooted at the chosen symbol, as the stage is. Nested instances
;; clip whose root that symbol is. Nested symbols inside it still ;; inside it still resolve — clip/resolver is the function that knows
;; resolve — clip/resolver is the function that knows how. ;; how.
doc (assoc-in clip [:timelines timeline] tl) doc (assoc-in clip [:symbols sid] sym)
resolve-frame (clip/resolver doc store palette timeline resolve-frame (clip/resolver doc store palette sid
{:picture-fps picture-fps}) {:picture-fps picture-fps})
ras (raster/make (:width clip) (:height clip)) ras (raster/make (:width clip) (:height clip))
bg (get palette :bg 0)] bg (get palette :bg 0)]
(-> (audio! doc timeline store audio-url) (-> (audio! doc sid store audio-url)
(.then (fn [audio] (.then (fn [audio]
(js/Promise.resolve (js/Promise.resolve
(begin! exporter {:name name :width (:width clip) (begin! exporter {:name name :width (:width clip)

View file

@ -33,7 +33,6 @@
plate, which a human draws, is worth decimating. Sparse visibility keys capture plate, which a human draws, is worth decimating. Sparse visibility keys capture
decisions about the mouth cavity, blink and teeth without thinning geometry." decisions about the mouth cavity, blink and teeth without thinning geometry."
(:require [arthur.domain.channel :as ch] (:require [arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.feature :as feature] [arthur.domain.feature :as feature]
[arthur.domain.geom :as geom] [arthur.domain.geom :as geom]
[arthur.domain.ring :as ring] [arthur.domain.ring :as ring]
@ -262,7 +261,7 @@
not. Two faces in one shot were filmed together and are posed apart: choosing not. Two faces in one shot were filmed together and are posed apart: choosing
frame 12 for the second face must leave the first one running, and it does, frame 12 for the second face must leave the first one running, and it does,
because an anchor map lives on that subject's own head node and because an anchor map lives on that subject's own head node and
`domain/timeline` reads anchors off whatever node carries them." `domain/symbol` reads anchors off whatever node carries them."
[{:keys [subject mode anchors]} {:keys [clip]}] [{:keys [subject mode anchors]} {:keys [clip]}]
(when-not (contains? head-modes mode) (when-not (contains? head-modes mode)
(throw (ex-info "head mode must be free or anchored" (throw (ex-info "head mode must be free or anchored"
@ -274,14 +273,14 @@
{:subject subject :subjects (vec (sort-by str (keys (:subjects clip))))}))) {:subject subject :subjects (vec (sort-by str (keys (:subjects clip))))})))
(reduce (reduce
(fn [c sid] (fn [c sid]
(let [frames (get-in c [:timelines sid :frames])] (let [frames (get-in c [:symbols sid :frames])]
(when (and (= mode :anchored) (when (and (= mode :anchored)
(not (and (map? anchors) (contains? anchors 0) (not (and (map? anchors) (contains? anchors 0)
(every? #(and (integer? %) (<= 0 %) (< % frames)) (every? #(and (integer? %) (<= 0 %) (< % frames))
(concat (keys anchors) (vals anchors)))))) (concat (keys anchors) (vals anchors))))))
(throw (ex-info "anchored head needs a frame-zero key and valid source frames" (throw (ex-info "anchored head needs a frame-zero key and valid source frames"
{:subject sid :anchors anchors :frames frames}))) {:subject sid :anchors anchors :frames frames})))
(update-in c [:timelines sid :nodes :head] (update-in c [:symbols sid :nodes :head]
(fn [n] (fn [n]
(cond-> (assoc n :channels (:measured n)) (cond-> (assoc n :channels (:measured n))
(= mode :anchored) (assoc :anchors anchors) (= mode :anchored) (assoc :anchors anchors)
@ -654,7 +653,7 @@
;; the clip ;; the clip
(defn- subject-part (defn- subject-part
"A subject's drawing, metadata and blocks. Node names are timeline-local." "A subject's drawing, metadata and blocks. Node names are symbol-local."
[params subject {:keys [outer eyes brows teeth] :as inputs}] [params subject {:keys [outer eyes brows teeth] :as inputs}]
(let [own (partial feature/owned subject) (let [own (partial feature/owned subject)
areas (cond-> [:mouth] (and eyes brows) (into [:eye :brow]) teeth (conj :teeth)) areas (cond-> [:mouth] (and eyes brows) (into [:eye :brow]) teeth (conj :teeth))
@ -666,13 +665,13 @@
:eye-l [:eye [:eye-l :eye-l-in :iris-l :pupil-l]] :eye-l [:eye [:eye-l :eye-l-in :iris-l :pupil-l]]
:brow-r [:brow [:brow-r]] :brow-l [:brow [:brow-l]]}) :brow-r [:brow [:brow-r]] :brow-l [:brow [:brow-l]]})
teeth (assoc :teeth [:teeth [:teeth]]))] teeth (assoc :teeth [:teeth [:teeth]]))]
{:timeline {:id subject :frames (count outer) {:symbol {:id subject :frames (count outer)
:nodes (into {:head {:id :head :name "head" :kind :group :z "a1" :nodes (into {:head {:id :head :name "head" :kind :group :z "a1"
:measured (:measured head)}} :measured (:measured head)}}
(mapcat :nodes) parts)} (mapcat :nodes) parts)}
:features (into {} (map (fn [[role [area nodes]]] :features (into {} (map (fn [[role [area nodes]]]
[(own role) {:id (own role) :subject subject [(own role) {:id (own role) :subject subject
:timeline subject :area area :symbol subject :area area
:nodes nodes :params {}}])) features) :nodes nodes :params {}}])) features)
:groups (if (and eyes brows) :groups (if (and eyes brows)
{(own :eyes) {:id (own :eyes) :kind :eye-pair :subject subject {(own :eyes) {:id (own :eyes) :kind :eye-pair :subject subject
@ -681,12 +680,13 @@
:store (into (:store head) (mapcat :store) parts)})) :store (into (:store head) (mapcat :store) parts)}))
(defn clip (defn clip
"Subject-id -> conditioned measurements becomes a library of face timelines. "Subject-id -> conditioned measurements becomes one symbol per face, and a
symbol called :main that places them.
:main holds exposure and a shared source-to-stage placement. Each subject is :main holds exposure and a shared source-to-stage placement. Each subject is
placed by an ordinary symbol instance, so pose choices and transforms have placed by an ordinary instance, so pose choices and transforms have their
their existing instance scope. Features name local nodes in that subject's existing instance scope. Features name local nodes in that subject's symbol;
timeline; block descriptors still name globally distinct features. block descriptors still name globally distinct features.
Subjects share a source frame space. :head and :anchors may be overridden Subjects share a source frame space. :head and :anchors may be overridden
per subject; all other freeze settings come from params." per subject; all other freeze settings come from params."
@ -697,7 +697,7 @@
(throw (ex-info "a freeze needs subjects with ids distinct from :main, :root and :face" {}))) (throw (ex-info "a freeze needs subjects with ids distinct from :main, :root and :face" {})))
(let [ordered (sort-by (comp str key) subjects) (let [ordered (sort-by (comp str key) subjects)
parts (mapv (fn [[id inputs]] [id (subject-part params id inputs)]) ordered) parts (mapv (fn [[id inputs]] [id (subject-part params id inputs)]) ordered)
lengths (distinct (map #(get-in % [1 :timeline :frames]) parts)) lengths (distinct (map #(get-in % [1 :symbol :frames]) parts))
_ (when-not (and (= 1 (count lengths)) (pos? (first lengths))) _ (when-not (and (= 1 (count lengths)) (pos? (first lengths)))
(throw (ex-info "subjects need the same positive frame count" (throw (ex-info "subjects need the same positive frame count"
{:frames (vec lengths)}))) {:frames (vec lengths)})))
@ -707,9 +707,9 @@
:width (first stage) :height (second stage) :width (first stage) :height (second stage)
:subjects (into {} (map (fn [[id _]] [id {:id id :params {}}])) ordered) :subjects (into {} (map (fn [[id _]] [id {:id id :params {}}])) ordered)
:features (merged :features) :groups (merged :groups) :features (merged :features) :groups (merged :groups)
:timelines :symbols
(into {clip/root-id (into {:main
{:id clip/root-id :frames nf {:id :main :frames nf
:nodes (into {:root {:id :root :name "clip" :kind :group :z "a1" :nodes (into {:root {:id :root :name "clip" :kind :group :z "a1"
:time {:mode :map :expose expose}} :time {:mode :map :expose expose}}
:face {:id :face :name "source placement" :kind :group :face {:id :face :name "source placement" :kind :group
@ -717,10 +717,10 @@
:channels (face-placement params subjects)}} :channels (face-placement params subjects)}}
(map-indexed (map-indexed
(fn [i [id _]] (fn [i [id _]]
[id {:id id :kind :symbol :of id :parent :face [id {:id id :kind :instance :of id :parent :face
:z (str "a" i)}])) :z (str "a" i)}]))
ordered)}} ordered)}}
(map (fn [[id part]] [id (:timeline part)])) parts)}] (map (fn [[id part]] [id (:symbol part)])) parts)}]
(doseq [[subject inputs] ordered (doseq [[subject inputs] ordered
[id track] (:presence inputs)] [id track] (:presence inputs)]
(when-not (and (= nf (count track)) (when-not (and (= nf (count track))

View file

@ -39,7 +39,7 @@
:when (:generated channel)] :when (:generated channel)]
[id prop channel])] [id prop channel])]
(-> (reduce (fn [entry [id prop channel]] (-> (reduce (fn [entry [id prop channel]]
(let [at [:clip :timelines (get-in entry [:clip :features fid :timeline]) (let [at [:clip :symbols (get-in entry [:clip :features fid :symbol])
:nodes id :channels prop] :nodes id :channels prop]
old (get-in entry at)] old (get-in entry at)]
(assoc-in entry at (assoc-in entry at
@ -103,7 +103,7 @@
by hand." by hand."
[entry params base subject] [entry params base subject]
(let [baked (freeze/head-part subject params @base) (let [baked (freeze/head-part subject params @base)
at [:clip :timelines subject :nodes :head] at [:clip :symbols subject :nodes :head]
old (get-in entry at) old (get-in entry at)
measured (:measured baked)] measured (:measured baked)]
(cond-> (-> entry (cond-> (-> entry

View file

@ -4,8 +4,8 @@
Everything here returns a promise of a PARSED JS VALUE, not of CLJS data, and Everything here returns a promise of a PARSED JS VALUE, not of CLJS data, and
that is deliberate: a leaf is transit, and `domain/project` reads it straight out that is deliberate: a leaf is transit, and `domain/project` reads it straight out
of the response object. A keywordising `js->clj` on the way past would turn the of the response object. A keywordising `js->clj` on the way past would turn the
leaf path \"clip/c1/timeline/main/node/mouth\" into a keyword whose name is leaf path \"clip/c1/symbol/main/node/mouth\" into a keyword whose name is
\"c1/timeline/main/node/mouth\", \"c1/symbol/main/node/mouth\",
losing the prefix — a corruption that only shows up on the way back in. losing the prefix — a corruption that only shows up on the way back in.
CSRF IS NOT EXEMPTED. The page renders `{% csrf_token %}`, so Django sets its CSRF IS NOT EXEMPTED. The page renders `{% csrf_token %}`, so Django sets its

View file

@ -11,7 +11,6 @@
(rf/reg-sub ::muted? (fn [db _] (get-in db [:playback :muted?]))) (rf/reg-sub ::muted? (fn [db _] (get-in db [:playback :muted?])))
(rf/reg-sub ::fps (fn [db _] (get-in db [:clip :fps]))) (rf/reg-sub ::fps (fn [db _] (get-in db [:clip :fps])))
(rf/reg-sub ::display-fps (fn [db _] (get-in db [:clip :display-fps]))) (rf/reg-sub ::display-fps (fn [db _] (get-in db [:clip :display-fps])))
(rf/reg-sub ::frames (fn [db _] (get-in db [:clip :frames])))
;; The stage, in pixels. On the clip because project dimensions are independent ;; The stage, in pixels. On the clip because project dimensions are independent
;; of the footage — see flow/freeze/face-placement — so the canvas and the raster ;; of the footage — see flow/freeze/face-placement — so the canvas and the raster
;; take their size from the document rather than from a constant. ;; take their size from the document rather than from a constant.

View file

@ -9,7 +9,6 @@
playhead is not an input, so moving it cannot invalidate this." playhead is not an input, so moving it cannot invalidate this."
(:require [arthur.domain.clip :as clip] (:require [arthur.domain.clip :as clip]
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.timeline :as timeline]
[arthur.footage.store :as footage] [arthur.footage.store :as footage]
[arthur.subs.playback :as playback] [arthur.subs.playback :as playback]
[re-frame.core :as rf])) [re-frame.core :as rf]))
@ -23,27 +22,38 @@
:<- [::paint-revision] :<- [::paint-revision]
(fn [[id _] _] (:clip (footage/entry id)))) (fn [[id _] _] (:clip (footage/entry id))))
(rf/reg-sub ::open (fn [db _] (get-in db [:ui :open])))
(rf/reg-sub (rf/reg-sub
::timeline ::symbol
:<- [::clip] :<- [::clip]
(fn [clip _] (some-> clip clip/root))) :<- [::open]
(fn [[clip sid] _] (some-> clip (clip/symbol sid))))
(rf/reg-sub
::frames
:<- [::symbol]
(fn [sym _]
;; The open symbol's length, read off it rather than copied into the db, so a
;; drop that lengthens it lengthens the transport with it.
(:frames sym)))
(rf/reg-sub (rf/reg-sub
::exposure ::exposure
:<- [::timeline] :<- [::symbol]
(fn [tl _] (fn [sym _]
;; Exposure lives on the timeline's root node and is INHERITED, so reading it ;; Exposure lives on the symbol's root node and is INHERITED, so reading it
;; there is reading it everywhere. The transport shows it so that `exposure 2` ;; there is reading it everywhere. The transport shows it so that `exposure 2`
;; is visibly doing something at the transport rather than only inside the ;; is visibly doing something at the transport rather than only inside the
;; document. ;; document.
(or (get-in tl [:nodes :root :time :expose]) 1))) (or (get-in sym [:nodes :root :time :expose]) 1)))
(rf/reg-sub (rf/reg-sub
::palette ::palette
(fn [db _] (fn [db _]
;; A NAME resolves to a ramp. One today; when timelines carry a `:palette` ;; A NAME resolves to a ramp. One today; when timelines carry a `:palette`
;; channel this becomes the project's table and the walk carries the ramp in ;; channel this becomes the project's table and the walk carries the ramp in
;; scope, which is why domain/timeline takes the palette as a parameter rather ;; scope, which is why domain/symbol takes the palette as a parameter rather
;; than reaching for a global. ;; than reaching for a global.
(get {:arthur/default pal/index-of} (:palette db) pal/index-of))) (get {:arthur/default pal/index-of} (:palette db) pal/index-of)))
@ -68,11 +78,10 @@
(rf/reg-sub (rf/reg-sub
::resolver ::resolver
:<- [::clip] :<- [::clip]
:<- [::timeline] :<- [::open]
:<- [::store] :<- [::store]
:<- [::palette] :<- [::palette]
:<- [::playback/display-fps] :<- [::playback/display-fps]
(fn [[document tl store palette picture-fps] _] (fn [[document sid store palette picture-fps] _]
(when tl (clip/resolver (assoc-in document [:timelines :main] tl) (when (and document (clip/symbol document sid))
store palette clip/root-id (clip/resolver document store palette sid {:picture-fps picture-fps}))))
{:picture-fps picture-fps}))))

View file

@ -23,6 +23,6 @@
;; the node alone because every caller that wants the node also wants to know ;; the node alone because every caller that wants the node also wants to know
;; where it lives — an edit names the timeline, and a bare node has forgotten. ;; where it lives — an edit names the timeline, and a bare node has forgotten.
(when (and clip (= :node (first selection))) (when (and clip (= :node (first selection)))
(let [[_ tid id] selection] (let [[_ sid id] selection]
(when-let [n (get-in clip [:timelines tid :nodes id])] (when-let [n (get-in clip [:symbols sid :nodes id])]
[tid id n]))))) [sid id n])))))

View file

@ -2,7 +2,7 @@
"The right pane: what the selection is, and what can be changed about it. "The right pane: what the selection is, and what can be changed about it.
Sections rather than a mode switch. The clip's facts are always true, so the Sections rather than a mode switch. The clip's facts are always true, so the
clip section is always there; the node and timeline sections appear when clip section is always there; the node and symbol sections appear when
something of that kind is selected; the tracking section appears when the clip something of that kind is selected; the tracking section appears when the clip
has analysis in it. Nothing here computes — every control dispatches an intent has analysis in it. Nothing here computes — every control dispatches an intent
and every readout comes off a subscription." and every readout comes off a subscription."
@ -45,12 +45,14 @@
(let [clip @(rf/subscribe [::render/clip]) (let [clip @(rf/subscribe [::render/clip])
fps @(rf/subscribe [::playback/fps]) fps @(rf/subscribe [::playback/fps])
picture @(rf/subscribe [::playback/display-fps]) picture @(rf/subscribe [::playback/display-fps])
frames @(rf/subscribe [::playback/frames]) open @(rf/subscribe [::render/open])
frames @(rf/subscribe [::render/frames])
expose @(rf/subscribe [::render/exposure])] expose @(rf/subscribe [::render/exposure])]
[section "clip" [section "clip"
[facts [facts
"name" (:name clip) "name" (:name clip)
"stage" (str (:width clip) "×" (:height clip)) "stage" (str (:width clip) "×" (:height clip))
"open" (some-> open name)
"length" (str frames " frames") "length" (str frames " frames")
"source" (str fps " fps") "source" (str fps " fps")
"expose" (str "on " expose "s")] "expose" (str "on " expose "s")]
@ -84,7 +86,7 @@
gap after the current one does. Lifted out of the old stage toolbar unchanged — gap after the current one does. Lifted out of the old stage toolbar unchanged —
a drawing key is a parameter of the shape, and this is where the shape's a drawing key is a parameter of the shape, and this is where the shape's
parameters are." parameters are."
[id n frame] [sid id n frame]
(let [geom (get-in n [:channels paint/geometry]) (let [geom (get-in n [:channels paint/geometry])
active (when geom (paint/active-frame geom frame)) active (when geom (paint/active-frame geom frame))
ks (when geom (sort (keys (:keys geom)))) ks (when geom (sort (keys (:keys geom))))
@ -94,7 +96,7 @@
[:div.row {:style {:margin "5px 0"}} [:div.row {:style {:margin "5px 0"}}
[:button {:disabled (or (< frame start) (>= frame end) [:button {:disabled (or (< frame start) (>= frame end)
(contains? (:keys geom) frame)) (contains? (:keys geom) frame))
:on-click #(rf/dispatch [::paint-events/add-key id])} :on-click #(rf/dispatch [::paint-events/add-key sid id])}
"drawing key here"]] "drawing key here"]]
(when (seq ks) (when (seq ks)
[:div.row [:div.row
@ -110,22 +112,22 @@
[:label.dim (str "key " active " → " next-k " ") [:label.dim (str "key " active " → " next-k " ")
[:select {:value (name (or (channel/segment-interp geom active) :hold)) [:select {:value (name (or (channel/segment-interp geom active) :hold))
:on-change #(rf/dispatch [::paint-events/set-segment-interp :on-change #(rf/dispatch [::paint-events/set-segment-interp
id active (keyword (.. % -target -value))])} sid id active (keyword (.. % -target -value))])}
[:option {:value "hold"} "hold"] [:option {:value "hold"} "hold"]
[:option {:value "linear"} "tween"]]]])])) [:option {:value "linear"} "tween"]]]])]))
(defn- node-section [[tid id n]] (defn- node-section [[sid id n]]
(let [frame @(rf/subscribe [::playback/frame]) (let [frame @(rf/subscribe [::playback/frame])
[start end] (:span n)] [start end] (:span n)]
[section (str (name (:kind n)) (when (not= :main tid) (str " · " (name tid)))) [section (str (name (:kind n)) " · in " (name sid))
[facts [facts
"name" (or (:name n) (brief id)) "name" (or (:name n) (brief id))
"id" (brief id) "id" (brief id)
;; Which library timeline a placement plays. The one fact that makes a ;; Which symbol an instance places. The one fact that makes an instance
;; symbol instance legible as an instance rather than as a node. ;; legible as an instance rather than as a node.
"of" (when (= :symbol (:kind n)) (str (:of n))) "of" (when (= :instance (:kind n)) (str (:of n)))
"span" (when start (str start " … " end))] "span" (when start (str start " … " end))]
(when (:paint? n) [drawing-keys id n frame]) (when (:paint? n) [drawing-keys sid id n frame])
[:div.row {:style {:margin-top "6px"}} [:span.dim "channels"]] [:div.row {:style {:margin-top "6px"}} [:span.dim "channels"]]
[:dl.facts [:dl.facts
(doall (doall
@ -136,16 +138,15 @@
[:dd (channel-state ch)]]))]])) [:dd (channel-state ch)]]))]]))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; a timeline ;; a symbol
(defn- timeline-section [tid] (defn- symbol-section [sid]
(let [tl @(rf/subscribe [::render/clip]) (let [sym (get-in @(rf/subscribe [::render/clip]) [:symbols sid])]
tl (get-in tl [:timelines tid])] [section "symbol"
[section "timeline"
[facts [facts
"id" (str tid) "id" (str sid)
"length" (str (:frames tl) " frames") "length" (str (:frames sym) " frames")
"nodes" (str (count (:nodes tl)))]])) "nodes" (str (count (:nodes sym)))]]))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; tracked objects ;; tracked objects
@ -245,5 +246,5 @@
[:div {:style {:min-height 0}} [:div {:style {:min-height 0}}
[clip-section] [clip-section]
(when node [node-section node]) (when node [node-section node])
(when (= :timeline (first selection)) [timeline-section (second selection)]) (when (= :symbol (first selection)) [symbol-section (second selection)])
(when tracked? [tracking-section])]])) (when tracked? [tracking-section])]]))

View file

@ -106,7 +106,7 @@
:palette @(rf/subscribe [::render/palette]) :palette @(rf/subscribe [::render/palette])
:ramp @(rf/subscribe [::render/ramp]) :ramp @(rf/subscribe [::render/ramp])
:fps @(rf/subscribe [::sub/fps]) :fps @(rf/subscribe [::sub/fps])
:frames @(rf/subscribe [::sub/frames]) :frames @(rf/subscribe [::render/frames])
:width @(rf/subscribe [::sub/width]) :width @(rf/subscribe [::sub/width])
:height @(rf/subscribe [::sub/height]) :height @(rf/subscribe [::sub/height])
:frame @(rf/subscribe [::sub/frame]) :frame @(rf/subscribe [::sub/frame])

View file

@ -8,14 +8,14 @@
the confusion this pane should be ending. the confusion this pane should be ending.
A SYMBOL is one timeline out of the open document's library — the thing a A SYMBOL is one timeline out of the open document's library — the thing a
`:kind :symbol` node is an instance of. Dragging one onto the stage places an `:kind :instance` node is an instance of. Dragging one onto the stage places an
instance. Open a different project and this is a different list, because a instance. Open a different project and this is a different list, because a
library belongs to the document that holds it. library belongs to the document that holds it.
FOOTAGE is source media on the server: uploads that have been probed, FOOTAGE is source media on the server: uploads that have been probed,
transcoded and cut into frames. Running one through detection produces a transcoded and cut into frames. Running one through detection produces a
document whose roto IS a symbol — `flow/freeze/clip` gives each tracked subject document whose roto IS a symbol — `flow/freeze/clip` gives each tracked subject
its own library timeline and places it on `:main` with one instance — so its own symbol and places one instance of each in `:main` — so
footage becomes a row in the group above it by way of being detected. footage becomes a row in the group above it by way of being detected.
THE WHOLE PANE IS THE DROP TARGET. A video dropped anywhere in it uploads and THE WHOLE PANE IS THE DROP TARGET. A video dropped anywhere in it uploads and
@ -53,25 +53,25 @@
(defn- symbols [] (defn- symbols []
(let [clip @(rf/subscribe [::render/clip]) (let [clip @(rf/subscribe [::render/clip])
selection @(rf/subscribe [::sub/selection]) selection @(rf/subscribe [::sub/selection])
library (sort-by str (remove #{:main} (keys (:timelines clip))))] library (sort-by str (keys (:symbols clip)))]
(group "symbols" (group "symbols"
[:div.dim "timelines in this document"] [:div.dim "in this document"]
(if (empty? library) (if (empty? library)
[:div.dim "none yet"] [:div.dim "none yet"]
(doall (doall
(for [tid library (for [sid library
:let [tl (get-in clip [:timelines tid])]] :let [sym (get-in clip [:symbols sid])]]
^{:key tid} ^{:key sid}
[item {:label (name tid) [item {:label (name sid)
:sub (str (:frames tl) "f · " (count (:nodes tl)) " nodes") :sub (str (:frames sym) "f · " (count (:nodes sym)) " nodes")
:on? (= selection [:timeline tid]) :on? (= selection [:symbol sid])
:draggable true :draggable true
:on-drag-start :on-drag-start
(fn [^js event] (fn [^js event]
(.setData (.-dataTransfer event) "text/plain" (.setData (.-dataTransfer event) "text/plain"
(str "symbol:" (subs (str tid) 1))) (str "symbol:" (subs (str sid) 1)))
(set! (.. event -dataTransfer -effectAllowed) "copy")) (set! (.. event -dataTransfer -effectAllowed) "copy"))
:on-click #(rf/dispatch [::ui/select [:timeline tid]])}]))) :on-click #(rf/dispatch [::ui/select [:symbol sid]])}])))
(when (seq library) (when (seq library)
[:div.dim "drag onto the stage to place"])))) [:div.dim "drag onto the stage to place"]))))

View file

@ -47,15 +47,17 @@
(defonce ^:private dragging (atom nil)) (defonce ^:private dragging (atom nil))
(defn- editing (defn- editing
"The selected node when it is a polygon on the root timeline, as "The selected node when it is a polygon in the open symbol, as
`[id node geom active-key editable?]`. Nothing else is vertex-editable yet." `[sid id node geom active-key editable?]`. Nothing else is vertex-editable yet:
[clip frame] a shape inside an instance is drawn through that instance's transform, and
(let [[tid id n] @(rf/subscribe [::sub/selected-node])] handles in the open symbol's space would be in the wrong place."
(when (and (= :main tid) (:paint? n)) [frame]
(let [[sid id n] @(rf/subscribe [::sub/selected-node])]
(when (and (= @(rf/subscribe [::render/open]) sid) (:paint? n))
(let [geom (get-in n [:channels paint/geometry]) (let [geom (get-in n [:channels paint/geometry])
active (when geom (paint/active-frame geom frame)) active (when geom (paint/active-frame geom frame))
[start end] (:span n)] [start end] (:span n)]
[id n geom active [sid id n geom active
;; A frame between two drawing keys with a tween running has no vertices ;; A frame between two drawing keys with a tween running has no vertices
;; of its own to move: what is on screen there is interpolated, and ;; of its own to move: what is on screen there is interpolated, and
;; dragging it would silently edit the key behind it instead. ;; dragging it would silently edit the key behind it instead.
@ -69,7 +71,7 @@
tool @(rf/subscribe [::sub/tool]) tool @(rf/subscribe [::sub/tool])
draft @(rf/subscribe [::sub/draft]) draft @(rf/subscribe [::sub/draft])
drawing? (= :polygon tool) drawing? (= :polygon tool)
[id _ geom active editable?] (editing clip frame) [sid id _ geom active editable?] (editing frame)
pts (when geom (channel/value-at geom frame))] pts (when geom (channel/value-at geom frame))]
[:svg {:class (str "paint-overlay" (when drawing? " drawing")) [:svg {:class (str "paint-overlay" (when drawing? " drawing"))
:width (* zoom w) :height (* zoom h) :width (* zoom w) :height (* zoom h)
@ -79,9 +81,9 @@
(let [[x y] (stage-point event w h)] (let [[x y] (stage-point event w h)]
(rf/dispatch [::ui/add-draft-point x y])))) (rf/dispatch [::ui/add-draft-point x y]))))
:on-pointer-move (fn [event] :on-pointer-move (fn [event]
(when-let [[node key-frame vertex] @dragging] (when-let [[sid node key-frame vertex] @dragging]
(rf/dispatch [::paint-events/set-vertex (rf/dispatch [::paint-events/set-vertex
node key-frame vertex sid node key-frame vertex
(stage-point event w h)]))) (stage-point event w h)])))
:on-pointer-up (fn [_] (reset! dragging nil)) :on-pointer-up (fn [_] (reset! dragging nil))
:on-pointer-cancel (fn [_] (reset! dragging nil))} :on-pointer-cancel (fn [_] (reset! dragging nil))}
@ -104,10 +106,10 @@
(.preventDefault event) (.preventDefault event)
(.setPointerCapture (.-currentTarget event) (.setPointerCapture (.-currentTarget event)
(.-pointerId event)) (.-pointerId event))
(reset! dragging [id active i]))}])))])])) (reset! dragging [sid id active i]))}])))])]))
(defn- dropped-symbol (defn- dropped-symbol
"The library timeline a drag out of the media pool is carrying, or nil. "The symbol a drag out of the media pool is carrying, or nil.
`text/plain` with a prefix rather than a custom MIME type: the payload is one `text/plain` with a prefix rather than a custom MIME type: the payload is one
short string, every browser agrees about this type, and a drag that arrives short string, every browser agrees about this type, and a drag that arrives
@ -130,8 +132,8 @@
(.preventDefault event))) (.preventDefault event)))
:on-drop (fn [^js event] :on-drop (fn [^js event]
(.preventDefault event) (.preventDefault event)
(when-let [tid (dropped-symbol event)] (when-let [sid (dropped-symbol event)]
(rf/dispatch [::ui/place-symbol tid (stage-point event w h)])))} (rf/dispatch [::ui/place-symbol sid (stage-point event w h)])))}
[:canvas.stage {:ref #(player/set-canvas! %) [:canvas.stage {:ref #(player/set-canvas! %)
:width w :height h :width w :height h
:style {:width (str (* zoom w) "px") :style {:width (str (* zoom w) "px")

View file

@ -1,22 +1,24 @@
(ns arthur.ui.timeline (ns arthur.ui.timeline
"The bottom pane: the transport, a ruler, and a row per node. "The bottom pane: the transport, a ruler, and a row per node of the open symbol.
`rows` is the whole of the interesting part and it is a PURE function of the `rows` is the whole of the interesting part and it is a PURE function of the
clip and the set of open paths. It flattens the document's two axes of nesting clip, the open symbol and the set of open paths. It flattens the document's two
— parent/child within a timeline, and instance into a library timeline — into axes of nesting — parent/child within a symbol, and instance of another
symbol — into
one depth-tagged list, which is what lets the labels column and the tracks one depth-tagged list, which is what lets the labels column and the tracks
column render from the same vector and therefore stay aligned without measuring column render from the same vector and therefore stay aligned without measuring
anything. anything.
EVERY FRAME NUMBER A ROW CARRIES IS IN ROOT FRAME SPACE. A symbol's keys are EVERY FRAME NUMBER A ROW CARRIES IS IN THE OPEN SYMBOL'S FRAME SPACE. An
its own timeline's, and drawing them against the stage's ruler unmapped would instance's keys are in its own symbol's, and drawing them against the ruler
unmapped would
put a key under the wrong frame — silently, and most convincingly when the put a key under the wrong frame — silently, and most convincingly when the
instance starts at 0. So the walk carries a `->root` function and composes one instance starts at 0. So the walk carries a `->open` function and composes one
more mapping into it at each instance. The mapping is the inverse of more mapping into it at each instance. The mapping is the inverse of
`node/local-frame`: `local = in + rate·(parent - at)`, so `node/local-frame`: `local = in + rate·(parent - at)`, so
`parent = at + (local - in)/rate`. `parent = at + (local - in)/rate`.
WHAT IT DOES NOT DO YET: a looping instance repeats its timeline, and only the WHAT IT DOES NOT DO YET: a looping instance repeats its symbol, and only the
first pass is drawn. An expanded loop therefore shows keys where they first first pass is drawn. An expanded loop therefore shows keys where they first
happen and not where they happen again." happen and not where they happen again."
(:require [clojure.string :as str] (:require [clojure.string :as str]
@ -35,7 +37,7 @@
(defn- local->parent (defn- local->parent
"The inverse of `node/local-frame`: where a frame of this node's OWN time sits "The inverse of `node/local-frame`: where a frame of this node's OWN time sits
on the timeline it lives in. in the symbol it lives in.
Two frame spaces meet at every node and mixing them up is the bug this exists Two frame spaces meet at every node and mixing them up is the bug this exists
to prevent. A node's `:span` is checked against the frame its PARENT hands it, to prevent. A node's `:span` is checked against the frame its PARENT hands it,
@ -52,7 +54,7 @@
identity identity
(fn [f] (fn [f]
(let [f (- f (or offset 0))] (let [f (- f (or offset 0))]
(if (and (#{:symbol :audio} (:kind n)) (not (zero? rate))) (if (and (#{:instance :audio} (:kind n)) (not (zero? rate)))
(js/Math.round (+ at (/ (- f in) rate))) (js/Math.round (+ at (/ (- f in) rate)))
f)))))) f))))))
@ -69,7 +71,7 @@
(or (:name n) (or (:name n)
(if (keyword? id) (subs (str id) 1) (subs (str id) 0 8)))) (if (keyword? id) (subs (str id) 1) (subs (str id) 0 8))))
(defn- channel-rows [n path depth ->root span] (defn- channel-rows [n path depth ->open span]
(for [[cpath ch] (sort-by (comp str key) (node/channels n))] (for [[cpath ch] (sort-by (comp str key) (node/channels n))]
{:path (conj path cpath) {:path (conj path cpath)
:depth depth :depth depth
@ -77,15 +79,16 @@
:kind :channel :kind :channel
:select nil :select nil
:span (when (or (:dense ch) (seq (:keys ch))) span) :span (when (or (:dense ch) (seq (:keys ch))) span)
:keys (mapv ->root (keyed-frames ch)) :keys (mapv ->open (keyed-frames ch))
:dense? (boolean (:dense ch))})) :dense? (boolean (:dense ch))}))
(defn rows (defn rows
"The visible rows, outermost first. `expanded` is a set of row paths." "The visible rows of symbol `sid`, outermost first. `expanded` is a set of row
[clip expanded] paths."
(letfn [(walk [tid path depth ->root] [clip sid expanded]
(let [tl (get-in clip [:timelines tid]) (letfn [(walk [sid path depth ->open]
ordered (->> (:nodes tl) (let [sym (get-in clip [:symbols sid])
ordered (->> (:nodes sym)
;; Front-most at the top, as a layer list is drawn ;; Front-most at the top, as a layer list is drawn
;; everywhere. `:z` is the lexicographic draw key; ;; everywhere. `:z` is the lexicographic draw key;
;; the id breaks ties so the order is stable. ;; the id breaks ties so the order is stable.
@ -99,18 +102,18 @@
channels (node/channels n) channels (node/channels n)
;; The span is in the PARENT's space and the channels ;; The span is in the PARENT's space and the channels
;; are in the node's own, so they take different ;; are in the node's own, so they take different
;; mappings. `self` is also what the symbol's target ;; mappings. `self` is also what the instance's
;; timeline is resolved in — `clip/resolver` roots the ;; symbol is resolved in — `clip/resolver` roots the
;; child at this node's local frame — so the nested ;; child at this node's local frame — so the nested
;; walk carries it down unchanged. ;; walk carries it down unchanged.
self (comp ->root (local->parent n)) self (comp ->open (local->parent n))
span (mapv ->root (or (:span n) [0 (:frames tl)])) span (mapv ->open (or (:span n) [0 (:frames sym)]))
row {:path rpath row {:path rpath
:depth depth :depth depth
:label (node-label id n) :label (node-label id n)
:kind :node :kind :node
:node-kind (:kind n) :node-kind (:kind n)
:select [:node tid id] :select [:node sid id]
:expandable? true :expandable? true
:expanded? open? :expanded? open?
:span span :span span
@ -123,11 +126,11 @@
[row] [row]
(-> [row] (-> [row]
(into (channel-rows n rpath (inc depth) self span)) (into (channel-rows n rpath (inc depth) self span))
(into (when (= :symbol (:kind n)) (into (when (= :instance (:kind n))
(walk (:of n) rpath (inc depth) self))))))) (walk (:of n) rpath (inc depth) self)))))))
ordered))))] ordered))))]
(if (get-in clip [:timelines :main]) (if (get-in clip [:symbols sid])
(walk :main [] 0 identity) (walk sid [] 0 identity)
[]))) [])))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
@ -153,7 +156,7 @@
(let [playing? @(rf/subscribe [::playback/playing?]) (let [playing? @(rf/subscribe [::playback/playing?])
rate @(rf/subscribe [::playback/rate]) rate @(rf/subscribe [::playback/rate])
frame @(rf/subscribe [::playback/frame]) frame @(rf/subscribe [::playback/frame])
frames @(rf/subscribe [::playback/frames]) frames @(rf/subscribe [::render/frames])
{:keys [fps drop]} @player/meter] {:keys [fps drop]} @player/meter]
[:div.pane-head [:div.pane-head
[:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")] [:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")]
@ -214,11 +217,11 @@
(defn view [] (defn view []
(r/with-let [scrubbing (r/atom false)] (r/with-let [scrubbing (r/atom false)]
(let [clip @(rf/subscribe [::render/clip]) (let [clip @(rf/subscribe [::render/clip])
frames (max 1 (or @(rf/subscribe [::playback/frames]) 1)) frames (max 1 (or @(rf/subscribe [::render/frames]) 1))
frame @(rf/subscribe [::playback/frame]) frame @(rf/subscribe [::playback/frame])
selection @(rf/subscribe [::sub/selection]) selection @(rf/subscribe [::sub/selection])
expanded @(rf/subscribe [::sub/expanded]) expanded @(rf/subscribe [::sub/expanded])
visible (rows clip expanded) visible (rows clip @(rf/subscribe [::render/open]) expanded)
;; Roughly ten labels, on a round number of frames. ;; Roughly ten labels, on a round number of frames.
step (* 10 (js/Math.ceil (/ frames 100)))] step (* 10 (js/Math.ceil (/ frames 100)))]
[:section.pane.time [:section.pane.time
@ -262,5 +265,5 @@
(if (seq visible) (if (seq visible)
(doall (for [row visible] (doall (for [row visible]
^{:key (str (:path row))} [track-cell row frames])) ^{:key (str (:path row))} [track-cell row frames]))
[:div.tl-empty "nothing on this timeline"]) [:div.tl-empty "nothing in this symbol"])
[:div.tl-playhead {:style {:left (at% frame frames)}}]]]]))) [:div.tl-playhead {:style {:left (at% frame frames)}}]]]])))

View file

@ -12,17 +12,17 @@
[re-frame.core :as rf])) [re-frame.core :as rf]))
(defn- exporter [] (defn- exporter []
(let [{:keys [timeline zoom isolate busy? done total]} @(rf/subscribe [::export/state]) (let [{:keys [target zoom busy? done total]} @(rf/subscribe [::export/state])
targets @(rf/subscribe [::export/targets])] targets @(rf/subscribe [::export/targets])]
[:<> [:<>
;; ONE SELECT over everything exportable: the clip, each symbol in its ;; ONE SELECT over everything exportable: each symbol, and each instance
;; library, and each placement on the stage. They are one list because they ;; in the open one. They are one list because they
;; are one kind of request — render this, alone. ;; are one kind of request — render this, alone.
;; ;;
;; The option VALUE goes through `export/target-value`, never `name`: a ;; The option VALUE goes through `export/target-value`, never `name`: a
;; symbol timeline is `:sym/face-8625` and a placement is a uuid, and writing ;; symbol id is `:sym/face-8625` and an instance is a uuid, and writing
;; either through `name` loses what identifies it. ;; either through `name` loses what identifies it.
[:select {:value (export/target-value {:timeline (or timeline :main) :isolate isolate}) [:select {:value (export/target-value target)
:disabled busy? :disabled busy?
:title "what to render" :title "what to render"
:on-change #(rf/dispatch [::export/set-target :on-change #(rf/dispatch [::export/set-target

View file

@ -13,7 +13,7 @@
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.raster :as raster] [arthur.domain.raster :as raster]
[arthur.domain.timeline :as timeline])) [arthur.domain.symbol :as symbol]))
(defn- ms [label n f] (defn- ms [label n f]
(let [t0 (js/Date.now)] (let [t0 (js/Date.now)]
@ -24,11 +24,11 @@
(/ dt n)))) (/ dt n))))
(deftest bench (deftest bench
(let [res (timeline/resolver (clip/root @swarm/clip) @swarm/store pal/index-of) (let [res (symbol/resolver (clip/symbol @swarm/clip :main) @swarm/store pal/index-of)
ras (raster/make 320 200) ras (raster/make 320 200)
dest (js/Uint8ClampedArray. (* 320 200 4)) dest (js/Uint8ClampedArray. (* 320 200 4))
n 120] n 120]
(println "\nswarm:" (count (clip/nodes @swarm/clip)) "nodes") (println "\nswarm:" (count (:nodes (clip/symbol @swarm/clip :main))) "nodes")
(let [a (ms "resolve " n (fn [i] (res (mod i 229)))) (let [a (ms "resolve " n (fn [i] (res (mod i 229))))
b (ms "resolve+draw " n (fn [i] b (ms "resolve+draw " n (fn [i]
(raster/clear! ras 0) (raster/clear! ras 0)

View file

@ -11,14 +11,14 @@
{:subjects (into {} (map (fn [id] [id {:id id :params {}}]) people)) {:subjects (into {} (map (fn [id] [id {:id id :params {}}]) people))
:features (into {} (map-indexed (fn [i id] :features (into {} (map-indexed (fn [i id]
[id {:id id :subject (nth people (quot i 2)) [id {:id id :subject (nth people (quot i 2))
:timeline (nth people (quot i 2)) :symbol (nth people (quot i 2))
:area :eye :nodes [] :params {}}]) eyes)) :area :eye :nodes [] :params {}}]) eyes))
:groups (into {} (map-indexed (fn [i ids] :groups (into {} (map-indexed (fn [i ids]
(let [id (keyword (str "pair-" i))] (let [id (keyword (str "pair-" i))]
[id {:id id :kind :eye-pair [id {:id id :kind :eye-pair
:subject (nth people i) :subject (nth people i)
:members ids :params {}}])) members)) :members ids :params {}}])) members))
:timelines (into {} (map (fn [id] :symbols (into {} (map (fn [id]
[id {:id id :frames 1 [id {:id id :frames 1
:nodes {:head {:id :head :kind :group :z "a1" :nodes {:head {:id :head :kind :group :z "a1"
:measured {[:xform :rot] :measured {[:xform :rot]

View file

@ -0,0 +1,236 @@
(ns arthur.domain.instance-test
(:require [cljs.test :refer [deftest is testing]]
[arthur.demo.stage :as stage]
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.leaf :as leaf]
[arthur.domain.node :as node]
[arthur.domain.pose :as pose]
[arthur.domain.palette :as pal]
[arthur.domain.symbol :as symbol]))
(def source
{:name "source" :fps 30 :width 320 :height 200
:symbols
{:main {:id :main :frames 4
:nodes {:root {:id :root :kind :group :z "a1"}
:mark {:id :mark :kind :rect :parent :root :z "a1"
:channels {[:xform :pos] (ch/keyed {0 [0 0] 1 [10 0]
2 [20 0] 3 [30 0]})
[:geom :size] (ch/framed 4)
[:style :color] (ch/framed :brow)}}}}}})
(deftest two-instances-own-their-frame-and-placement
(let [document
(-> source
(assoc-in [:symbols :main]
{:id :main :frames 6
:nodes {:root {:id :root :kind :group :z "a1"}
:left {:id :left :kind :instance :of :sym/test
:parent :root :z "a1" :span [0 4]
:channels {[:xform :pos] (ch/framed [100 50])}}
:right {:id :right :kind :instance :of :sym/test
:parent :root :z "a2" :span [2 6]
:time {:mode :map :at 2 :in 0 :rate 1}
:channels {[:xform :pos] (ch/framed [120 50])}}}})
(assoc-in [:symbols :sym/test]
(assoc (get-in source [:symbols :main]) :id :sym/test)))
resolve (clip/resolver document nil pal/index-of :main)
at (fn [f] (mapv (juxt :node :cx) (resolve f)))]
(is (empty? (clip/problems document)))
(is (= [[[:left :mark] 110]] (at 1)))
(is (= [[[:left :mark] 120] [[:right :mark] 120]] (at 2)))
(is (= [[[:right :mark] 150]] (at 5)))
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))))
(deftest a-placement-holds-and-cuts-each-generated-shape-independently
(let [values (js/Int16Array. (clj->js (range 2 32)))
visible (ch/keyed {0 true 20 true 21 false})
dense {:animated? true :interp :hold
:dense {:store "sizes" :offset 0 :stride 1 :frames 30}
:pose-sampled? true}
shape (fn [id z group]
{:id id :kind :rect :parent :root :z z :pose-group group
:channels {[:xform :pos] (ch/keyed {0 [0 0] 8 [8 0]})
[:geom :size] dense
[:vis] (assoc visible :pose-sampled? true)
[:style :color] (ch/framed :brow)}})
symbol {:id :sym/poses :frames 30
:nodes {:root {:id :root :kind :group :z "a1"}
:mouth (shape :mouth "a1" :mouth)
:mouth-detail (shape :mouth-detail "a2" :mouth)
:eye (shape :eye "a3" :eye)
:brow (shape :brow "a4" :brow)}}
document {:fps 30 :width 320 :height 200
:symbols
{:main {:id :main :frames 30
:nodes {:root {:id :root :kind :group :z "a1"}
:first {:id :first :kind :instance :of :sym/poses
:parent :root :z "a1"
:playback {:tracks {:mouth {0 0, 8 20, 9 21}
[:node :mouth-detail] {0 0, 8 4}
:eye {0 0, 4 4}}}}
:second {:id :second :kind :instance :of :sym/poses
:parent :root :z "a2"
:playback {:tracks {:mouth {0 0, 8 8}}}}}}
:sym/poses symbol}}
resolve (clip/resolver document {"sizes" {:data values}} pal/index-of :main)
low-resolve (clip/resolver document {"sizes" {:data values}}
pal/index-of :main {:picture-fps 8})
at (fn [f] (into {} (map (fn [op] [(:node op) op])) (resolve f)))
low-at (fn [f] (into {} (map (fn [op] [(:node op) op])) (low-resolve f)))]
(is (empty? (clip/problems document)))
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))
(is (= 2 (:size (get (at 7) [:first :mouth]))) "eight static frames")
(is (= 22 (:size (get (at 8) [:first :mouth]))) "cut to source pose 20")
(is (= 6 (:size (get (at 8) [:first :mouth-detail])))
"one node may depart from its shared mouth group")
(is (= 10 (:size (get (at 8) [:second :mouth]))) "other instance chooses pose 8")
(is (= 6 (:size (get (at 7) [:first :eye]))) "eye has its own timing")
(is (= 8 (:cx (get (at 8) [:first :eye]))) "authored position still reads stage time")
(is (nil? (get (at 9) [:first :mouth]))
"generated visibility is read from the same selected pose")
(is (some? (get (at 9) [:second :mouth])))
(is (= 5 (:size (get (low-at 7) [:first :brow])))
"picture rate samples only generated motion")
(is (= 22 (:size (get (low-at 8) [:first :mouth])))
"an explicit cut occurs at its exact local frame, even off the picture grid")
(is (= 8 (:cx (get (low-at 8) [:first :eye])))
"authored position ignores the picture grid")
(let [sym (get-in document [:symbols :sym/poses])
opts {:source-fps 30 :picture-fps 8}]
(is (= (mapv #(select-keys % [:node :cx :size])
(symbol/eval-frame sym 8 {"sizes" {:data values}}
pal/index-of {:mouth {0 0, 8 20}} opts))
(mapv #(select-keys % [:node :cx :size])
((symbol/resolver sym {"sizes" {:data values}}
pal/index-of {:mouth {0 0, 8 20}} opts) 8)))
"pure evaluation and playback apply the same pose choice"))))
(deftest stage-pose-edits-preserve-earlier-motion-and-survive-save
(let [document (-> source
(assoc-in [:symbols :main :nodes :placed]
{:id :placed :kind :instance :of :sym/test :parent :root
:z "a2"})
(assoc-in [:symbols :sym/test]
{:id :sym/test :frames 4
:nodes {:root {:id :root :kind :group :z "a1"}
:mark {:id :mark :kind :rect :parent :root
:z "a1" :pose-group :mark
:channels {[:geom :size]
{:animated? true :interp :hold
:keys {0 2 1 3 2 4 3 5}
:pose-sampled? true}}}}})
(pose/put-cut :main :placed :mark 2 3))
cuts (get-in document [:symbols :main :nodes :placed :playback :tracks :mark])]
(is (= {2 3} cuts))
(is (= 1 (pose/source-frame (pose/prepare {:mark cuts}) :mark 1 1))
"before the first cut, dense motion continues")
(is (= 3 (pose/source-frame (pose/prepare {:mark cuts}) :mark 2 2)))
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))
(is (nil? (get-in (pose/remove-cut document :main :placed :mark 2)
[:symbols :main :nodes :placed :playback :tracks :mark])))
(is (seq (clip/problems (assoc-in document
[:symbols :main :nodes :placed :playback :tracks :mark]
{4 3}))))))
(defn- uuid-of
"The uuid the layout authors for the placement whose handle is `id`.
Read out of `stage/layout` rather than written here as a literal: what this test
is about is the mapping `compose` performs, and nine copied uuids would assert
that someone copied them correctly."
[id]
(or (->> (concat (:instances stage/layout) (:audio stage/layout))
(some (fn [p] (when (= id (:id p)) (:uuid p)))))
(throw (ex-info "no such placement in the layout" {:id id}))))
(defn- placement
"The composed node for the placement the layout calls `id`."
[document id]
(get-in document [:symbols :main :nodes (uuid-of id)]))
(deftest stage-fixture-keeps-source-as-one-symbol
(let [document (stage/compose source)]
(is (empty? (clip/problems document)))
(is (= #{:main :sym/face-8625} (set (keys (:symbols document)))))
(is (= :sym/face-8625 (:of (placement document :left))))
(is (= :sym/face-8625 (:of (placement document :right))))
(testing "every placement is keyed by its own uuid"
;; The identity change: seven placements of one drawing are seven things,
;; and each is named by something that means only itself. Sharing a key, or
;; keying by a description of where a thing sits, is what this rules out.
(let [symbols (filter (comp #{:instance} :kind val)
(get-in document [:symbols :main :nodes]))]
(is (= 7 (count symbols)))
(is (every? uuid? (map key symbols)))
(is (= 7 (count (distinct (map key symbols)))))
(testing "and each still says which drawing it plays and what to call it"
(is (every? #(= :sym/face-8625 (:of (val %))) symbols))
(is (every? #(string? (:name (val %))) symbols))
(is (= 7 (count (distinct (map #(:name (val %)) symbols))))))))
(is (= 7 (count (filter #(= :instance (:kind %))
(vals (get-in document [:symbols :main :nodes]))))))
(is (= [48 280] (:span (placement document :right))))
(let [left (placement document :left)
scale (get-in left [:channels [:xform :scale]])
anchor (get-in left [:channels [:xform :anchor] :value])
pos (get-in left [:channels [:xform :pos]])
start-pos (ch/value-at pos 0)]
(is (= [160 100] anchor) "the source center becomes a stored pivot")
(is (= [-120 -60] start-pos))
(is (not= start-pos (ch/value-at pos 40)) "the face drifts during playback")
(is (= [0.4 0.4] (ch/value-at scale 0)))
(is (= [0.56 0.56] (ch/value-at scale 12)))
(is (= [0.52 0.52] (ch/value-at scale 48)))
(doseq [f [0 12 48]]
(let [m (node/local! (node/mat) start-pos 0 (ch/value-at scale f) [0 0] anchor)
out (js/Float64Array. 2)]
(node/apply-pt! out 0 m 160 100)
(is (= [40 40] [(aget out 0) (aget out 1)])
"the face center stays put while it scales"))))
(testing "the editorial link resolves to the placement's uuid"
;; The EDN names `:right`; the document must carry the identity, or the link
;; dangles the moment anything is renamed. `clip/problems` above checks it
;; resolves to a node at all; this checks it resolves to the RIGHT one.
(is (= (uuid-of :right) (:linked-to (placement document :voice-right))))
(is (uuid? (:linked-to (placement document :voice-right)))))
(is (= [48 260] (:span (placement document :voice-right))))
(is (= 0.5 (ch/value-at
(get-in (placement document :voice-right)
[:channels [:audio :gain]]) 54)))
(is (< -0.8 (ch/value-at
(get-in (placement document :voice-right)
[:channels [:audio :pan]]) 110) 0.7))
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))))
(defn- nested
"Three symbols: :outer places :inner, and :loose is placed by nothing."
[]
(-> (clip/blank)
(assoc-in [:symbols :outer] {:id :outer :frames 200 :nodes {}})
(assoc-in [:symbols :inner] {:id :inner :frames 10 :nodes {}})
(assoc-in [:symbols :loose] {:id :loose :frames 30 :nodes {}})
(clip/place-symbol :outer :inner 5 (random-uuid) [0 0])))
(deftest no-symbol-is-special
(let [c (nested)]
(testing "a document opens on the longest symbol nothing places"
(is (= [:loose :main :outer] (clip/unplaced c)))
(is (= :outer (clip/opens-on c)))
(is (= :main (clip/opens-on (clip/blank)))))
(testing "an instance can go into any symbol, and spans that symbol's frames"
(let [[n] (vals (get-in c [:symbols :outer :nodes]))]
(is (= :inner (:of n)))
(is (= [5 15] (:span n)) "as long as what it places, not as the space it is in")))
(testing "placing is refused when it would make a cycle"
(is (clip/contains-symbol? c :outer :inner))
(is (not (clip/contains-symbol? c :inner :outer)))
(is (= c (clip/place-symbol c :inner :outer 0 (random-uuid) [0 0]))
"outer inside inner, which is inside outer")
(is (= c (clip/place-symbol c :inner :inner 0 (random-uuid) [0 0]))
"a symbol inside itself"))
(testing "and the result is a valid document whose instance saves"
(is (empty? (clip/problems c)))
(is (= (get-in c [:symbols :outer :nodes])
(get-in (leaf/clip "c" (leaf/leaves "c" c)) [:symbols :outer :nodes]))))))

View file

@ -6,7 +6,7 @@
So the assertion is exact equality on the real clips — the frozen take in both So the assertion is exact equality on the real clips — the frozen take in both
head modes, the hand-written demo, the swarm — rather than on a fixture, and head modes, the hand-written demo, the swarm — rather than on a fixture, and
`clip/clip-keys` plus `timeline/timeline-keys` make a field added without a leaf `clip/clip-keys` plus `symbol/symbol-keys` make a field added without a leaf
fail loudly instead." fail loudly instead."
(:require [cljs.test :refer [deftest is testing]] (:require [cljs.test :refer [deftest is testing]]
[arthur.demo :as demo] [arthur.demo :as demo]
@ -16,11 +16,11 @@
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.leaf :as leaf])) [arthur.domain.leaf :as leaf]))
(defn- one-timeline (defn- one-symbol
"A minimal clip holding one timeline of these nodes, for the cases that are "A minimal clip holding one timeline of these nodes, for the cases that are
about a path rather than about a take." about a path rather than about a take."
[nodes] [nodes]
{:timelines {:main {:id :main :frames 1 :nodes nodes}}}) {:symbols {:main {:id :main :frames 1 :nodes nodes}}})
(deftest every-real-clip-survives-the-split-exactly (deftest every-real-clip-survives-the-split-exactly
(doseq [[label c] [["the frozen take" @take/clip] (doseq [[label c] [["the frozen take" @take/clip]
@ -37,20 +37,20 @@
(is (contains? ls "clip/c7/source")) (is (contains? ls "clip/c7/source"))
;; A TIMELINE ID IS A SEGMENT, which is what lets a symbol's nodes be ;; A TIMELINE ID IS A SEGMENT, which is what lets a symbol's nodes be
;; addressed by the same path shape as the clip's own. `main` is the root. ;; addressed by the same path shape as the clip's own. `main` is the root.
(is (contains? ls "clip/c7/timeline/main")) (is (contains? ls "clip/c7/symbol/main"))
(is (= {:frames 229} (get ls "clip/c7/timeline/main")) (is (= {:frames 229} (get ls "clip/c7/symbol/main"))
"a timeline's leaf is its frame space; :fps is the clip's") "a timeline's leaf is its frame space; :fps is the clip's")
;; Nodes are local to the face timeline; feature and group ids are clip-wide. ;; Nodes are local to the face timeline; feature and group ids are clip-wide.
(is (contains? ls "clip/c7/timeline/face-1/node/mouth")) (is (contains? ls "clip/c7/symbol/face-1/node/mouth"))
(is (contains? ls "clip/c7/timeline/face-1/channel/mouth/geom.pts")) (is (contains? ls "clip/c7/symbol/face-1/channel/mouth/geom.pts"))
(is (contains? ls "clip/c7/timeline/face-1/channel/mouth-in/vis")) (is (contains? ls "clip/c7/symbol/face-1/channel/mouth-in/vis"))
(is (contains? ls "clip/c7/feature/face-1~eye-r")) (is (contains? ls "clip/c7/feature/face-1~eye-r"))
(is (contains? ls "clip/c7/group/face-1~eyes")) (is (contains? ls "clip/c7/group/face-1~eyes"))
(is (contains? ls "clip/c7/subject/face-1")) (is (contains? ls "clip/c7/subject/face-1"))
;; A head's measured channels are written together by a freeze and replaced ;; A head's measured channels are written together by a freeze and replaced
;; together by a re-freeze, so they are one leaf and not three. ;; together by a re-freeze, so they are one leaf and not three.
(is (contains? ls "clip/c7/timeline/face-1/measured/head")) (is (contains? ls "clip/c7/symbol/face-1/measured/head"))
(is (= 3 (count (get ls "clip/c7/timeline/face-1/measured/head")))) (is (= 3 (count (get ls "clip/c7/symbol/face-1/measured/head"))))
;; :frames is NOT in `timing` any more. A timeline is a frame space and a clip ;; :frames is NOT in `timing` any more. A timeline is a frame space and a clip
;; is a rate, so the one leaf that held both was the persistence half of the ;; is a rate, so the one leaf that held both was the persistence half of the
;; conflation `domain/clip` exists to undo. ;; conflation `domain/clip` exists to undo.
@ -60,11 +60,11 @@
;; The boundary that lets two people key different parts without meeting. A node ;; The boundary that lets two people key different parts without meeting. A node
;; leaf carries structure and no geometry. ;; leaf carries structure and no geometry.
(let [ls (leaf/leaves :c1 @take/clip) (let [ls (leaf/leaves :c1 @take/clip)
n (get ls "clip/c1/timeline/face-1/node/mouth")] n (get ls "clip/c1/symbol/face-1/node/mouth")]
(is (= {:id :mouth :name "mouth" :kind :poly :parent :head (is (= {:id :mouth :name "mouth" :kind :poly :parent :head
:z "a1" :pose-group :mouth} n)) :z "a1" :pose-group :mouth} n))
(is (nil? (:channels n))) (is (nil? (:channels n)))
(is (:animated? (get ls "clip/c1/timeline/face-1/channel/mouth/geom.pts"))))) (is (:animated? (get ls "clip/c1/symbol/face-1/channel/mouth/geom.pts")))))
(deftest a-field-with-no-leaf-is-refused-rather-than-dropped (deftest a-field-with-no-leaf-is-refused-rather-than-dropped
;; The invariant that keeps the round trip exact as the model grows: a field ;; The invariant that keeps the round trip exact as the model grows: a field
@ -74,7 +74,7 @@
(leaf/leaves :c1 (assoc @take/clip :sequences [])))) (leaf/leaves :c1 (assoc @take/clip :sequences []))))
(is (thrown-with-msg? ExceptionInfo #"no leaf to save it in" (is (thrown-with-msg? ExceptionInfo #"no leaf to save it in"
(leaf/leaves :c1 (assoc-in @take/clip (leaf/leaves :c1 (assoc-in @take/clip
[:timelines :main :markers] [])))) [:symbols :main :markers] []))))
(is (= clip/clip-keys (set (keys (assoc @take/clip :name "x")))) (is (= clip/clip-keys (set (keys (assoc @take/clip :name "x"))))
"clip-keys has drifted from what a frozen clip actually holds")) "clip-keys has drifted from what a frozen clip actually holds"))
@ -86,16 +86,16 @@
(is (not (contains? ls "clip/c1/source"))) (is (not (contains? ls "clip/c1/source")))
(is (not (contains? (leaf/clip :c1 ls) :analysis))) (is (not (contains? (leaf/clip :c1 ls) :analysis)))
(is (not (contains? (get-in (leaf/clip :c1 ls) (is (not (contains? (get-in (leaf/clip :c1 ls)
[:timelines :main :nodes :root]) [:symbols :main :nodes :root])
:channels))))) :channels)))))
(deftest a-namespaced-id-is-one-path-segment (deftest a-namespaced-id-is-one-path-segment
;; docs/architecture.md draws a node as `:eye-r/iris`, and a leaf path is ;; docs/architecture.md draws a node as `:eye-r/iris`, and a leaf path is
;; "/"-delimited, so the two have to be reconciled somewhere. ;; "/"-delimited, so the two have to be reconciled somewhere.
(let [c (one-timeline {:eye-r/iris {:id :eye-r/iris :kind :disc :parent nil :z "a1" (let [c (one-symbol {:eye-r/iris {:id :eye-r/iris :kind :disc :parent nil :z "a1"
:channels {[:geom :radius] (ch/framed 2)}}}) :channels {[:geom :radius] (ch/framed 2)}}})
ls (leaf/leaves :c1 c)] ls (leaf/leaves :c1 c)]
(is (contains? ls "clip/c1/timeline/main/node/eye-r~iris")) (is (contains? ls "clip/c1/symbol/main/node/eye-r~iris"))
(is (= c (leaf/clip :c1 ls)))) (is (= c (leaf/clip :c1 ls))))
;; `(keyword "a~b")` rather than a literal: ~ is unquote in CLJS source. ;; `(keyword "a~b")` rather than a literal: ~ is unquote in CLJS source.
(is (thrown-with-msg? ExceptionInfo #"cannot contain ~" (is (thrown-with-msg? ExceptionInfo #"cannot contain ~"
@ -109,13 +109,13 @@
;; right in a log and resolves nothing: `:linked-to` dangles and an export target ;; right in a log and resolves nothing: `:linked-to` dangles and an export target
;; matches no node, with no error anywhere. ;; matches no node, with no error anywhere.
(let [u #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218" (let [u #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218"
c (one-timeline {u {:id u :kind :symbol :of :sym/face-8625 :parent nil c (one-symbol {u {:id u :kind :instance :of :sym/face-8625 :parent nil
:z "a1" :name "8625 bottom left"}}) :z "a1" :name "8625 bottom left"}})
ls (leaf/leaves :c1 c)] ls (leaf/leaves :c1 c)]
(is (contains? ls (str "clip/c1/timeline/main/node/" u)) (is (contains? ls (str "clip/c1/symbol/main/node/" u))
"written plainly, with no sigil") "written plainly, with no sigil")
(is (= c (leaf/clip :c1 ls))) (is (= c (leaf/clip :c1 ls)))
(is (uuid? (first (keys (get-in (leaf/clip :c1 ls) [:timelines :main :nodes]))))))) (is (uuid? (first (keys (get-in (leaf/clip :c1 ls) [:symbols :main :nodes])))))))
(deftest only-a-whole-canonical-uuid-reads-as-one (deftest only-a-whole-canonical-uuid-reads-as-one
;; The id encoding decides by SHAPE, so the boundaries of that shape are the ;; The id encoding decides by SHAPE, so the boundaries of that shape are the
@ -155,24 +155,24 @@
(is (empty? (leaf/problems (leaf/leaves :c1 @take/locked))))) (is (empty? (leaf/problems (leaf/leaves :c1 @take/locked)))))
(deftest a-channel-leaf-for-a-node-that-is-not-there-is-named (deftest a-channel-leaf-for-a-node-that-is-not-there-is-named
(let [ls (dissoc (leaf/leaves :c1 @take/clip) "clip/c1/timeline/face-1/node/mouth")] (let [ls (dissoc (leaf/leaves :c1 @take/clip) "clip/c1/symbol/face-1/node/mouth")]
(is (some #(re-find #"node with no node leaf" %) (leaf/problems ls))))) (is (some #(re-find #"node with no node leaf" %) (leaf/problems ls)))))
(deftest a-node-leaf-is-scoped-to-its-own-timeline (deftest a-node-leaf-is-scoped-to-its-own-symbol
;; The reason the node index in `problems` is keyed by (clip, timeline, node) ;; The reason the node index in `problems` is keyed by (clip, timeline, node)
;; rather than by node alone: two timelines may each hold a `:mouth`, and a ;; rather than by node alone: two timelines may each hold a `:mouth`, and a
;; channel of one is not a channel of the other. Keyed by node alone, deleting ;; channel of one is not a channel of the other. Keyed by node alone, deleting
;; the root's node leaf would have been excused by the symbol's. ;; the root's node leaf would have been excused by the symbol's.
(let [ls (-> (leaf/leaves :c1 @take/clip) (let [ls (-> (leaf/leaves :c1 @take/clip)
(assoc "clip/c1/timeline/sym~blink" {:frames 3} (assoc "clip/c1/symbol/sym~blink" {:frames 3}
"clip/c1/timeline/sym~blink/node/mouth" "clip/c1/symbol/sym~blink/node/mouth"
{:id :mouth :kind :poly :parent nil :z "a1"}) {:id :mouth :kind :poly :parent nil :z "a1"})
(dissoc "clip/c1/timeline/face-1/node/mouth"))] (dissoc "clip/c1/symbol/face-1/node/mouth"))]
(is (some #(re-find #"node with no node leaf" %) (leaf/problems ls))))) (is (some #(re-find #"node with no node leaf" %) (leaf/problems ls)))))
(deftest a-property-with-path-punctuation-in-it-is-refused (deftest a-property-with-path-punctuation-in-it-is-refused
(is (thrown-with-msg? (is (thrown-with-msg?
ExceptionInfo #"cannot contain . or /" ExceptionInfo #"cannot contain . or /"
(leaf/leaves :c1 (one-timeline (leaf/leaves :c1 (one-symbol
{:a {:id :a :kind :poly :parent nil :z "a1" {:a {:id :a :kind :poly :parent nil :z "a1"
:channels {[:geom :pts.x] (ch/framed [0 0])}}}))))) :channels {[:geom :pts.x] (ch/framed [0 0])}}})))))

View file

@ -174,7 +174,7 @@
(is (empty? (node/problems {:id :x :kind :group :z "a1"}))) (is (empty? (node/problems {:id :x :kind :group :z "a1"})))
(is (seq (node/problems {:kind :group :z "a1"})) "no :id") (is (seq (node/problems {:kind :group :z "a1"})) "no :id")
(is (seq (node/problems {:id :x :kind :blob :z "a1"})) "not a kind") (is (seq (node/problems {:id :x :kind :blob :z "a1"})) "not a kind")
(is (seq (node/problems {:id :x :kind :symbol :z "a1"})) "a kind that is not built") (is (seq (node/problems {:id :x :kind :instance :z "a1"})) "a kind that is not built")
(is (seq (node/problems {:id :x :kind :group})) "no :z") (is (seq (node/problems {:id :x :kind :group})) "no :z")
(is (seq (node/problems {:id :x :kind :group :z "a1" :span [3]})) "a malformed span") (is (seq (node/problems {:id :x :kind :group :z "a1" :span [3]})) "a malformed span")
(is (seq (node/problems {:id :x :kind :group :z "a1" (is (seq (node/problems {:id :x :kind :group :z "a1"

View file

@ -4,22 +4,22 @@
[arthur.domain.channel :as channel] [arthur.domain.channel :as channel]
[arthur.domain.leaf :as leaf] [arthur.domain.leaf :as leaf]
[arthur.domain.paint :as paint] [arthur.domain.paint :as paint]
[arthur.domain.timeline :as timeline])) [arthur.domain.symbol :as symbol]))
(defn- geometry [clip] (defn- geometry [clip]
(get-in clip [:timelines :main :nodes :paint-test :channels paint/geometry])) (get-in clip [:symbols :main :nodes :paint-test :channels paint/geometry]))
(deftest drawing-keys-hold-and-tween-on-the-timeline-clock (deftest drawing-keys-hold-and-tween-on-the-symbol-clock
(let [a [10 10 30 10 20 30] (let [a [10 10 30 10 20 30]
c0 (paint/new-shape demo/clip :paint-test 3 a :brow) c0 (paint/new-shape demo/clip :main :paint-test 3 a :brow)
c1 (paint/add-key c0 :paint-test 9) c1 (paint/add-key c0 :main :paint-test 9)
c2 (paint/set-vertex c1 :paint-test 9 0 [22 10]) c2 (paint/set-vertex c1 :main :paint-test 9 0 [22 10])
c3 (paint/add-key c2 :paint-test 15) c3 (paint/add-key c2 :main :paint-test 15)
c4 (paint/set-vertex c3 :paint-test 15 0 [34 10]) c4 (paint/set-vertex c3 :main :paint-test 15 0 [34 10])
held (geometry c4) held (geometry c4)
mixed-clip (paint/set-segment-interp c4 :paint-test 9 :linear) mixed-clip (paint/set-segment-interp c4 :main :paint-test 9 :linear)
mixed (geometry mixed-clip)] mixed (geometry mixed-clip)]
(is (= [3 229] (get-in c2 [:timelines :main :nodes :paint-test :span]))) (is (= [3 229] (get-in c2 [:symbols :main :nodes :paint-test :span])))
(is (= a (channel/value-at held 8))) (is (= a (channel/value-at held 8)))
(is (= 10 (first (channel/value-at held 8)))) (is (= 10 (first (channel/value-at held 8))))
(is (= 22 (first (channel/value-at held 9)))) (is (= 22 (first (channel/value-at held 9))))
@ -28,5 +28,5 @@
(is (empty? (channel/problems mixed))) (is (empty? (channel/problems mixed)))
;; The demo's root is exposed on 2s. Paint at frame 3 must still appear at 3. ;; The demo's root is exposed on 2s. Paint at frame 3 must still appear at 3.
(is (some #(= :paint-test (:node %)) (is (some #(= :paint-test (:node %))
(timeline/eval-frame (get-in c2 [:timelines :main]) 3))) (symbol/eval-frame (get-in c2 [:symbols :main]) 3)))
(is (= mixed-clip (leaf/clip :c1 (leaf/leaves :c1 mixed-clip)))))) (is (= mixed-clip (leaf/clip :c1 (leaf/leaves :c1 mixed-clip))))))

View file

@ -22,11 +22,11 @@
[arthur.domain.channel :as ch] [arthur.domain.channel :as ch]
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.project :as project] [arthur.domain.project :as project]
[arthur.domain.timeline :as timeline] [arthur.domain.symbol :as symbol]
[arthur.flow.freeze :as freeze] [arthur.flow.freeze :as freeze]
[arthur.support.ops :as ops])) [arthur.support.ops :as ops]))
(defn- face-timeline [c] (clip/timeline c :face-1)) (defn- face-symbol [c] (clip/symbol c :face-1))
(defn- wired (defn- wired
"A clip out and back, over a wire that is really only JSON." "A clip out and back, over a wire that is really only JSON."
@ -37,7 +37,7 @@
(def ^:private after (delay (wired :c1 @before))) (def ^:private after (delay (wired :c1 @before)))
(deftest what-comes-back-is-a-valid-clip (deftest what-comes-back-is-a-valid-clip
;; `clip/problems` and not `timeline/problems`: the round trip has to preserve ;; `clip/problems` and not `symbol/problems`: the round trip has to preserve
;; the tracking identities and the timeline map as well as the nodes, and only ;; the tracking identities and the timeline map as well as the nodes, and only
;; the clip-level check looks at those. ;; the clip-level check looks at those.
(let [ps (clip/problems (:clip @after))] (let [ps (clip/problems (:clip @after))]
@ -53,14 +53,14 @@
;; The assertion. Both evaluators, both scenes, every frame order — so a block ;; The assertion. Both evaluators, both scenes, every frame order — so a block
;; that came back with its offsets shifted, or a cursor that seeks differently ;; that came back with its offsets shifted, or a cursor that seeks differently
;; over a rebuilt key map, has nowhere to hide. ;; over a rebuilt key map, has nowhere to hide.
(let [n (clip/frames (:clip @before)) (let [n (clip/frames (:clip @before) :main)
paths {"specification" [ops/specified ops/specified] paths {"specification" [ops/specified ops/specified]
"playback" [ops/resolved ops/resolved] "playback" [ops/resolved ops/resolved]
"spec vs playback, after" [ops/specified ops/resolved]}] "spec vs playback, after" [ops/specified ops/resolved]}]
(doseq [[label [f g]] paths (doseq [[label [f g]] paths
[order fs] (ops/orders n)] [order fs] (ops/orders n)]
(let [a (f (face-timeline (:clip @before)) (:store @before)) (let [a (f (face-symbol (:clip @before)) (:store @before))
b (g (face-timeline (:clip @after)) (:store @after))] b (g (face-symbol (:clip @after)) (:store @after))]
(testing (str label ", " order) (testing (str label ", " order)
(doseq [frame fs] (doseq [frame fs]
(is (= (a frame) (b frame)) (is (= (a frame) (b frame))
@ -84,8 +84,8 @@
;; everything above and lose the locked take's identity transform. ;; everything above and lose the locked take's identity transform.
(let [locked {:clip @take/locked :store @take/store} (let [locked {:clip @take/locked :store @take/store}
back (wired :c1 locked) back (wired :c1 locked)
a (ops/resolved (face-timeline (:clip locked)) (:store locked)) a (ops/resolved (face-symbol (:clip locked)) (:store locked))
b (ops/resolved (face-timeline (:clip back)) (:store back))] b (ops/resolved (face-symbol (:clip back)) (:store back))]
(is (= (:clip locked) (:clip back))) (is (= (:clip locked) (:clip back)))
(doseq [frame (range 0 take/frames 7)] (doseq [frame (range 0 take/frames 7)]
(is (= (a frame) (b frame)) (str "frame " frame))))) (is (= (a frame) (b frame)) (str "frame " frame)))))
@ -116,7 +116,7 @@
(deftest an-absence-mask-survives-the-wire (deftest an-absence-mask-survives-the-wire
(let [back (wired :c1 @gappy) (let [back (wired :c1 @gappy)
at (fn [entry id path f] at (fn [entry id path f]
(ch/value-at (get-in (:nodes (face-timeline (:clip entry))) [id :channels path]) (ch/value-at (get-in (:nodes (face-symbol (:clip entry))) [id :channels path])
f (:store entry))) f (:store entry)))
;; Every dense track of the eye, iris, brow and brow-position blocks, and ;; Every dense track of the eye, iris, brow and brow-position blocks, and
;; the feature whose gap it must follow — the same table ;; the feature whose gap it must follow — the same table
@ -147,7 +147,7 @@
;; frame rather than hidden, and its partner is not. ;; frame rather than hidden, and its partner is not.
(let [back (wired :c1 @gappy) (let [back (wired :c1 @gappy)
drawn (into #{} (map :node) drawn (into #{} (map :node)
((timeline/resolver (face-timeline (:clip back)) (:store back)) 12))] ((symbol/resolver (face-symbol (:clip back)) (:store back)) 12))]
(is (not (contains? drawn :eye-r))) (is (not (contains? drawn :eye-r)))
(is (contains? drawn :eye-l)) (is (contains? drawn :eye-l))
(is (contains? drawn :mouth)))) (is (contains? drawn :mouth))))

View file

@ -1,205 +1,397 @@
(ns arthur.domain.symbol-test (ns arthur.domain.symbol-test
"Frame evaluation, and the hand-written scene.
port-plan step 2 exists to find out whether the data model works BEFORE nine
hundred lines of measurement are ported into it, so these assertions are about
the model's claims rather than about a look: that structure is flat and
addressable, that draw order is authored, that time maps compose, that
presence and visibility are different questions, and that the fast path and the
specification give the same frame."
(:require [cljs.test :refer [deftest is testing]] (:require [cljs.test :refer [deftest is testing]]
[arthur.demo.stage :as stage] [arthur.demo :as demo]
[arthur.domain.channel :as ch] [arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.leaf :as leaf]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.pose :as pose]
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.timeline :as timeline])) [arthur.domain.raster :as raster]
[arthur.domain.clip :as clip]
[arthur.domain.symbol :as symbol]
[arthur.support.ops :as ops]))
(def source (defn- poly [id parent z pts color & [extra]]
{:name "source" :fps 30 :width 320 :height 200 (merge {:id id :kind :poly :parent parent :z z
:timelines :channels {[:geom :pts] (ch/framed pts)
{:main {:id :main :frames 4 [:style :color] (ch/framed color)}}
:nodes {:root {:id :root :kind :group :z "a1"} extra))
:mark {:id :mark :kind :rect :parent :root :z "a1"
:channels {[:xform :pos] (ch/keyed {0 [0 0] 1 [10 0]
2 [20 0] 3 [30 0]})
[:geom :size] (ch/framed 4)
[:style :color] (ch/framed :brow)}}}}}})
(deftest two-instances-own-their-frame-and-placement (defn- sc [& nodes]
(let [document {:nodes (into {} (map (juxt :id identity)) nodes)})
(-> source
(assoc-in [:timelines :main]
{:id :main :frames 6
:nodes {:root {:id :root :kind :group :z "a1"}
:left {:id :left :kind :symbol :of :sym/test
:parent :root :z "a1" :span [0 4]
:channels {[:xform :pos] (ch/framed [100 50])}}
:right {:id :right :kind :symbol :of :sym/test
:parent :root :z "a2" :span [2 6]
:time {:mode :map :at 2 :in 0 :rate 1}
:channels {[:xform :pos] (ch/framed [120 50])}}}})
(assoc-in [:timelines :sym/test]
(assoc (get-in source [:timelines :main]) :id :sym/test)))
resolve (clip/resolver document nil)
at (fn [f] (mapv (juxt :node :cx) (resolve f)))]
(is (empty? (clip/problems document)))
(is (= [[[:left :mark] 110]] (at 1)))
(is (= [[[:left :mark] 120] [[:right :mark] 120]] (at 2)))
(is (= [[[:right :mark] 150]] (at 5)))
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))))
(deftest a-placement-holds-and-cuts-each-generated-shape-independently (defn- ids-at [scene f]
(let [values (js/Int16Array. (clj->js (range 2 32))) (mapv :node (symbol/eval-frame scene f)))
visible (ch/keyed {0 true 20 true 21 false})
dense {:animated? true :interp :hold
:dense {:store "sizes" :offset 0 :stride 1 :frames 30}
:pose-sampled? true}
shape (fn [id z group]
{:id id :kind :rect :parent :root :z z :pose-group group
:channels {[:xform :pos] (ch/keyed {0 [0 0] 8 [8 0]})
[:geom :size] dense
[:vis] (assoc visible :pose-sampled? true)
[:style :color] (ch/framed :brow)}})
symbol {:id :sym/poses :frames 30
:nodes {:root {:id :root :kind :group :z "a1"}
:mouth (shape :mouth "a1" :mouth)
:mouth-detail (shape :mouth-detail "a2" :mouth)
:eye (shape :eye "a3" :eye)
:brow (shape :brow "a4" :brow)}}
document {:fps 30 :width 320 :height 200
:timelines
{:main {:id :main :frames 30
:nodes {:root {:id :root :kind :group :z "a1"}
:first {:id :first :kind :symbol :of :sym/poses
:parent :root :z "a1"
:playback {:tracks {:mouth {0 0, 8 20, 9 21}
[:node :mouth-detail] {0 0, 8 4}
:eye {0 0, 4 4}}}}
:second {:id :second :kind :symbol :of :sym/poses
:parent :root :z "a2"
:playback {:tracks {:mouth {0 0, 8 8}}}}}}
:sym/poses symbol}}
resolve (clip/resolver document {"sizes" {:data values}})
low-resolve (clip/resolver document {"sizes" {:data values}}
pal/index-of :main {:picture-fps 8})
at (fn [f] (into {} (map (fn [op] [(:node op) op])) (resolve f)))
low-at (fn [f] (into {} (map (fn [op] [(:node op) op])) (low-resolve f)))]
(is (empty? (clip/problems document)))
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))
(is (= 2 (:size (get (at 7) [:first :mouth]))) "eight static frames")
(is (= 22 (:size (get (at 8) [:first :mouth]))) "cut to source pose 20")
(is (= 6 (:size (get (at 8) [:first :mouth-detail])))
"one node may depart from its shared mouth group")
(is (= 10 (:size (get (at 8) [:second :mouth]))) "other instance chooses pose 8")
(is (= 6 (:size (get (at 7) [:first :eye]))) "eye has its own timing")
(is (= 8 (:cx (get (at 8) [:first :eye]))) "authored position still reads stage time")
(is (nil? (get (at 9) [:first :mouth]))
"generated visibility is read from the same selected pose")
(is (some? (get (at 9) [:second :mouth])))
(is (= 5 (:size (get (low-at 7) [:first :brow])))
"picture rate samples only generated motion")
(is (= 22 (:size (get (low-at 8) [:first :mouth])))
"an explicit cut occurs at its exact local frame, even off the picture grid")
(is (= 8 (:cx (get (low-at 8) [:first :eye])))
"authored position ignores the picture grid")
(let [tl (get-in document [:timelines :sym/poses])
opts {:source-fps 30 :picture-fps 8}]
(is (= (mapv #(select-keys % [:node :cx :size])
(timeline/eval-frame tl 8 {"sizes" {:data values}}
pal/index-of {:mouth {0 0, 8 20}} opts))
(mapv #(select-keys % [:node :cx :size])
((timeline/resolver tl {"sizes" {:data values}}
pal/index-of {:mouth {0 0, 8 20}} opts) 8)))
"pure evaluation and playback apply the same pose choice"))))
(deftest stage-pose-edits-preserve-earlier-motion-and-survive-save (def ^:private pts-of ops/points)
(let [document (-> source
(assoc-in [:timelines :main :nodes :placed]
{:id :placed :kind :symbol :of :sym/test :parent :root
:z "a2"})
(assoc-in [:timelines :sym/test]
{:id :sym/test :frames 4
:nodes {:root {:id :root :kind :group :z "a1"}
:mark {:id :mark :kind :rect :parent :root
:z "a1" :pose-group :mark
:channels {[:geom :size]
{:animated? true :interp :hold
:keys {0 2 1 3 2 4 3 5}
:pose-sampled? true}}}}})
(pose/put-cut :placed :mark 2 3))
cuts (get-in document [:timelines :main :nodes :placed :playback :tracks :mark])]
(is (= {2 3} cuts))
(is (= 1 (pose/source-frame (pose/prepare {:mark cuts}) :mark 1 1))
"before the first cut, dense motion continues")
(is (= 3 (pose/source-frame (pose/prepare {:mark cuts}) :mark 2 2)))
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))
(is (nil? (get-in (pose/remove-cut document :placed :mark 2)
[:timelines :main :nodes :placed :playback :tracks :mark])))
(is (seq (clip/problems (assoc-in document
[:timelines :main :nodes :placed :playback :tracks :mark]
{4 3}))))))
(defn- uuid-of ;; ---- structure ----
"The uuid the layout authors for the placement whose handle is `id`.
Read out of `stage/layout` rather than written here as a literal: what this test (deftest depth-order-puts-every-node-after-its-parent
is about is the mapping `compose` performs, and nine copied uuids would assert (let [s (sc {:id :a :kind :group :z "a1"}
that someone copied them correctly." {:id :b :kind :group :parent :a :z "a1"}
[id] {:id :c :kind :group :parent :b :z "a1"}
(or (->> (concat (:instances stage/layout) (:audio stage/layout)) {:id :d :kind :group :parent :a :z "a2"})
(some (fn [p] (when (= id (:id p)) (:uuid p))))) ord (symbol/order (:nodes s))]
(throw (ex-info "no such placement in the layout" {:id id})))) (is (= 0 (symbol/depth (:nodes s) :a)))
(is (= 2 (symbol/depth (:nodes s) :c)))
(let [pos (into {} (map-indexed (fn [i id] [id i])) ord)]
(doseq [[id p] [[:b :a] [:c :b] [:d :a]]]
(is (< (get pos p) (get pos id)) (str p " must come before " id))))))
(defn- placement (deftest a-parent-cycle-throws-instead-of-hanging
"The composed node for the placement the layout calls `id`." ;; Reachable from one bad :node/set-parent, and a hung tab is a far worse
[document id] ;; diagnostic than a stack trace naming the nodes.
(get-in document [:timelines :main :nodes (uuid-of id)])) (let [s (sc {:id :a :kind :group :parent :b :z "a1"}
{:id :b :kind :group :parent :a :z "a1"})]
(is (thrown-with-msg? ExceptionInfo #"cycle" (symbol/order (:nodes s))))
(is (seq (symbol/problems s)))))
(deftest stage-fixture-keeps-source-as-one-symbol (deftest a-missing-parent-is-named-rather-than-silently-orphaning
(let [document (stage/compose source)] (let [s (sc {:id :a :kind :group :parent :nope :z "a1"})]
(is (empty? (clip/problems document))) (is (seq (symbol/problems s)))))
(is (= #{:main :sym/face-8625} (set (keys (:timelines document)))))
(is (= :sym/face-8625 (:of (placement document :left)))) (deftest reparenting-is-one-field-and-does-not-move-a-subtree
(is (= :sym/face-8625 (:of (placement document :right)))) ;; The flat-with-pointers claim, asserted as the thing it buys: a reparent is an
(testing "every placement is keyed by its own uuid" ;; assoc-in at one node, and nothing else in the map changes identity — which is
;; The identity change: seven placements of one drawing are seven things, ;; what keeps re-frame's ancestor subs from invalidating.
;; and each is named by something that means only itself. Sharing a key, or (let [s (sc {:id :a :kind :group :z "a1"}
;; keying by a description of where a thing sits, is what this rules out. {:id :b :kind :group :z "a2" :channels {[:xform :pos] (ch/framed [100 0])}}
(let [symbols (filter (comp #{:symbol} :kind val) (poly :c :a "a1" [0 0 10 0 10 10] :brow))
(get-in document [:timelines :main :nodes]))] s' (assoc-in s [:nodes :c :parent] :b)]
(is (= 7 (count symbols))) (is (identical? (get-in s [:nodes :a]) (get-in s' [:nodes :a]))
(is (every? uuid? (map key symbols))) "the old parent is the same object")
(is (= 7 (count (distinct (map key symbols))))) (is (identical? (get-in s [:nodes :b]) (get-in s' [:nodes :b]))
(testing "and each still says which drawing it plays and what to call it" "and so is the new one")
(is (every? #(= :sym/face-8625 (:of (val %))) symbols)) (is (= [[0 0] [10 0] [10 10]]
(is (every? #(string? (:name (val %))) symbols)) (pts-of (first (filter #(= :c (:node %)) (symbol/eval-frame s 0))))))
(is (= 7 (count (distinct (map #(:name (val %)) symbols)))))))) (is (= [[100 0] [110 0] [110 10]]
(is (= 7 (count (filter #(= :symbol (:kind %)) (pts-of (first (filter #(= :c (:node %)) (symbol/eval-frame s' 0))))))))
(vals (get-in document [:timelines :main :nodes]))))))
(is (= [48 280] (:span (placement document :right)))) ;; ---- draw order ----
(let [left (placement document :left)
scale (get-in left [:channels [:xform :scale]]) (deftest draw-order-is-depth-first-by-sibling-z
anchor (get-in left [:channels [:xform :anchor] :value]) ;; z is a fractional index among siblings, so the sort key is the chain of z
pos (get-in left [:channels [:xform :pos]]) ;; values from the root. A parent's chain is a PREFIX of its child's, which is
start-pos (ch/value-at pos 0)] ;; why a parent draws before its children without that being a special case.
(is (= [160 100] anchor) "the source center becomes a stored pivot") (let [s (sc {:id :root :kind :group :z "a1"}
(is (= [-120 -60] start-pos)) (poly :under :root "a0" [0 0 1 0 1 1] :bg)
(is (not= start-pos (ch/value-at pos 40)) "the face drifts during playback") {:id :mid :kind :group :parent :root :z "a1"}
(is (= [0.4 0.4] (ch/value-at scale 0))) (poly :deep :mid "a5" [0 0 1 0 1 1] :brow)
(is (= [0.56 0.56] (ch/value-at scale 12))) (poly :over :root "a2" [0 0 1 0 1 1] :teeth))]
(is (= [0.52 0.52] (ch/value-at scale 48))) (is (= [:under :deep :over] (ids-at s 0)))))
(doseq [f [0 12 48]]
(let [m (node/local! (node/mat) start-pos 0 (ch/value-at scale f) [0 0] anchor) (deftest a-deep-child-of-an-early-sibling-still-draws-before-a-later-sibling
out (js/Float64Array. 2)] ;; The failure this guards: comparing z paths with `compare` would compare
(node/apply-pt! out 0 m 160 100) ;; COUNT first, so a painted cel three levels under "a1" would jump in front of
(is (= [40 40] [(aget out 0) (aget out 1)]) ;; a bare "a2". It reads as a layer order that mostly works.
"the face center stays put while it scales")))) (let [s (sc {:id :root :kind :group :z "a1"}
(testing "the editorial link resolves to the placement's uuid" {:id :g1 :kind :group :parent :root :z "a1"}
;; The EDN names `:right`; the document must carry the identity, or the link {:id :g2 :kind :group :parent :g1 :z "a1"}
;; dangles the moment anything is renamed. `clip/problems` above checks it (poly :deep :g2 "a1" [0 0 1 0 1 1] :brow)
;; resolves to a node at all; this checks it resolves to the RIGHT one. (poly :shallow :root "a2" [0 0 1 0 1 1] :teeth))]
(is (= (uuid-of :right) (:linked-to (placement document :voice-right)))) (is (= [:deep :shallow] (ids-at s 0)))))
(is (uuid? (:linked-to (placement document :voice-right)))))
(is (= [48 260] (:span (placement document :voice-right)))) (deftest a-fractional-index-inserts-between-two-siblings-without-renumbering
(is (= 0.5 (ch/value-at (let [base (sc {:id :root :kind :group :z "a1"}
(get-in (placement document :voice-right) (poly :a :root "a1" [0 0 1 0 1 1] :bg)
[:channels [:audio :gain]]) 54))) (poly :c :root "a3" [0 0 1 0 1 1] :teeth))
(is (< -0.8 (ch/value-at with (assoc-in base [:nodes :b] (poly :b :root "a2" [0 0 1 0 1 1] :brow))]
(get-in (placement document :voice-right) (is (= [:a :c] (ids-at base 0)))
[:channels [:audio :pan]]) 110) 0.7)) (is (= [:a :b :c] (ids-at with 0)))
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document)))))) (is (= (get-in base [:nodes :a]) (get-in with [:nodes :a])) "and :a is untouched")))
;; ---- transform composition through the tree ----
(deftest geometry-lands-in-the-parents-space
(let [s (sc {:id :g :kind :group :z "a1"
:channels {[:xform :pos] (ch/framed [100 50])
[:xform :scale] (ch/framed [2 2])}}
(poly :p :g "a1" [0 0 10 0 10 10 0 10] :skin-base))
op (first (symbol/eval-frame s 0))]
(is (= [[100 50] [120 50] [120 70] [100 70]] (pts-of op)))))
(deftest a-keyed-group-position-moves-its-children-and-holds-between-keys
;; This is the scene the plan asks for, minimally: a rectangle parented to a
;; group whose [:xform :pos] is keyed on four frames.
(let [s (sc {:id :g :kind :group :z "a1"
:channels {[:xform :pos]
(ch/keyed {0 [0 0], 4 [10 0], 8 [10 10], 12 [0 10]})}}
(poly :p :g "a1" [0 0 2 0 2 2] :skin-base))
at #(first (pts-of (first (symbol/eval-frame s %))))]
(is (= [0 0] (at 0)))
(is (= [0 0] (at 3)) "held")
(is (= [10 0] (at 4)))
(is (= [10 10] (at 8)))
(is (= [0 10] (at 12)))
(is (= [0 10] (at 99)) "and holds the last key")))
;; ---- time maps compose along the chain ----
(deftest exposure-on-the-root-is-inherited-by-everything-under-it
;; docs/design.md is emphatic that everything rides ONE grid: a head cutting on
;; odd frames against a mouth cutting on even ones reads as two performances.
(let [s (sc {:id :root :kind :group :z "a1" :time {:mode :map :expose 3}}
{:id :g :kind :group :parent :root :z "a1"
:channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)))}}
(poly :p :g "a1" [0 0 1 0 1 1] :skin-base))
x-at #(first (first (pts-of (first (symbol/eval-frame s %)))))]
(is (= [0 0 0 3 3 3 6 6 6 9 9 9] (mapv x-at (range 12)))))
(testing "and a node may set its own grid, which the model permits deliberately"
(let [s (sc {:id :root :kind :group :z "a1" :time {:mode :map :expose 2}}
{:id :g :kind :group :parent :root :z "a1" :time {:mode :map :expose 4}
:channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)))}}
(poly :p :g "a1" [0 0 1 0 1 1] :skin-base))
x-at #(first (first (pts-of (first (symbol/eval-frame s %)))))]
(is (= [0 0 0 0 4 4 4 4 8 8 8 8] (mapv x-at (range 12)))))))
(deftest offset-is-per-node-which-is-the-entire-point-of-mouth-lead
;; Lead applies to performance nodes and NOT to the plate. If it were a clip
;; property the mouth would drag the whole head forward with it.
(let [keys (into {} (map (juxt identity #(vector % 0))) (range 12))
s (sc {:id :root :kind :group :z "a1"}
{:id :plate :kind :group :parent :root :z "a1"
:channels {[:xform :pos] (ch/keyed keys)}}
(poly :plate-p :plate "a1" [0 0 1 0 1 1] :skin-base)
{:id :mouth :kind :group :parent :root :z "a2" :time {:mode :map :offset 2}
:channels {[:xform :pos] (ch/keyed keys)}}
(poly :mouth-p :mouth "a1" [0 0 1 0 1 1] :mouth-dark))
x-of (fn [f id] (->> (symbol/eval-frame s f)
(filter #(= id (:node %))) first pts-of first first))]
(is (= [0 1 2 3] (mapv #(x-of % :plate-p) (range 4))))
(is (= [2 3 4 5] (mapv #(x-of % :mouth-p) (range 4))) "the mouth reads ahead")))
;; ---- span and visibility are different questions ----
(deftest span-removes-a-node-and-vis-switches-it-off
;; :span is Lottie's ip/op and Flash's PlaceObject/RemoveObject: the range over
;; which the node EXISTS. [:vis] blinks an existing node on and off. Conflating
;; them is how you end up with a part that holds a stale pose outside its range.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :p :root "a1" [0 0 1 0 1 1] :brow
{:span [2 5]
:channels {[:geom :pts] (ch/framed [0 0 1 0 1 1])
[:style :color] (ch/framed :brow)
[:vis] (ch/keyed {0 true, 3 false, 4 true})}}))]
(is (= [[] [] [:p] [] [:p] [] []] (mapv #(ids-at s %) (range 7))))))
(deftest a-hidden-group-takes-its-children-with-it
(let [s (sc {:id :g :kind :group :z "a1"
:channels {[:vis] (ch/keyed {0 true, 2 false})}}
(poly :p :g "a1" [0 0 1 0 1 1] :brow))]
(is (= [:p] (ids-at s 0)))
(is (= [] (ids-at s 2)))))
(deftest an-absent-transform-drops-the-subtree-and-an-absent-geometry-does-not
;; The asymmetry is the whole reason presence is tracked per CHANNEL rather than
;; per node. An absent mouth outline has nothing to draw, but the head it hangs
;; off is still exactly where it was.
(let [state (js/Uint8Array. #js [ch/present ch/absent-bit])
store {"pos" {:data (js/Float32Array. #js [0 0, 0 0]) :state state}
"pts" {:data (js/Int16Array. #js [0 0 1 0 1 1, 0 0 1 0 1 1]) :state state}}
absent-pos (sc {:id :g :kind :group :z "a1"
:channels {[:xform :pos] {:animated? true
:dense {:store "pos" :offset 0 :stride 2 :frames 2}}}}
(poly :child :g "a1" [0 0 1 0 1 1] :brow))
absent-pts (sc {:id :g :kind :group :z "a1"}
{:id :m :kind :poly :parent :g :z "a1"
:channels {[:geom :pts] {:animated? true
:dense {:store "pts" :offset 0 :stride 6 :frames 2}}
[:style :color] (ch/framed :mouth-dark)}}
(poly :teeth :m "a2" [0 0 1 0 1 1] :teeth))]
(is (= [:child] (mapv :node (symbol/eval-frame absent-pos 0 store))))
(is (= [] (mapv :node (symbol/eval-frame absent-pos 1 store)))
"an absent transform gives the children nowhere to be")
(is (= [:m :teeth] (mapv :node (symbol/eval-frame absent-pts 0 store))))
(is (= [:teeth] (mapv :node (symbol/eval-frame absent-pts 1 store)))
"an absent outline removes only itself")))
;; ---- stencils ----
(deftest a-stencil-resolves-to-the-stencil-nodes-palette-index
;; A stencil is a COLOUR KEY, not a node reference — the take format's clip= —
;; and the indexed buffer being its own clip mask is what keeps the iris inside
;; the eye at any gaze and any radius with no clamp anywhere.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :sclera :root "a1" [0 0 10 0 10 10] :eye-white)
{:id :iris :kind :disc :parent :root :stencil :sclera :z "a2"
:channels {[:geom :radius] (ch/framed 4)
[:style :color] (ch/framed :iris)}})
ops (symbol/eval-frame s 0)]
(is (= [:sclera :iris] (mapv :node ops)))
(is (= (:eye-white pal/index-of) (:stencil (second ops))))))
(deftest a-node-stencilled-by-something-that-drew-nothing-is-dropped
;; Unclipped would be an iris floating over the cheek on exactly the frames
;; where the eye is missing, which is worse than a missing iris.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :sclera :root "a1" [0 0 10 0 10 10] :eye-white
{:channels {[:geom :pts] (ch/framed [0 0 10 0 10 10])
[:style :color] (ch/framed :eye-white)
[:vis] (ch/keyed {0 true, 1 false})}})
{:id :iris :kind :disc :parent :root :stencil :sclera :z "a2"
:channels {[:geom :radius] (ch/framed 4)
[:style :color] (ch/framed :iris)}})]
(is (= [:sclera :iris] (ids-at s 0)))
(is (= [] (ids-at s 1)))))
;; ---- discs and rects ----
(deftest disc-and-rect-extents-retain-precision-for-enclosing-instances
(let [s (sc {:id :g :kind :group :z "a1"
:channels {[:xform :pos] (ch/framed [50 60]) [:xform :scale] (ch/framed [2 2])}}
{:id :d :kind :disc :parent :g :z "a1"
:channels {[:geom :radius] (ch/framed 3) [:style :color] (ch/framed :iris)}}
{:id :r :kind :rect :parent :g :z "a2"
:channels {[:geom :size] (ch/framed 1.7) [:style :color] (ch/framed :pupil)}})
[d r] (symbol/eval-frame s 0)]
(is (= [50 60 6] [(:cx d) (:cy d) (:r d)]))
(is (= 3.4 (:size r)))))
;; ---- the fast path and the specification agree ----
(deftest the-resolver-agrees-with-eval-frame-in-any-frame-order
;; THE assertion of this step. The resolver caches the topological order and the
;; z paths, holds a cursor per channel and reuses one point buffer per node, and
;; every one of those is a way to be subtly wrong on some frames and not others
;; — which presents as a bad take rather than as an error.
;; The frame orders and the snapshot live in `arthur.support.ops`, because the
;; same comparison is what proves a scene survived the server — see
;; flow/project-test.
(let [s demo/main
spec (ops/specified s nil)
fast (ops/resolved s nil)]
(doseq [[label fs] (ops/orders (:frames s))]
(testing label
(doseq [f fs]
(is (= (spec f) (fast f)) (str label " at frame " f)))))))
(deftest the-resolver-reuses-one-buffer-per-node
;; At 30fps per-frame allocation is the only thing that will make this stutter,
;; and fixed topology is what makes the buffer size knowable at all.
(let [res (symbol/resolver demo/main)
buf-of (fn [f id] (->> (res f) (filter #(= id (:node %))) first :pts))]
(is (identical? (buf-of 0 :card) (buf-of 30 :card)))))
;; ---- the hand-written scene, end to end ----
(deftest the-hand-written-clip-is-valid
;; `clip/problems` rather than `symbol/problems`: it checks the clip's fields,
;; the timeline map and the tracking identities as well as the nodes, so it is
;; the check a save would make.
(let [ps (clip/problems demo/clip)]
(is (empty? ps) (pr-str ps)))
(is (pos? demo/frames))
(testing "a clip is not a symbol, and handing one over fails loudly"
;; The mistake this split makes easy: both are maps with an :id, and the wrong
;; one resolves to no ops rather than to an error.
(is (thrown-with-msg? ExceptionInfo #"not a symbol"
(symbol/resolver demo/clip)))
(is (thrown-with-msg? ExceptionInfo #"not a symbol"
(symbol/eval-frame demo/clip 0)))))
(deftest the-hand-written-clip-renders-and-moves
;; port-plan step 2's done condition, as an assertion rather than a look: the
;; scene rasterises, it writes only palette indices, and the pixels are not the
;; same on every frame.
(let [res (symbol/resolver demo/main)
render (fn [f]
(let [r (raster/make (:width demo/clip) (:height demo/clip))]
(raster/clear! r (:bg pal/index-of))
(raster/draw-ops! r (res f))
r))
frames (mapv render (range 0 demo/frames 6))
sig (fn [r] (vec (array-seq (:buf r))))]
(is (every? (fn [r] (every? #(< % (count pal/rgb)) (array-seq (:buf r)))) frames)
"every byte written is a real palette index")
(is (> (count (distinct (map sig frames))) 1) "something moves")
(testing "the mark actually covers pixels"
(is (pos? (count (remove zero? (sig (first frames)))))))))
(deftest the-hand-written-clip-steps-on-the-exposure-grid
;; Exposure 2 on the clip root, inherited, so odd frames are identical to the
;; even frame before them. If this fails, exposure is being applied somewhere
;; other than the frame the channels are sampled at.
(let [res (symbol/resolver demo/main)
render (fn [f]
(let [r (raster/make (:width demo/clip) (:height demo/clip))]
(raster/clear! r (:bg pal/index-of))
(raster/draw-ops! r (res f))
(vec (array-seq (:buf r)))))]
(doseq [f (range 0 demo/frames 2)]
(is (= (render f) (render (inc f))) (str "frame " (inc f) " must hold frame " f)))
;; Two grid slots that straddle a key, not two adjacent ones: between keys
;; nothing changes, because that is what hold MEANS. The scene's second key
;; is at 57, and exposure 2 floors that onto 58 — which is itself the
;; expose-before-anything-else rule showing up in pixels.
(is (not= (render 56) (render 58)) "and a key on the grid is seen")))
(deftest the-hand-written-clip-keeps-the-iris-and-pupil-inside-the-card
;; The stencil chain, on real pixels: the iris is clipped by the card and the
;; pupil by the iris, and neither is expressed anywhere as a chain.
(let [res (symbol/resolver demo/main)]
(doseq [f (range 0 demo/frames 4)]
(let [before (raster/make (:width demo/clip) (:height demo/clip))
after (raster/make (:width demo/clip) (:height demo/clip))
ops (res f)
card? (fn [op] (= :card (:node op)))]
(raster/clear! before (:bg pal/index-of))
(raster/draw-ops! before (filter card? ops))
(raster/clear! after (:bg pal/index-of))
(raster/draw-ops! after ops)
(let [ci (:skin-base pal/index-of)
card (set (for [i (range (alength (:buf before)))
:when (= ci (aget (:buf before) i))]
i))
eye (set (for [i (range (alength (:buf after)))
:when (#{(:iris pal/index-of) (:pupil pal/index-of)}
(aget (:buf after) i))]
i))]
(is (pos? (count eye)) (str "frame " f ": the iris drew something"))
(is (empty? (remove card eye))
(str "frame " f ": " (count (remove card eye)) " pixels outside the card")))))))
;; ---- the palette is a parameter, not a global ----
(deftest the-same-scene-resolves-differently-under-a-different-ramp
;; A node names a TONE; which ramp that tone is read in belongs to the timeline
;; it sits in. So resolution must not reach for one ambient answer — the same
;; drawing has to read day or night without a stored value changing, which is
;; the entire payoff of indexed colour.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :p :root "a1" [0 0 10 0 10 10] :skin-base))
day {:skin-base 1}
night {:skin-base 17}]
(is (= 1 (:color (first (symbol/eval-frame s 0 nil day)))))
(is (= 17 (:color (first (symbol/eval-frame s 0 nil night)))))
(is (= 17 (:color (first ((symbol/resolver s nil night) 0))))
"and the playback path agrees")))
(deftest a-tone-the-ramp-does-not-define-is-loudly-wrong
;; 255 renders magenta. Naming a colour the ramp has no entry for is a bug in
;; authored data and should be impossible to miss.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :p :root "a1" [0 0 10 0 10 10] :skin-base))]
(is (= 255 (:color (first (symbol/eval-frame s 0 nil {})))))))
(deftest partitioning-the-index-space-stops-two-palettes-colliding-on-a-stencil
;; A stencil is a colour key, so two nodes sharing a tone share a stencil —
;; a real weakness of the technique. Concatenating the named palettes into one
;; index space means two nodes in DIFFERENT palettes cannot collide at all.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :sclera :root "a1" [0 0 20 0 20 20] :eye-white)
{:id :iris :kind :disc :parent :root :stencil :sclera :z "a2"
:channels {[:geom :radius] (ch/framed 4)
[:style :color] (ch/framed :iris)}})
;; :night's tones sit above :day's in one concatenated space
night {:eye-white 14 :iris 15}
ops (symbol/eval-frame s 0 nil night)]
(is (= 14 (:stencil (second ops)))
"the stencil resolves to the index the stencil node actually drew in")))

View file

@ -1,397 +0,0 @@
(ns arthur.domain.timeline-test
"Frame evaluation, and the hand-written scene.
port-plan step 2 exists to find out whether the data model works BEFORE nine
hundred lines of measurement are ported into it, so these assertions are about
the model's claims rather than about a look: that structure is flat and
addressable, that draw order is authored, that time maps compose, that
presence and visibility are different questions, and that the fast path and the
specification give the same frame."
(:require [cljs.test :refer [deftest is testing]]
[arthur.demo :as demo]
[arthur.domain.channel :as ch]
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]
[arthur.domain.raster :as raster]
[arthur.domain.clip :as clip]
[arthur.domain.timeline :as timeline]
[arthur.support.ops :as ops]))
(defn- poly [id parent z pts color & [extra]]
(merge {:id id :kind :poly :parent parent :z z
:channels {[:geom :pts] (ch/framed pts)
[:style :color] (ch/framed color)}}
extra))
(defn- sc [& nodes]
{:nodes (into {} (map (juxt :id identity)) nodes)})
(defn- ids-at [scene f]
(mapv :node (timeline/eval-frame scene f)))
(def ^:private pts-of ops/points)
;; ---- structure ----
(deftest depth-order-puts-every-node-after-its-parent
(let [s (sc {:id :a :kind :group :z "a1"}
{:id :b :kind :group :parent :a :z "a1"}
{:id :c :kind :group :parent :b :z "a1"}
{:id :d :kind :group :parent :a :z "a2"})
ord (timeline/order (:nodes s))]
(is (= 0 (timeline/depth (:nodes s) :a)))
(is (= 2 (timeline/depth (:nodes s) :c)))
(let [pos (into {} (map-indexed (fn [i id] [id i])) ord)]
(doseq [[id p] [[:b :a] [:c :b] [:d :a]]]
(is (< (get pos p) (get pos id)) (str p " must come before " id))))))
(deftest a-parent-cycle-throws-instead-of-hanging
;; Reachable from one bad :node/set-parent, and a hung tab is a far worse
;; diagnostic than a stack trace naming the nodes.
(let [s (sc {:id :a :kind :group :parent :b :z "a1"}
{:id :b :kind :group :parent :a :z "a1"})]
(is (thrown-with-msg? ExceptionInfo #"cycle" (timeline/order (:nodes s))))
(is (seq (timeline/problems s)))))
(deftest a-missing-parent-is-named-rather-than-silently-orphaning
(let [s (sc {:id :a :kind :group :parent :nope :z "a1"})]
(is (seq (timeline/problems s)))))
(deftest reparenting-is-one-field-and-does-not-move-a-subtree
;; The flat-with-pointers claim, asserted as the thing it buys: a reparent is an
;; assoc-in at one node, and nothing else in the map changes identity — which is
;; what keeps re-frame's ancestor subs from invalidating.
(let [s (sc {:id :a :kind :group :z "a1"}
{:id :b :kind :group :z "a2" :channels {[:xform :pos] (ch/framed [100 0])}}
(poly :c :a "a1" [0 0 10 0 10 10] :brow))
s' (assoc-in s [:nodes :c :parent] :b)]
(is (identical? (get-in s [:nodes :a]) (get-in s' [:nodes :a]))
"the old parent is the same object")
(is (identical? (get-in s [:nodes :b]) (get-in s' [:nodes :b]))
"and so is the new one")
(is (= [[0 0] [10 0] [10 10]]
(pts-of (first (filter #(= :c (:node %)) (timeline/eval-frame s 0))))))
(is (= [[100 0] [110 0] [110 10]]
(pts-of (first (filter #(= :c (:node %)) (timeline/eval-frame s' 0))))))))
;; ---- draw order ----
(deftest draw-order-is-depth-first-by-sibling-z
;; z is a fractional index among siblings, so the sort key is the chain of z
;; values from the root. A parent's chain is a PREFIX of its child's, which is
;; why a parent draws before its children without that being a special case.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :under :root "a0" [0 0 1 0 1 1] :bg)
{:id :mid :kind :group :parent :root :z "a1"}
(poly :deep :mid "a5" [0 0 1 0 1 1] :brow)
(poly :over :root "a2" [0 0 1 0 1 1] :teeth))]
(is (= [:under :deep :over] (ids-at s 0)))))
(deftest a-deep-child-of-an-early-sibling-still-draws-before-a-later-sibling
;; The failure this guards: comparing z paths with `compare` would compare
;; COUNT first, so a painted cel three levels under "a1" would jump in front of
;; a bare "a2". It reads as a layer order that mostly works.
(let [s (sc {:id :root :kind :group :z "a1"}
{:id :g1 :kind :group :parent :root :z "a1"}
{:id :g2 :kind :group :parent :g1 :z "a1"}
(poly :deep :g2 "a1" [0 0 1 0 1 1] :brow)
(poly :shallow :root "a2" [0 0 1 0 1 1] :teeth))]
(is (= [:deep :shallow] (ids-at s 0)))))
(deftest a-fractional-index-inserts-between-two-siblings-without-renumbering
(let [base (sc {:id :root :kind :group :z "a1"}
(poly :a :root "a1" [0 0 1 0 1 1] :bg)
(poly :c :root "a3" [0 0 1 0 1 1] :teeth))
with (assoc-in base [:nodes :b] (poly :b :root "a2" [0 0 1 0 1 1] :brow))]
(is (= [:a :c] (ids-at base 0)))
(is (= [:a :b :c] (ids-at with 0)))
(is (= (get-in base [:nodes :a]) (get-in with [:nodes :a])) "and :a is untouched")))
;; ---- transform composition through the tree ----
(deftest geometry-lands-in-the-parents-space
(let [s (sc {:id :g :kind :group :z "a1"
:channels {[:xform :pos] (ch/framed [100 50])
[:xform :scale] (ch/framed [2 2])}}
(poly :p :g "a1" [0 0 10 0 10 10 0 10] :skin-base))
op (first (timeline/eval-frame s 0))]
(is (= [[100 50] [120 50] [120 70] [100 70]] (pts-of op)))))
(deftest a-keyed-group-position-moves-its-children-and-holds-between-keys
;; This is the scene the plan asks for, minimally: a rectangle parented to a
;; group whose [:xform :pos] is keyed on four frames.
(let [s (sc {:id :g :kind :group :z "a1"
:channels {[:xform :pos]
(ch/keyed {0 [0 0], 4 [10 0], 8 [10 10], 12 [0 10]})}}
(poly :p :g "a1" [0 0 2 0 2 2] :skin-base))
at #(first (pts-of (first (timeline/eval-frame s %))))]
(is (= [0 0] (at 0)))
(is (= [0 0] (at 3)) "held")
(is (= [10 0] (at 4)))
(is (= [10 10] (at 8)))
(is (= [0 10] (at 12)))
(is (= [0 10] (at 99)) "and holds the last key")))
;; ---- time maps compose along the chain ----
(deftest exposure-on-the-root-is-inherited-by-everything-under-it
;; docs/design.md is emphatic that everything rides ONE grid: a head cutting on
;; odd frames against a mouth cutting on even ones reads as two performances.
(let [s (sc {:id :root :kind :group :z "a1" :time {:mode :map :expose 3}}
{:id :g :kind :group :parent :root :z "a1"
:channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)))}}
(poly :p :g "a1" [0 0 1 0 1 1] :skin-base))
x-at #(first (first (pts-of (first (timeline/eval-frame s %)))))]
(is (= [0 0 0 3 3 3 6 6 6 9 9 9] (mapv x-at (range 12)))))
(testing "and a node may set its own grid, which the model permits deliberately"
(let [s (sc {:id :root :kind :group :z "a1" :time {:mode :map :expose 2}}
{:id :g :kind :group :parent :root :z "a1" :time {:mode :map :expose 4}
:channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)))}}
(poly :p :g "a1" [0 0 1 0 1 1] :skin-base))
x-at #(first (first (pts-of (first (timeline/eval-frame s %)))))]
(is (= [0 0 0 0 4 4 4 4 8 8 8 8] (mapv x-at (range 12)))))))
(deftest offset-is-per-node-which-is-the-entire-point-of-mouth-lead
;; Lead applies to performance nodes and NOT to the plate. If it were a clip
;; property the mouth would drag the whole head forward with it.
(let [keys (into {} (map (juxt identity #(vector % 0))) (range 12))
s (sc {:id :root :kind :group :z "a1"}
{:id :plate :kind :group :parent :root :z "a1"
:channels {[:xform :pos] (ch/keyed keys)}}
(poly :plate-p :plate "a1" [0 0 1 0 1 1] :skin-base)
{:id :mouth :kind :group :parent :root :z "a2" :time {:mode :map :offset 2}
:channels {[:xform :pos] (ch/keyed keys)}}
(poly :mouth-p :mouth "a1" [0 0 1 0 1 1] :mouth-dark))
x-of (fn [f id] (->> (timeline/eval-frame s f)
(filter #(= id (:node %))) first pts-of first first))]
(is (= [0 1 2 3] (mapv #(x-of % :plate-p) (range 4))))
(is (= [2 3 4 5] (mapv #(x-of % :mouth-p) (range 4))) "the mouth reads ahead")))
;; ---- span and visibility are different questions ----
(deftest span-removes-a-node-and-vis-switches-it-off
;; :span is Lottie's ip/op and Flash's PlaceObject/RemoveObject: the range over
;; which the node EXISTS. [:vis] blinks an existing node on and off. Conflating
;; them is how you end up with a part that holds a stale pose outside its range.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :p :root "a1" [0 0 1 0 1 1] :brow
{:span [2 5]
:channels {[:geom :pts] (ch/framed [0 0 1 0 1 1])
[:style :color] (ch/framed :brow)
[:vis] (ch/keyed {0 true, 3 false, 4 true})}}))]
(is (= [[] [] [:p] [] [:p] [] []] (mapv #(ids-at s %) (range 7))))))
(deftest a-hidden-group-takes-its-children-with-it
(let [s (sc {:id :g :kind :group :z "a1"
:channels {[:vis] (ch/keyed {0 true, 2 false})}}
(poly :p :g "a1" [0 0 1 0 1 1] :brow))]
(is (= [:p] (ids-at s 0)))
(is (= [] (ids-at s 2)))))
(deftest an-absent-transform-drops-the-subtree-and-an-absent-geometry-does-not
;; The asymmetry is the whole reason presence is tracked per CHANNEL rather than
;; per node. An absent mouth outline has nothing to draw, but the head it hangs
;; off is still exactly where it was.
(let [state (js/Uint8Array. #js [ch/present ch/absent-bit])
store {"pos" {:data (js/Float32Array. #js [0 0, 0 0]) :state state}
"pts" {:data (js/Int16Array. #js [0 0 1 0 1 1, 0 0 1 0 1 1]) :state state}}
absent-pos (sc {:id :g :kind :group :z "a1"
:channels {[:xform :pos] {:animated? true
:dense {:store "pos" :offset 0 :stride 2 :frames 2}}}}
(poly :child :g "a1" [0 0 1 0 1 1] :brow))
absent-pts (sc {:id :g :kind :group :z "a1"}
{:id :m :kind :poly :parent :g :z "a1"
:channels {[:geom :pts] {:animated? true
:dense {:store "pts" :offset 0 :stride 6 :frames 2}}
[:style :color] (ch/framed :mouth-dark)}}
(poly :teeth :m "a2" [0 0 1 0 1 1] :teeth))]
(is (= [:child] (mapv :node (timeline/eval-frame absent-pos 0 store))))
(is (= [] (mapv :node (timeline/eval-frame absent-pos 1 store)))
"an absent transform gives the children nowhere to be")
(is (= [:m :teeth] (mapv :node (timeline/eval-frame absent-pts 0 store))))
(is (= [:teeth] (mapv :node (timeline/eval-frame absent-pts 1 store)))
"an absent outline removes only itself")))
;; ---- stencils ----
(deftest a-stencil-resolves-to-the-stencil-nodes-palette-index
;; A stencil is a COLOUR KEY, not a node reference — the take format's clip= —
;; and the indexed buffer being its own clip mask is what keeps the iris inside
;; the eye at any gaze and any radius with no clamp anywhere.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :sclera :root "a1" [0 0 10 0 10 10] :eye-white)
{:id :iris :kind :disc :parent :root :stencil :sclera :z "a2"
:channels {[:geom :radius] (ch/framed 4)
[:style :color] (ch/framed :iris)}})
ops (timeline/eval-frame s 0)]
(is (= [:sclera :iris] (mapv :node ops)))
(is (= (:eye-white pal/index-of) (:stencil (second ops))))))
(deftest a-node-stencilled-by-something-that-drew-nothing-is-dropped
;; Unclipped would be an iris floating over the cheek on exactly the frames
;; where the eye is missing, which is worse than a missing iris.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :sclera :root "a1" [0 0 10 0 10 10] :eye-white
{:channels {[:geom :pts] (ch/framed [0 0 10 0 10 10])
[:style :color] (ch/framed :eye-white)
[:vis] (ch/keyed {0 true, 1 false})}})
{:id :iris :kind :disc :parent :root :stencil :sclera :z "a2"
:channels {[:geom :radius] (ch/framed 4)
[:style :color] (ch/framed :iris)}})]
(is (= [:sclera :iris] (ids-at s 0)))
(is (= [] (ids-at s 1)))))
;; ---- discs and rects ----
(deftest disc-and-rect-extents-retain-precision-for-enclosing-instances
(let [s (sc {:id :g :kind :group :z "a1"
:channels {[:xform :pos] (ch/framed [50 60]) [:xform :scale] (ch/framed [2 2])}}
{:id :d :kind :disc :parent :g :z "a1"
:channels {[:geom :radius] (ch/framed 3) [:style :color] (ch/framed :iris)}}
{:id :r :kind :rect :parent :g :z "a2"
:channels {[:geom :size] (ch/framed 1.7) [:style :color] (ch/framed :pupil)}})
[d r] (timeline/eval-frame s 0)]
(is (= [50 60 6] [(:cx d) (:cy d) (:r d)]))
(is (= 3.4 (:size r)))))
;; ---- the fast path and the specification agree ----
(deftest the-resolver-agrees-with-eval-frame-in-any-frame-order
;; THE assertion of this step. The resolver caches the topological order and the
;; z paths, holds a cursor per channel and reuses one point buffer per node, and
;; every one of those is a way to be subtly wrong on some frames and not others
;; — which presents as a bad take rather than as an error.
;; The frame orders and the snapshot live in `arthur.support.ops`, because the
;; same comparison is what proves a scene survived the server — see
;; flow/project-test.
(let [s demo/timeline
spec (ops/specified s nil)
fast (ops/resolved s nil)]
(doseq [[label fs] (ops/orders (:frames s))]
(testing label
(doseq [f fs]
(is (= (spec f) (fast f)) (str label " at frame " f)))))))
(deftest the-resolver-reuses-one-buffer-per-node
;; At 30fps per-frame allocation is the only thing that will make this stutter,
;; and fixed topology is what makes the buffer size knowable at all.
(let [res (timeline/resolver demo/timeline)
buf-of (fn [f id] (->> (res f) (filter #(= id (:node %))) first :pts))]
(is (identical? (buf-of 0 :card) (buf-of 30 :card)))))
;; ---- the hand-written scene, end to end ----
(deftest the-hand-written-clip-is-valid
;; `clip/problems` rather than `timeline/problems`: it checks the clip's fields,
;; the timeline map and the tracking identities as well as the nodes, so it is
;; the check a save would make.
(let [ps (clip/problems demo/clip)]
(is (empty? ps) (pr-str ps)))
(is (pos? demo/frames))
(testing "a clip is not a timeline, and handing one over fails loudly"
;; The mistake this split makes easy: both are maps with an :id, and the wrong
;; one resolves to no ops rather than to an error.
(is (thrown-with-msg? ExceptionInfo #"not a timeline"
(timeline/resolver demo/clip)))
(is (thrown-with-msg? ExceptionInfo #"not a timeline"
(timeline/eval-frame demo/clip 0)))))
(deftest the-hand-written-clip-renders-and-moves
;; port-plan step 2's done condition, as an assertion rather than a look: the
;; scene rasterises, it writes only palette indices, and the pixels are not the
;; same on every frame.
(let [res (timeline/resolver demo/timeline)
render (fn [f]
(let [r (raster/make (:width demo/clip) (:height demo/clip))]
(raster/clear! r (:bg pal/index-of))
(raster/draw-ops! r (res f))
r))
frames (mapv render (range 0 demo/frames 6))
sig (fn [r] (vec (array-seq (:buf r))))]
(is (every? (fn [r] (every? #(< % (count pal/rgb)) (array-seq (:buf r)))) frames)
"every byte written is a real palette index")
(is (> (count (distinct (map sig frames))) 1) "something moves")
(testing "the mark actually covers pixels"
(is (pos? (count (remove zero? (sig (first frames)))))))))
(deftest the-hand-written-clip-steps-on-the-exposure-grid
;; Exposure 2 on the clip root, inherited, so odd frames are identical to the
;; even frame before them. If this fails, exposure is being applied somewhere
;; other than the frame the channels are sampled at.
(let [res (timeline/resolver demo/timeline)
render (fn [f]
(let [r (raster/make (:width demo/clip) (:height demo/clip))]
(raster/clear! r (:bg pal/index-of))
(raster/draw-ops! r (res f))
(vec (array-seq (:buf r)))))]
(doseq [f (range 0 demo/frames 2)]
(is (= (render f) (render (inc f))) (str "frame " (inc f) " must hold frame " f)))
;; Two grid slots that straddle a key, not two adjacent ones: between keys
;; nothing changes, because that is what hold MEANS. The scene's second key
;; is at 57, and exposure 2 floors that onto 58 — which is itself the
;; expose-before-anything-else rule showing up in pixels.
(is (not= (render 56) (render 58)) "and a key on the grid is seen")))
(deftest the-hand-written-clip-keeps-the-iris-and-pupil-inside-the-card
;; The stencil chain, on real pixels: the iris is clipped by the card and the
;; pupil by the iris, and neither is expressed anywhere as a chain.
(let [res (timeline/resolver demo/timeline)]
(doseq [f (range 0 demo/frames 4)]
(let [before (raster/make (:width demo/clip) (:height demo/clip))
after (raster/make (:width demo/clip) (:height demo/clip))
ops (res f)
card? (fn [op] (= :card (:node op)))]
(raster/clear! before (:bg pal/index-of))
(raster/draw-ops! before (filter card? ops))
(raster/clear! after (:bg pal/index-of))
(raster/draw-ops! after ops)
(let [ci (:skin-base pal/index-of)
card (set (for [i (range (alength (:buf before)))
:when (= ci (aget (:buf before) i))]
i))
eye (set (for [i (range (alength (:buf after)))
:when (#{(:iris pal/index-of) (:pupil pal/index-of)}
(aget (:buf after) i))]
i))]
(is (pos? (count eye)) (str "frame " f ": the iris drew something"))
(is (empty? (remove card eye))
(str "frame " f ": " (count (remove card eye)) " pixels outside the card")))))))
;; ---- the palette is a parameter, not a global ----
(deftest the-same-scene-resolves-differently-under-a-different-ramp
;; A node names a TONE; which ramp that tone is read in belongs to the timeline
;; it sits in. So resolution must not reach for one ambient answer — the same
;; drawing has to read day or night without a stored value changing, which is
;; the entire payoff of indexed colour.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :p :root "a1" [0 0 10 0 10 10] :skin-base))
day {:skin-base 1}
night {:skin-base 17}]
(is (= 1 (:color (first (timeline/eval-frame s 0 nil day)))))
(is (= 17 (:color (first (timeline/eval-frame s 0 nil night)))))
(is (= 17 (:color (first ((timeline/resolver s nil night) 0))))
"and the playback path agrees")))
(deftest a-tone-the-ramp-does-not-define-is-loudly-wrong
;; 255 renders magenta. Naming a colour the ramp has no entry for is a bug in
;; authored data and should be impossible to miss.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :p :root "a1" [0 0 10 0 10 10] :skin-base))]
(is (= 255 (:color (first (timeline/eval-frame s 0 nil {})))))))
(deftest partitioning-the-index-space-stops-two-palettes-colliding-on-a-stencil
;; A stencil is a colour key, so two nodes sharing a tone share a stencil —
;; a real weakness of the technique. Concatenating the named palettes into one
;; index space means two nodes in DIFFERENT palettes cannot collide at all.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :sclera :root "a1" [0 0 20 0 20 20] :eye-white)
{:id :iris :kind :disc :parent :root :stencil :sclera :z "a2"
:channels {[:geom :radius] (ch/framed 4)
[:style :color] (ch/framed :iris)}})
;; :night's tones sit above :day's in one concatenated space
night {:eye-white 14 :iris 15}
ops (timeline/eval-frame s 0 nil night)]
(is (= 14 (:stencil (second ops)))
"the stencil resolves to the index the stencil node actually drew in")))

View file

@ -5,7 +5,7 @@
is NAMESPACED (`:sym/face-8625`) and a placement's is a UUID, and the panel puts is NAMESPACED (`:sym/face-8625`) and a placement's is a UUID, and the panel puts
both into `<option value>`s and reads them back out of a change event. Writing both into `<option value>`s and reads them back out of a change event. Writing
that value with `name` drops the `sym`, the id comes back `:face-8625`, it that value with `name` drops the `sym`, the id comes back `:face-8625`, it
matches no key in `:timelines`, and `export/run!` throws from inside re-frame's matches no key in `:symbols`, and `export/run!` throws from inside re-frame's
`:do-fx` where nothing catches it: `:busy?` latches on and the readout sits at `:do-fx` where nothing catches it: `:busy?` latches on and the readout sits at
\"frame 0 /\" forever with nothing in the status line. \"frame 0 /\" forever with nothing in the status line.
@ -17,12 +17,12 @@
(def ^:private a-uuid #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218") (def ^:private a-uuid #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218")
(deftest every-kind-of-target-survives-the-round-trip (deftest every-kind-of-target-survives-the-round-trip
(doseq [t [{:timeline :main} (doseq [t [{:symbol :main}
{:timeline :sym/face-8625} {:symbol :sym/face-8625}
{:timeline :main :isolate a-uuid} {:symbol :main :isolate a-uuid}
{:timeline :sym/face-8625 :isolate a-uuid}]] {:symbol :sym/face-8625 :isolate a-uuid}]]
(let [back (export/target-id (export/target-value t))] (let [back (export/target-id (export/target-value t))]
(is (= (:timeline t) (:timeline back)) (is (= (:symbol t) (:symbol back))
(str (pr-str t) " -> " (pr-str (export/target-value t)))) (str (pr-str t) " -> " (pr-str (export/target-value t))))
(is (= (:isolate t) (:isolate back))) (is (= (:isolate t) (:isolate back)))
(testing "and a placement comes back a uuid, not a string or a keyword" (testing "and a placement comes back a uuid, not a string or a keyword"
@ -31,15 +31,15 @@
(deftest the-value-keeps-the-namespace-and-marks-the-two-kinds (deftest the-value-keeps-the-namespace-and-marks-the-two-kinds
;; Spelled out, because these are the strings that end up in the DOM. ;; Spelled out, because these are the strings that end up in the DOM.
(is (= "t:main" (export/target-value {:timeline :main}))) (is (= "s:main" (export/target-value {:symbol :main})))
(is (= "t:sym/face-8625" (export/target-value {:timeline :sym/face-8625}))) (is (= "s:sym/face-8625" (export/target-value {:symbol :sym/face-8625})))
(is (= (str "n:main:" a-uuid) (is (= (str "n:main:" a-uuid)
(export/target-value {:timeline :main :isolate a-uuid})))) (export/target-value {:symbol :main :isolate a-uuid}))))
(deftest a-whole-timeline-has-no-isolate (deftest a-whole-symbol-has-no-isolate
;; Switching from a placement back to the clip must clear it, or the new target ;; Switching from a placement back to the clip must clear it, or the new target
;; would still be filtered down to one node that may not even be in it. ;; would still be filtered down to one node that may not even be in it.
(is (nil? (:isolate (export/target-id "t:main"))))) (is (nil? (:isolate (export/target-id "s:main")))))
(deftest the-old-encoding-is-the-bug (deftest the-old-encoding-is-the-bug
;; A guard against someone "simplifying" this back to `name`. `name` is lossy on ;; A guard against someone "simplifying" this back to `name`. `name` is lossy on
@ -55,37 +55,37 @@
"A clip with one symbol in its library, placed twice, plus a decoy: a node that "A clip with one symbol in its library, placed twice, plus a decoy: a node that
is not a symbol must not show up as a face." is not a symbol must not show up as a face."
{:fps 30 :width 320 :height 200 {:fps 30 :width 320 :height 200
:timelines :symbols
{:main {:frames 280 {:main {:frames 280
:nodes {:root {:id :root :kind :group :z "a1"} :nodes {:root {:id :root :kind :group :z "a1"}
#uuid "22222222-2222-4222-8222-222222222222" #uuid "22222222-2222-4222-8222-222222222222"
{:id #uuid "22222222-2222-4222-8222-222222222222" {:id #uuid "22222222-2222-4222-8222-222222222222"
:kind :symbol :of :sym/face :parent :root :z "a2" :kind :instance :of :sym/face :parent :root :z "a2"
:name "8625 right"} :name "8625 right"}
#uuid "11111111-1111-4111-8111-111111111111" #uuid "11111111-1111-4111-8111-111111111111"
{:id #uuid "11111111-1111-4111-8111-111111111111" {:id #uuid "11111111-1111-4111-8111-111111111111"
:kind :symbol :of :sym/face :parent :root :z "a1" :kind :instance :of :sym/face :parent :root :z "a1"
:name "8625 left"} :name "8625 left"}
:a-rect {:id :a-rect :kind :rect :parent :root :z "a3"}}} :a-rect {:id :a-rect :kind :rect :parent :root :z "a3"}}}
:sym/face {:frames 40 :nodes {:root {:id :root :kind :group :z "a1"}}}}}) :sym/face {:frames 40 :nodes {:root {:id :root :kind :group :z "a1"}}}}})
(deftest the-picker-offers-the-clip-the-drawing-and-every-placement (deftest the-picker-offers-every-symbol-then-every-instance-in-the-open-one
(let [ts (export/targets clip)] (let [ts (export/targets clip :main)]
(is (= ["main (the clip)" "face" "8625 left" "8625 right"] (mapv :label ts)) (is (= ["main" "face" "8625 left" "8625 right"] (mapv :label ts))
"the clip, then the library, then the placements") "every symbol, then the instances in the open one")
(testing "the placements isolate a node on :main and the library ones do not" (testing "the instances isolate a node in the open symbol and the symbols do not"
(is (= [nil nil] (mapv :isolate (take 2 ts)))) (is (= [nil nil] (mapv :isolate (take 2 ts))))
(is (every? uuid? (mapv :isolate (drop 2 ts)))) (is (every? uuid? (mapv :isolate (drop 2 ts))))
(is (every? #(= :main (:timeline %)) (drop 2 ts)))) (is (every? #(= :main (:symbol %)) (drop 2 ts))))
(testing "ordered by label, because a uuid sorts at random" (testing "ordered by label, because a uuid sorts at random"
(is (= ["8625 left" "8625 right"] (mapv :label (drop 2 ts))))) (is (= ["8625 left" "8625 right"] (mapv :label (drop 2 ts)))))
(testing "and a node that is not a symbol is not a placement" (testing "and a node that is not an instance is not offered"
(is (not-any? #{"a-rect"} (map :label ts)))))) (is (not-any? #{"a-rect"} (map :label ts))))))
(deftest every-offered-target-round-trips (deftest every-offered-target-round-trips
;; The picker and the encoding asserted against each other, so neither can drift ;; The picker and the encoding asserted against each other, so neither can drift
;; into offering something that cannot be selected. ;; into offering something that cannot be selected.
(doseq [t (export/targets clip)] (doseq [t (export/targets clip :main)]
(let [norm #(merge {:isolate nil} (select-keys % [:timeline :isolate])) (let [norm #(merge {:isolate nil} (select-keys % [:symbol :isolate]))
back (export/target-id (export/target-value t))] back (export/target-id (export/target-value t))]
(is (= (norm t) (norm back)) (pr-str t))))) (is (= (norm t) (norm back)) (pr-str t)))))

View file

@ -144,7 +144,7 @@
(done))) (done)))
(.catch (fn [e] (is false (str "threw: " e)) (done)))))) (.catch (fn [e] (is false (str "threw: " e)) (done))))))
(deftest a-silent-timeline-produces-no-wav (deftest a-silent-symbol-produces-no-wav
(async done (async done
(-> (run-default! {:n 2 :audio nil}) (-> (run-default! {:n 2 :audio nil})
(.then (fn [{:keys [entries]}] (.then (fn [{:keys [entries]}]

View file

@ -24,7 +24,7 @@
{:id id :kind :poly :z z {:id id :kind :poly :z z
:channels {[:geom :pts] (ch/framed pts) [:style :color] (ch/framed color)}}) :channels {[:geom :pts] (ch/framed pts) [:style :color] (ch/framed color)}})
(defn- a-timeline (defn- a-symbol
"One authored square under a `:root` group. Picture sampling now applies to "One authored square under a `:root` group. Picture sampling now applies to
marked generated channels in the shared resolver, leaving this square alone." marked generated channels in the shared resolver, leaving this square alone."
[frames] [frames]
@ -37,7 +37,7 @@
matters is its frame space — so it is the smallest thing that resolves to an op." matters is its frame space — so it is the smallest thing that resolves to an op."
[{:keys [frames fps w h] :or {frames 10 fps 24 w 8 h 6}}] [{:keys [frames fps w h] :or {frames 10 fps 24 w 8 h 6}}]
{:name "t" :fps fps :width w :height h {:name "t" :fps fps :width w :height h
:timelines {clip/root-id (a-timeline frames)}}) :symbols {:main (a-symbol frames)}})
(defn- recorder (defn- recorder
"An `Exporter` that records the calls rather than encoding anything. "An `Exporter` that records the calls rather than encoding anything.
@ -57,7 +57,7 @@
"Run an export over `clip`, returning a promise of the recorded log." "Run an export over `clip`, returning a promise of the recorded log."
[clip & {:as opts}] [clip & {:as opts}]
(let [log (atom {})] (let [log (atom {})]
(-> (export/run! (merge {:clip clip :timeline clip/root-id :store {} (-> (export/run! (merge {:clip clip :symbol :main :store {}
:palette pal/index-of :ramp pal/rgb :zoom 1 :palette pal/index-of :ramp pal/rgb :zoom 1
:name "t"} :name "t"}
opts) opts)
@ -68,7 +68,7 @@
;; ---- plan ---- ;; ---- plan ----
(deftest plan-reports-what-the-export-will-be (deftest plan-reports-what-the-export-will-be
(let [p (export/plan {:clip (a-clip {:frames 48 :fps 24 :w 320 :h 200}) :timeline clip/root-id :zoom 3})] (let [p (export/plan {:clip (a-clip {:frames 48 :fps 24 :w 320 :h 200}) :symbol :main :zoom 3})]
(is (= 48 (:frames p))) (is (= 48 (:frames p)))
(is (= 24 (:fps p))) (is (= 24 (:fps p)))
(is (= 3 (:zoom p))) (is (= 3 (:zoom p)))
@ -78,7 +78,7 @@
(deftest the-zoom-is-an-integer-of-at-least-one (deftest the-zoom-is-an-integer-of-at-least-one
;; Anything else resamples, and a zoom of 0 would be a zero-byte picture. ;; Anything else resamples, and a zoom of 0 would be a zero-byte picture.
(let [zoom-of #(:zoom (export/plan {:clip (a-clip {}) :timeline clip/root-id :zoom %}))] (let [zoom-of #(:zoom (export/plan {:clip (a-clip {}) :symbol :main :zoom %}))]
(is (= 2 (zoom-of 2.7)) "truncated, not rounded") (is (= 2 (zoom-of 2.7)) "truncated, not rounded")
(is (= 1 (zoom-of 0))) (is (= 1 (zoom-of 0)))
(is (= 1 (zoom-of -4))) (is (= 1 (zoom-of -4)))
@ -90,19 +90,19 @@
;; 12fps picture rate it is still 48 frames and two seconds, holding 24 poses. ;; 12fps picture rate it is still 48 frames and two seconds, holding 24 poses.
;; If :frames ever tracks :poses here, every export at a reduced picture rate ;; If :frames ever tracks :poses here, every export at a reduced picture rate
;; comes out half length with the audio sliding off it. ;; comes out half length with the audio sliding off it.
(let [p (export/plan {:clip (a-clip {:frames 48 :fps 24}) :timeline clip/root-id (let [p (export/plan {:clip (a-clip {:frames 48 :fps 24}) :symbol :main
:picture-fps 12})] :picture-fps 12})]
(is (= 48 (:frames p)) "the frame count does not move") (is (= 48 (:frames p)) "the frame count does not move")
(is (= 2 (:seconds p)) "and neither does the duration") (is (= 2 (:seconds p)) "and neither does the duration")
(is (= 24 (:poses p)) "but the picture holds half as many poses")) (is (= 24 (:poses p)) "but the picture holds half as many poses"))
(testing "a picture rate at or above the clip's rate changes nothing" (testing "a picture rate at or above the clip's rate changes nothing"
(doseq [fps [24 48 nil]] (doseq [fps [24 48 nil]]
(let [p (export/plan {:clip (a-clip {:frames 48 :fps 24}) :timeline clip/root-id (let [p (export/plan {:clip (a-clip {:frames 48 :fps 24}) :symbol :main
:picture-fps fps})] :picture-fps fps})]
(is (= 48 (:poses p)) (str "picture-fps " fps)))))) (is (= 48 (:poses p)) (str "picture-fps " fps))))))
(deftest plan-of-a-timeline-that-is-not-there-is-nothing (deftest plan-of-a-symbol-that-is-not-there-is-nothing
(is (nil? (export/plan {:clip (a-clip {}) :timeline :nope :zoom 1})))) (is (nil? (export/plan {:clip (a-clip {}) :symbol :nope :zoom 1}))))
;; ---- the walk ---- ;; ---- the walk ----
@ -176,25 +176,25 @@
(done))) (done)))
(.catch (fn [e] (is false (str "threw: " e)) (done)))))) (.catch (fn [e] (is false (str "threw: " e)) (done))))))
(deftest exporting-a-timeline-that-is-not-there-is-an-error (deftest exporting-a-symbol-that-is-not-there-is-an-error
;; And it names the timelines that ARE there, because the id came from a UI and ;; And it names the timelines that ARE there, because the id came from a UI and
;; "no such timeline" alone does not say what went wrong. ;; "no such timeline" alone does not say what went wrong.
(let [thrown (try (export/run! {:clip (a-clip {}) :timeline :nope :store {} (let [thrown (try (export/run! {:clip (a-clip {}) :symbol :nope :store {}
:palette pal/index-of :ramp pal/rgb} :palette pal/index-of :ramp pal/rgb}
(recorder (atom {})) nil) (recorder (atom {})) nil)
nil nil
(catch :default e e))] (catch :default e e))]
(is (some? thrown) "it throws rather than resolving to an empty archive") (is (some? thrown) "it throws rather than resolving to an empty archive")
(is (= [clip/root-id] (:timelines (ex-data thrown)))))) (is (= [:main] (:symbols (ex-data thrown))))))
(deftest a-symbol-is-exported-by-being-rooted-at-its-own-frame-space (deftest a-symbol-is-exported-by-being-rooted-at-its-own-frame-space
;; "Render that symbol" is rooting the resolver at it, so the walk's length is ;; "Render that symbol" is rooting the resolver at it, so the walk's length is
;; the SYMBOL's frame count and not the clip's. ;; the SYMBOL's frame count and not the clip's.
(async done (async done
(let [c (assoc-in (a-clip {:frames 30}) (let [c (assoc-in (a-clip {:frames 30})
[:timelines :sym] [:symbols :sym]
(a-timeline 4))] (a-symbol 4))]
(-> (run!* c :timeline :sym) (-> (run!* c :symbol :sym)
(.then (fn [{:keys [frames spec]}] (.then (fn [{:keys [frames spec]}]
(is (= 4 (count frames)) "the symbol's four frames, not the clip's 30") (is (= 4 (count frames)) "the symbol's four frames, not the clip's 30")
(is (= 4 (:frames spec))) (is (= 4 (:frames spec)))
@ -218,23 +218,23 @@
it needs no clock." it needs no clock."
[& {:keys [voice?]}] [& {:keys [voice?]}]
{:name "stage" :fps 30 :width 8 :height 6 {:name "stage" :fps 30 :width 8 :height 6
:timelines :symbols
{clip/root-id {:main
{:frames 12 {:frames 12
:nodes (cond-> {:root {:id :root :kind :group :z "a1"} :nodes (cond-> {:root {:id :root :kind :group :z "a1"}
p1 {:id p1 :kind :symbol :of :sym/face :parent :root :z "a1" p1 {:id p1 :kind :instance :of :sym/face :parent :root :z "a1"
:name "left" :channels {[:xform :pos] (ch/framed [0 0])}} :name "left" :channels {[:xform :pos] (ch/framed [0 0])}}
p2 {:id p2 :kind :symbol :of :sym/face :parent :root :z "a2" p2 {:id p2 :kind :instance :of :sym/face :parent :root :z "a2"
:name "right" :channels {[:xform :pos] (ch/framed [4 0])}} :name "right" :channels {[:xform :pos] (ch/framed [4 0])}}
:loose (assoc (poly :loose "a4" [0 0 1 0 1 1] :brow) :loose (assoc (poly :loose "a4" [0 0 1 0 1 1] :brow)
:parent :root)} :parent :root)}
voice? (assoc v1 {:id v1 :kind :audio :parent :root :z "a3" voice? (assoc v1 {:id v1 :kind :audio :parent :root :z "a3"
:linked-to p1 :source {:footage "f"} :span [0 12]}))} :linked-to p1 :source {:footage "f"} :span [0 12]}))}
:sym/face (a-timeline 6)}}) :sym/face (a-symbol 6)}})
(deftest isolating-keeps-the-placement-its-chain-and-its-voice (deftest isolating-keeps-the-placement-its-chain-and-its-voice
(let [tl (clip/timeline (staged :voice? true) clip/root-id) (let [sym (clip/symbol (staged :voice? true) :main)
kept (set (keys (:nodes (export/isolate tl p1))))] kept (set (keys (:nodes (export/isolate sym p1))))]
(is (contains? kept p1) "the placement itself") (is (contains? kept p1) "the placement itself")
(is (contains? kept :root) "and the root it hangs from, or it would move") (is (contains? kept :root) "and the root it hangs from, or it would move")
(is (contains? kept v1) "and the voice linked to it") (is (contains? kept v1) "and the voice linked to it")
@ -246,24 +246,24 @@
(deftest isolating-the-other-placement-drops-the-first-s-voice (deftest isolating-the-other-placement-drops-the-first-s-voice
;; The voice is linked to p1, so isolating p2 must not carry it: an isolated ;; The voice is linked to p1, so isolating p2 must not carry it: an isolated
;; export that kept every track would have the whole stage's sound over one face. ;; export that kept every track would have the whole stage's sound over one face.
(let [tl (clip/timeline (staged :voice? true) clip/root-id) (let [sym (clip/symbol (staged :voice? true) :main)
kept (set (keys (:nodes (export/isolate tl p2))))] kept (set (keys (:nodes (export/isolate sym p2))))]
(is (= #{:root p2} kept)))) (is (= #{:root p2} kept))))
(deftest isolating-nothing-leaves-the-timeline-alone (deftest isolating-nothing-leaves-the-symbol-alone
(let [tl (clip/timeline (staged :voice? true) clip/root-id)] (let [sym (clip/symbol (staged :voice? true) :main)]
(is (= tl (export/isolate tl nil))) (is (= sym (export/isolate sym nil)))
(testing "and so does isolating a node that is not there" (testing "and so does isolating a node that is not there"
(is (= tl (export/isolate tl (random-uuid))))))) (is (= sym (export/isolate sym (random-uuid)))))))
(deftest isolating-keeps-the-frame-space (deftest isolating-keeps-the-frame-space
;; What makes this different from exporting the symbol the placement plays: the ;; What makes this different from exporting the symbol the placement plays: the
;; STAGE's length and rate are what comes out, not the drawing's own. ;; STAGE's length and rate are what comes out, not the drawing's own.
(let [c (staged)] (let [c (staged)]
(is (= 12 (:frames (export/plan {:clip c :timeline clip/root-id :isolate p1})))) (is (= 12 (:frames (export/plan {:clip c :symbol :main :isolate p1}))))
(is (= 6 (:frames (export/plan {:clip c :timeline :sym/face}))) (is (= 6 (:frames (export/plan {:clip c :symbol :sym/face})))
"the drawing's own frame space is its own") "the drawing's own frame space is its own")
(is (= 30 (:fps (export/plan {:clip c :timeline clip/root-id :isolate p1})))))) (is (= 30 (:fps (export/plan {:clip c :symbol :main :isolate p1}))))))
(deftest an-isolated-walk-emits-the-stage-s-frames (deftest an-isolated-walk-emits-the-stage-s-frames
(async done (async done

View file

@ -5,7 +5,7 @@
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.landmarks :as lm] [arthur.domain.landmarks :as lm]
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.timeline :as timeline] [arthur.domain.symbol :as symbol]
[arthur.flow.condition.eyes :as condition-eyes] [arthur.flow.condition.eyes :as condition-eyes]
[arthur.flow.ingest :as ingest] [arthur.flow.ingest :as ingest]
[arthur.flow.measure.eyes :as eyes] [arthur.flow.measure.eyes :as eyes]
@ -50,7 +50,7 @@
(assoc take/params :aspect 1 :name "observed-gap") (assoc take/params :aspect 1 :name "observed-gap")
{:face-1 {:dense @take/analysis :presence presence}}) {:face-1 {:dense @take/analysis :presence presence}})
sample (fn [id frame] sample (fn [id frame]
(ch/value-at (get-in (:nodes (clip/timeline clip :face-1)) [id :channels [:geom :pts]]) (ch/value-at (get-in (:nodes (clip/symbol clip :face-1)) [id :channels [:geom :pts]])
frame store))] frame store))]
(is (empty? (clip/problems clip))) (is (empty? (clip/problems clip)))
(is (= [:face-1/eye-r :face-1/eye-l] (get-in clip [:groups :face-1/eyes :members]))) (is (= [:face-1/eye-r :face-1/eye-l] (get-in clip [:groups :face-1/eyes :members])))
@ -59,7 +59,7 @@
(is (not (ch/nothing? (sample :eye-l f)))) (is (not (ch/nothing? (sample :eye-l f))))
(is (not (ch/nothing? (sample :mouth f))))) (is (not (ch/nothing? (sample :mouth f)))))
(let [drawn (into #{} (map :node) (let [drawn (into #{} (map :node)
((timeline/resolver (clip/timeline clip :face-1) store pal/index-of) 11))] ((symbol/resolver (clip/symbol clip :face-1) store pal/index-of) 11))]
(is (not (contains? drawn :eye-r))) (is (not (contains? drawn :eye-r)))
(is (not (contains? drawn :iris-r))) (is (not (contains? drawn :iris-r)))
(is (contains? drawn :eye-l)) (is (contains? drawn :eye-l))

View file

@ -18,7 +18,7 @@
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.raster :as raster] [arthur.domain.raster :as raster]
[arthur.domain.ring :as ring] [arthur.domain.ring :as ring]
[arthur.domain.timeline :as timeline] [arthur.domain.symbol :as symbol]
[arthur.flow.freeze :as freeze])) [arthur.flow.freeze :as freeze]))
(def ^:private W 320) (def ^:private W 320)
@ -32,9 +32,9 @@
(def clip* (delay (:clip @frozen))) (def clip* (delay (:clip @frozen)))
;; Geometry assertions read the face timeline. Rendering assertions resolve ;; Geometry assertions read the face timeline. Rendering assertions resolve
;; the whole clip, including placement and inherited exposure. ;; the whole clip, including placement and inherited exposure.
(defn- face-timeline [c] (clip/timeline c :face-1)) (defn- face-symbol [c] (clip/symbol c :face-1))
(defn- nodes [c] (merge (clip/nodes c) (:nodes (face-timeline c)))) (defn- nodes [c] (merge (:nodes (clip/symbol c :main)) (:nodes (face-symbol c))))
(def tl* (delay (face-timeline @clip*))) (def sym* (delay (face-symbol @clip*)))
(def store (delay (:store @frozen))) (def store (delay (:store @frozen)))
(defn- node [id] (get (nodes @clip*) id)) (defn- node [id] (get (nodes @clip*) id))
@ -53,8 +53,8 @@
(defn- ops-at (defn- ops-at
"Ops for one frame of a TIMELINE." "Ops for one frame of a TIMELINE."
[tl f] [sym f]
((timeline/resolver tl @store pal/index-of) f)) ((symbol/resolver sym @store pal/index-of) f))
(defn- render (defn- render
"One frame of a CLIP into a byte buffer. The stage's size comes off the clip and "One frame of a CLIP into a byte buffer. The stage's size comes off the clip and
@ -62,7 +62,7 @@
[c f] [c f]
(let [r (raster/make (:width c) (:height c))] (let [r (raster/make (:width c) (:height c))]
(raster/clear! r (get pal/index-of :bg)) (raster/clear! r (get pal/index-of :bg))
(raster/draw-ops! r ((clip/resolver c @store pal/index-of) f)) (raster/draw-ops! r ((clip/resolver c @store pal/index-of :main) f))
(vec (array-seq (:buf r))))) (vec (array-seq (:buf r)))))
(defn- drawn (defn- drawn
@ -86,11 +86,11 @@
(is (empty? (clip/problems c)) (pr-str (clip/problems c))))) (is (empty? (clip/problems c)) (pr-str (clip/problems c)))))
(deftest the-tree-is-the-one-the-model-specifies (deftest the-tree-is-the-one-the-model-specifies
(is (= [:face :root] (timeline/lineage (clip/nodes @clip*) :face))) (is (= [:face :root] (symbol/lineage (:nodes (clip/symbol @clip* :main)) :face)))
(is (= :face-1 (get-in @clip* [:timelines :main :nodes :face-1 :of]))) (is (= :face-1 (get-in @clip* [:symbols :main :nodes :face-1 :of])))
(is (= [:head] (timeline/lineage (:nodes @tl*) :head))) (is (= [:head] (symbol/lineage (:nodes @sym*) :head)))
(is (= [:mouth :head] (timeline/lineage (:nodes @tl*) :mouth))) (is (= [:mouth :head] (symbol/lineage (:nodes @sym*) :mouth)))
(is (= [:mouth-in :mouth :head] (timeline/lineage (:nodes @tl*) :mouth-in))) (is (= [:mouth-in :mouth :head] (symbol/lineage (:nodes @sym*) :mouth-in)))
(is (= {:mode :map :expose 2} (:time (node :root)))) (is (= {:mode :map :expose 2} (:time (node :root))))
(is (every? #(nil? (:time (node %))) [:face :head :mouth :mouth-in]))) (is (every? #(nil? (:time (node %))) [:face :head :mouth :mouth-in])))
@ -195,7 +195,7 @@
;; checking arithmetic against itself; this checks `node/local!`, `node/world!` ;; checking arithmetic against itself; this checks `node/local!`, `node/world!`
;; and `emit` as well. ;; and `emit` as well.
(let [c (freeze/head-mode {:mode :free} @frozen) (let [c (freeze/head-mode {:mode :free} @frozen)
res (clip/resolver c @store pal/index-of) res (clip/resolver c @store pal/index-of :main)
k (first (:value (chan :face [:xform :scale]))) k (first (:value (chan :face [:xform :scale])))
anc (:value (chan :face [:xform :anchor])) anc (:value (chan :face [:xform :anchor]))
pos (:value (chan :face [:xform :pos])) pos (:value (chan :face [:xform :pos]))
@ -252,16 +252,16 @@
"anchor source addresses survive the document round trip"))) "anchor source addresses survive the document round trip")))
(deftest head-anchor-keys-hold-the-whole-measured-transform (deftest head-anchor-keys-hold-the-whole-measured-transform
(let [free (timeline/resolver (face-timeline (let [free (symbol/resolver (face-symbol
(freeze/head-mode {:mode :free} @frozen)) (freeze/head-mode {:mode :free} @frozen))
@store pal/index-of) @store pal/index-of)
held (timeline/resolver (face-timeline held (symbol/resolver (face-symbol
(freeze/head-mode {:mode :anchored (freeze/head-mode {:mode :anchored
:anchors {0 12, 40 88}} @frozen)) :anchors {0 12, 40 88}} @frozen))
@store pal/index-of) @store pal/index-of)
world (fn [resolver frame] world (fn [resolver frame]
(resolver frame) (resolver frame)
(vec (array-seq (timeline/world-of resolver :head))))] (vec (array-seq (symbol/world-of resolver :head))))]
(is (= (world free 12) (world held 0))) (is (= (world free 12) (world held 0)))
(is (= (world free 12) (world held 38))) (is (= (world free 12) (world held 38)))
(is (= (world free 88) (world held 40))) (is (= (world free 88) (world held 40)))
@ -285,7 +285,7 @@
(get-in (nodes x) [:head :measured]))) (get-in (nodes x) [:head :measured])))
;; And nothing above the timeline moved either: the toggle is one node's ;; And nothing above the timeline moved either: the toggle is one node's
;; channels, so the clip's own fields and its other timelines are untouched. ;; channels, so the clip's own fields and its other timelines are untouched.
(is (= (dissoc a :timelines) (dissoc x :timelines)))))) (is (= (dissoc a :symbols) (dissoc x :symbols))))))
(deftest invalid-head-anchor-maps-are-refused (deftest invalid-head-anchor-maps-are-refused
(is (thrown-with-msg? ExceptionInfo #"free or anchored" (is (thrown-with-msg? ExceptionInfo #"free or anchored"
@ -371,7 +371,7 @@
;; interior comes and goes. ;; interior comes and goes.
(is (nil? (chan :mouth [:vis]))) (is (nil? (chan :mouth [:vis])))
(doseq [f (range 0 take/frames 9)] (doseq [f (range 0 take/frames 9)]
(is (some #(= :mouth (:node %)) (ops-at @tl* f)) (is (some #(= :mouth (:node %)) (ops-at @sym* f))
(str "frame " f " drew no mouth outline")))) (str "frame " f " drew no mouth outline"))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
@ -403,8 +403,8 @@
(let [strip-node (fn [n] (let [strip-node (fn [n]
(update n :channels (update n :channels
#(into {} (map (fn [[p c]] [p (dissoc c :generated)])) %))) #(into {} (map (fn [[p c]] [p (dissoc c :generated)])) %)))
stripped (clip/update-root stripped (clip/update-symbol
@clip* @clip* :main
update :nodes update :nodes
#(into {} (map (fn [[id n]] [id (strip-node n)])) %))] #(into {} (map (fn [[id n]] [id (strip-node n)])) %))]
(doseq [f (range 0 take/frames 17)] (doseq [f (range 0 take/frames 17)]
@ -419,9 +419,9 @@
presence {:eye-r (mapv #(not (contains? gap %)) (range take/frames))} presence {:eye-r (mapv #(not (contains? gap %)) (range take/frames))}
c (freeze/clip (assoc take/params :name "one-eye-gappy") c (freeze/clip (assoc take/params :name "one-eye-gappy")
{:face-1 (assoc @take/measured :presence presence)}) {:face-1 (assoc @take/measured :presence presence)})
tl (face-timeline (:clip c)) sym (face-symbol (:clip c))
at (fn [id f] at (fn [id f]
(ch/value-at (get-in (:nodes tl) [id :channels [:geom :pts]]) f (:store c)))] (ch/value-at (get-in (:nodes sym) [id :channels [:geom :pts]]) f (:store c)))]
;; The identities are the CLIP's, and a gap does not touch them: a feature keeps ;; The identities are the CLIP's, and a gap does not touch them: a feature keeps
;; its id and its pair membership across the frames it was not observed on. ;; its id and its pair membership across the frames it was not observed on.
(is (= [:face-1/eye-r :face-1/eye-l] (get-in (:clip c) [:groups :face-1/eyes :members]))) (is (= [:face-1/eye-r :face-1/eye-l] (get-in (:clip c) [:groups :face-1/eyes :members])))
@ -431,7 +431,7 @@
(is (not (ch/nothing? (at :eye-r 60)))) (is (not (ch/nothing? (at :eye-r 60))))
(is (not (ch/nothing? (at :eye-l 50)))) (is (not (ch/nothing? (at :eye-l 50))))
(is (not (ch/nothing? (at :mouth 50)))) (is (not (ch/nothing? (at :mouth 50))))
(let [drawn-nodes (into #{} (map :node) ((timeline/resolver tl (:store c) pal/index-of) 50))] (let [drawn-nodes (into #{} (map :node) ((symbol/resolver sym (:store c) pal/index-of) 50))]
(is (not (contains? drawn-nodes :eye-r))) (is (not (contains? drawn-nodes :eye-r)))
(is (contains? drawn-nodes :eye-l)) (is (contains? drawn-nodes :eye-l))
(is (contains? drawn-nodes :mouth))) (is (contains? drawn-nodes :mouth)))
@ -457,9 +457,9 @@
windows) windows)
c (freeze/clip (assoc take/params :name "per-track-gaps") c (freeze/clip (assoc take/params :name "per-track-gaps")
{:face-1 (assoc @take/measured :presence presence)}) {:face-1 (assoc @take/measured :presence presence)})
tl (face-timeline (:clip c)) sym (face-symbol (:clip c))
at (fn [id path f] at (fn [id path f]
(ch/value-at (get-in (:nodes tl) [id :channels path]) f (:store c))) (ch/value-at (get-in (:nodes sym) [id :channels path]) f (:store c)))
;; Node, channel, and the feature whose gap it must follow. Every dense ;; Node, channel, and the feature whose gap it must follow. Every dense
;; track of the eye, iris, brow and brow-position blocks appears once. ;; track of the eye, iris, brow and brow-position blocks appears once.
tracks [[:eye-r [:geom :pts] :eye-r] tracks [[:eye-r [:geom :pts] :eye-r]
@ -485,7 +485,7 @@
(deftest teeth-follow-the-mouth-through-their-stencil-and-not-through-a-mask (deftest teeth-follow-the-mouth-through-their-stencil-and-not-through-a-mask
;; Teeth are their own feature, so that they can carry their own :area :teeth ;; Teeth are their own feature, so that they can carry their own :area :teeth
;; parameters — which means an occluded MOUTH sets no absence bit on them. They ;; parameters — which means an occluded MOUTH sets no absence bit on them. They
;; do not need one: they are stencilled by :mouth-in, and timeline/finish drops a ;; do not need one: they are stencilled by :mouth-in, and symbol/finish drops a
;; node whose stencil drew nothing. So the coupling is real, and it is the ;; node whose stencil drew nothing. So the coupling is real, and it is the
;; stencil rule rather than the mask that enforces it. The rule itself is ;; stencil rule rather than the mask that enforces it. The rule itself is
;; asserted in timeline-test; what is pinned here is the wiring that relies on it. ;; asserted in timeline-test; what is pinned here is the wiring that relies on it.
@ -506,7 +506,7 @@
;; Hoisted: the resolver caches its order and reuses its buffers, so the ;; Hoisted: the resolver caches its order and reuses its buffers, so the
;; node ids come out before the next frame is asked for. ;; node ids come out before the next frame is asked for.
nodes-at (fn [c] nodes-at (fn [c]
(let [r (timeline/resolver (face-timeline (:clip c)) (:store c) pal/index-of)] (let [r (symbol/resolver (face-symbol (:clip c)) (:store c) pal/index-of)]
(fn [f] (into #{} (map :node) (r f))))) (fn [f] (into #{} (map :node) (r f)))))
ref-at (nodes-at ref) ref-at (nodes-at ref)
occ-at (nodes-at occ) occ-at (nodes-at occ)
@ -542,8 +542,8 @@
det (mapv #(not (contains? gap %)) (range take/frames)) det (mapv #(not (contains? gap %)) (range take/frames))
c (freeze/clip (assoc take/params :name "gappy") c (freeze/clip (assoc take/params :name "gappy")
{:face-1 (assoc @take/measured :detected det)}) {:face-1 (assoc @take/measured :detected det)})
tl (face-timeline (:clip c)) sym (face-symbol (:clip c))
res (timeline/resolver tl (:store c) pal/index-of)] res (symbol/resolver sym (:store c) pal/index-of)]
(doseq [f [39 40 50 59 60]] (doseq [f [39 40 50 59 60]]
(let [ops (res f)] (let [ops (res f)]
(if (contains? gap f) (if (contains? gap f)
@ -552,7 +552,7 @@
;; And it is the MASK doing it, not a hidden flag: `[:vis]` on :mouth-in is ;; And it is the MASK doing it, not a hidden flag: `[:vis]` on :mouth-in is
;; unchanged across the gap, because hiding and absence are different ;; unchanged across the gap, because hiding and absence are different
;; questions with different answers. ;; questions with different answers.
(is (= (mapv #(ch/value-at (get-in (:nodes tl) [:mouth-in :channels [:vis]]) %) (is (= (mapv #(ch/value-at (get-in (:nodes sym) [:mouth-in :channels [:vis]]) %)
(range take/frames)) (range take/frames))
(mapv #(ch/value-at (chan :mouth-in [:vis]) %) (range take/frames)))))) (mapv #(ch/value-at (chan :mouth-in [:vis]) %) (range take/frames))))))
@ -602,7 +602,7 @@
shot (fn [f] shot (fn [f]
(let [r (raster/make W H) (let [r (raster/make W H)
mouth (filter #(= [:face-1 :mouth] (:node %)) mouth (filter #(= [:face-1 :mouth] (:node %))
((clip/resolver locked @store pal/index-of) f))] ((clip/resolver locked @store pal/index-of :main) f))]
(raster/clear! r (get pal/index-of :bg)) (raster/clear! r (get pal/index-of :bg))
(raster/draw-ops! r mouth) (raster/draw-ops! r mouth)
(vec (array-seq (:buf r))))) (vec (array-seq (:buf r)))))

View file

@ -4,6 +4,7 @@
[arthur.domain.channel :as ch] [arthur.domain.channel :as ch]
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.feature :as feature] [arthur.domain.feature :as feature]
[arthur.domain.palette :as pal]
[arthur.domain.pose :as pose] [arthur.domain.pose :as pose]
[arthur.domain.project :as project] [arthur.domain.project :as project]
[arthur.events.footage :as footage] [arthur.events.footage :as footage]
@ -33,20 +34,20 @@
(delay (assoc (take/build settings @inputs) :source-inputs {:subjects @inputs}))) (delay (assoc (take/build settings @inputs) :source-inputs {:subjects @inputs})))
(defn channel [entry subject node path] (defn channel [entry subject node path]
(get-in entry [:clip :timelines subject :nodes node :channels path])) (get-in entry [:clip :symbols subject :nodes node :channels path]))
(defn snapshot [c store f] (ops/snapshot ((clip/resolver c store) f))) (defn snapshot [c store f] (ops/snapshot ((clip/resolver c store pal/index-of :main) f)))
(defn by-node [c store f] (into {} (map (juxt :node identity)) (snapshot c store f))) (defn by-node [c store f] (into {} (map (juxt :node identity)) (snapshot c store f)))
(deftest subjects-share-local-names-without-sharing-blocks (deftest subjects-share-local-names-without-sharing-blocks
(let [{:keys [clip store]} @initial (let [{:keys [clip store]} @initial
a (get-in clip [:timelines :face-1 :nodes]) a (get-in clip [:symbols :face-1 :nodes])
b (get-in clip [:timelines :face-2 :nodes])] b (get-in clip [:symbols :face-2 :nodes])]
(is (empty? (clip/problems clip))) (is (empty? (clip/problems clip)))
(is (= (set (keys a)) (set (keys b)))) (is (= (set (keys a)) (set (keys b))))
(is (= :head (get-in b [:mouth :parent]))) (is (= :head (get-in b [:mouth :parent])))
(is (= :eye-r-in (get-in b [:iris-r :stencil]))) (is (= :eye-r-in (get-in b [:iris-r :stencil])))
(is (= :mouth (get-in b [:mouth :pose-group]))) (is (= :mouth (get-in b [:mouth :pose-group])))
(is (= :face-2 (get-in clip [:features :face-2/mouth :timeline]))) (is (= :face-2 (get-in clip [:features :face-2/mouth :symbol])))
(doseq [path [[:xform :pos] [:xform :rot] [:xform :scale]]] (doseq [path [[:xform :pos] [:xform :rot] [:xform :scale]]]
(is (not= (:store (:dense (get-in a [:head :measured path]))) (is (not= (:store (:dense (get-in a [:head :measured path])))
(:store (:dense (get-in b [:head :measured path])))))) (:store (:dense (get-in b [:head :measured path]))))))
@ -63,20 +64,20 @@
(deftest one-subjects-edit-and-anchor-do-not-change-its-neighbor (deftest one-subjects-edit-and-anchor-do-not-change-its-neighbor
(let [before @initial (let [before @initial
at [:clip :timelines :face-2 :nodes :iris-r :channels [:style :color]] at [:clip :symbols :face-2 :nodes :iris-r :channels [:style :color]]
before (assoc-in before at (ch/framed :brow)) before (assoc-in before at (ch/framed :brow))
after (regenerate/change before {:scope :feature :id :face-2/eye-r after (regenerate/change before {:scope :feature :id :face-2/eye-r
:knob :gaze-gain :value 2}) :knob :gaze-gain :value 2})
anchored (freeze/head-mode {:subject :face-2 :mode :anchored :anchors {0 12}} after)] anchored (freeze/head-mode {:subject :face-2 :mode :anchored :anchors {0 12}} after)]
(is (= (get-in before at) (get-in after at)) "authored channels survive regeneration") (is (= (get-in before at) (get-in after at)) "authored channels survive regeneration")
(is (= (get-in before [:clip :timelines :face-1]) (is (= (get-in before [:clip :symbols :face-1])
(get-in after [:clip :timelines :face-1]) (get-in after [:clip :symbols :face-1])
(get-in anchored [:timelines :face-1]))) (get-in anchored [:symbols :face-1])))
(is (not= (channel before :face-2 :iris-r [:xform :pos]) (is (not= (channel before :face-2 :iris-r [:xform :pos])
(channel after :face-2 :iris-r [:xform :pos]))) (channel after :face-2 :iris-r [:xform :pos])))
(is (= (channel before :face-2 :iris-l [:xform :pos]) (is (= (channel before :face-2 :iris-l [:xform :pos])
(channel after :face-2 :iris-l [:xform :pos]))) (channel after :face-2 :iris-l [:xform :pos])))
(is (= {0 12} (get-in anchored [:timelines :face-2 :nodes :head :anchors]))) (is (= {0 12} (get-in anchored [:symbols :face-2 :nodes :head :anchors])))
(is (empty? (clip/problems anchored))))) (is (empty? (clip/problems anchored)))))
(deftest the-second-subject-regenerates-inside-a-composed-stage (deftest the-second-subject-regenerates-inside-a-composed-stage
@ -84,11 +85,11 @@
after (regenerate/change before {:scope :subject :id :face-2 after (regenerate/change before {:scope :subject :id :face-2
:knob :anchor-avg :value 4}) :knob :anchor-avg :value 4})
full (take/build (assoc settings :anchor-avg 4) @inputs)] full (take/build (assoc settings :anchor-avg 4) @inputs)]
(is (= (get-in full [:clip :timelines :face-2 :nodes :head :measured]) (is (= (get-in full [:clip :symbols :face-2 :nodes :head :measured])
(get-in after [:clip :timelines :face-2 :nodes :head :measured]))) (get-in after [:clip :symbols :face-2 :nodes :head :measured])))
(doseq [tid [:main :sym/face-8625 :face-1]] (doseq [sid [:main :sym/face-8625 :face-1]]
(is (= (get-in before [:clip :timelines tid]) (is (= (get-in before [:clip :symbols sid])
(get-in after [:clip :timelines tid])))) (get-in after [:clip :symbols sid]))))
(is (empty? (clip/problems (:clip after)))) (is (empty? (clip/problems (:clip after))))
(is (thrown? ExceptionInfo (is (thrown? ExceptionInfo
(freeze/head-mode {:subject :face-2 :mode :anchored :anchors {0 frames}} (freeze/head-mode {:subject :face-2 :mode :anchored :anchors {0 frames}}
@ -97,7 +98,7 @@
(deftest an-instance-pose-cut-only-holds-that-faces-mouth (deftest an-instance-pose-cut-only-holds-that-faces-mouth
(let [{:keys [clip store]} @initial (let [{:keys [clip store]} @initial
cut (pose/put-cut clip :face-2 :mouth 0 20) cut (pose/put-cut clip :main :face-2 :mouth 0 20)
original (by-node clip store 4) original (by-node clip store 4)
held (by-node cut store 4) held (by-node cut store 4)
source (by-node clip store 20)] source (by-node clip store 20)]
@ -173,12 +174,12 @@
(deftest the-instance-boundary-preserves-the-single-face-geometry (deftest the-instance-boundary-preserves-the-single-face-geometry
(let [{:keys [clip store]} (take/build settings (select-keys @inputs [:face-1])) (let [{:keys [clip store]} (take/build settings (select-keys @inputs [:face-1]))
flat (assoc-in clip [:timelines :main :nodes] flat (assoc-in clip [:symbols :main :nodes]
(merge (dissoc (clip/nodes clip) :face-1) (merge (dissoc (:nodes (clip/symbol clip :main)) :face-1)
(assoc-in (get-in clip [:timelines :face-1 :nodes]) (assoc-in (get-in clip [:symbols :face-1 :nodes])
[:head :parent] :face))) [:head :parent] :face)))
nested (clip/resolver clip store) nested (clip/resolver clip store pal/index-of :main)
reference (clip/resolver flat store)] reference (clip/resolver flat store pal/index-of :main)]
(doseq [f [0 1 7 20 39]] (doseq [f [0 1 7 20 39]]
(let [a (ops/snapshot (nested f)) b (ops/snapshot (reference f))] (let [a (ops/snapshot (nested f)) b (ops/snapshot (reference f))]
(is (= (mapv (comp second :node) a) (mapv :node b))) (is (= (mapv (comp second :node) a) (mapv :node b)))

View file

@ -33,7 +33,7 @@
{:face-1 @inputs})) {:face-1 @inputs}))
(defn- channel [entry node path] (defn- channel [entry node path]
(get-in entry [:clip :timelines :face-1 :nodes node :channels path])) (get-in entry [:clip :symbols :face-1 :nodes node :channels path]))
(deftest eye-rebuild-is-confined-to-the-edited-feature (deftest eye-rebuild-is-confined-to-the-edited-feature
(let [before @initial (let [before @initial
@ -45,8 +45,8 @@
(channel after :iris-l [:xform :pos]))) (channel after :iris-l [:xform :pos])))
(is (= (channel before :mouth [:geom :pts]) (is (= (channel before :mouth [:geom :pts])
(channel after :mouth [:geom :pts]))) (channel after :mouth [:geom :pts])))
(is (= (get-in before [:clip :timelines :main :nodes :face]) (is (= (get-in before [:clip :symbols :main :nodes :face])
(get-in after [:clip :timelines :main :nodes :face]))) (get-in after [:clip :symbols :main :nodes :face])))
(is (= (channel (full-at {:gaze-gain 2}) :iris-r [:xform :pos]) (is (= (channel (full-at {:gaze-gain 2}) :iris-r [:xform :pos])
(channel after :iris-r [:xform :pos]))))) (channel after :iris-r [:xform :pos])))))
@ -68,8 +68,8 @@
{:scope :feature :id :face-1/mouth :knob :verts :value 10})] {:scope :feature :id :face-1/mouth :knob :verts :value 10})]
(is (not= (channel before :mouth [:geom :pts]) (is (not= (channel before :mouth [:geom :pts])
(channel after :mouth [:geom :pts]))) (channel after :mouth [:geom :pts])))
(is (= (get-in before [:clip :timelines :face-1 :nodes :head]) (is (= (get-in before [:clip :symbols :face-1 :nodes :head])
(get-in after [:clip :timelines :face-1 :nodes :head]))) (get-in after [:clip :symbols :face-1 :nodes :head])))
(is (= (channel before :eye-r [:geom :pts]) (is (= (channel before :eye-r [:geom :pts])
(channel after :eye-r [:geom :pts]))) (channel after :eye-r [:geom :pts])))
(is (= (channel (full-at {:verts 10}) :mouth [:geom :pts]) (is (= (channel (full-at {:verts 10}) :mouth [:geom :pts])
@ -80,19 +80,19 @@
after (regenerate/change before after (regenerate/change before
{:scope :subject :id :face-1 :knob :anchor-avg :value 4}) {:scope :subject :id :face-1 :knob :anchor-avg :value 4})
full (full-at {:anchor-avg 4})] full (full-at {:anchor-avg 4})]
(is (not= (get-in before [:clip :timelines :face-1 :nodes :head :measured]) (is (not= (get-in before [:clip :symbols :face-1 :nodes :head :measured])
(get-in after [:clip :timelines :face-1 :nodes :head :measured]))) (get-in after [:clip :symbols :face-1 :nodes :head :measured])))
(is (= (get-in full [:clip :timelines :face-1 :nodes :head :measured]) (is (= (get-in full [:clip :symbols :face-1 :nodes :head :measured])
(get-in after [:clip :timelines :face-1 :nodes :head :measured]))) (get-in after [:clip :symbols :face-1 :nodes :head :measured])))
(is (= (get-in before [:clip :timelines :main :nodes :face]) (is (= (get-in before [:clip :symbols :main :nodes :face])
(get-in after [:clip :timelines :main :nodes :face]))))) (get-in after [:clip :symbols :main :nodes :face])))))
(deftest contour-edit-does-not-rebuild-head-or-teeth (deftest contour-edit-does-not-rebuild-head-or-teeth
(let [before @initial (let [before @initial
after (regenerate/change before after (regenerate/change before
{:scope :subject :id :face-1 :knob :contour-avg :value 3})] {:scope :subject :id :face-1 :knob :contour-avg :value 3})]
(is (= (get-in before [:clip :timelines :face-1 :nodes :head]) (is (= (get-in before [:clip :symbols :face-1 :nodes :head])
(get-in after [:clip :timelines :face-1 :nodes :head]))) (get-in after [:clip :symbols :face-1 :nodes :head])))
(is (= (channel before :teeth [:geom :pts]) (is (= (channel before :teeth [:geom :pts])
(channel after :teeth [:geom :pts]))))) (channel after :teeth [:geom :pts])))))
@ -215,7 +215,7 @@
"the composed stage keeps the analysis the edit needs") "the composed stage keeps the analysis the edit needs")
(is (some? (:source-inputs entry))) (is (some? (:source-inputs entry)))
(testing "and the features still say which timeline they live in" (testing "and the features still say which timeline they live in"
(is (every? #(= :face-1 (:timeline %)) (is (every? #(= :face-1 (:symbol %))
(vals (:features (:clip entry)))))))) (vals (:features (:clip entry))))))))
(deftest a-stage-edit-plans-the-same-features-as-a-take-edit (deftest a-stage-edit-plans-the-same-features-as-a-take-edit
@ -238,8 +238,8 @@
(sym-channel after :iris-l [:geom :radius])) (sym-channel after :iris-l [:geom :radius]))
"and only the edited side of it") "and only the edited side of it")
(testing "the placements are left exactly as they were" (testing "the placements are left exactly as they were"
(is (= (get-in before [:clip :timelines :main :nodes]) (is (= (get-in before [:clip :symbols :main :nodes])
(get-in after [:clip :timelines :main :nodes])))) (get-in after [:clip :symbols :main :nodes]))))
(testing "and the document is still a document" (testing "and the document is still a document"
(is (empty? (clip/problems (:clip after))))))) (is (empty? (clip/problems (:clip after)))))))
@ -249,8 +249,8 @@
;; successful edit of the drawing. ;; successful edit of the drawing.
(let [after (regenerate/change (staged) (let [after (regenerate/change (staged)
{:scope :feature :id :face-1/eye-r :knob :iris-size :value 0.6}) {:scope :feature :id :face-1/eye-r :knob :iris-size :value 0.6})
nodes (get-in after [:clip :timelines :main :nodes]) nodes (get-in after [:clip :symbols :main :nodes])
syms (filter (comp #{:symbol} :kind val) nodes)] syms (filter (comp #{:instance} :kind val) nodes)]
(is (= 7 (count syms))) (is (= 7 (count syms)))
(is (every? uuid? (map key syms))) (is (every? uuid? (map key syms)))
(doseq [[_ n] (filter (comp #{:audio} :kind val) nodes)] (doseq [[_ n] (filter (comp #{:audio} :kind val) nodes)]

View file

@ -1,7 +1,7 @@
(ns arthur.support.ops (ns arthur.support.ops
"Comparing two evaluations of a timeline, frame for frame. "Comparing two evaluations of a timeline, frame for frame.
`domain/timeline` has two evaluators on purpose — `eval-frame` is the `domain/symbol` has two evaluators on purpose — `eval-frame` is the
specification and `resolver` is what playback uses — and timeline-test's central specification and `resolver` is what playback uses — and timeline-test's central
assertion is that they agree in forward, backward and random frame order. assertion is that they agree in forward, backward and random frame order.
Step 9 needs the same comparison for a different question: that a document which Step 9 needs the same comparison for a different question: that a document which
@ -13,14 +13,14 @@
scrub, and a copy of this list that forgot 'backward' would test the easy half. scrub, and a copy of this list that forgot 'backward' would test the easy half.
Every entry point takes a TIMELINE, not a clip: what produces ops is a bag of Every entry point takes a TIMELINE, not a clip: what produces ops is a bag of
nodes in a frame space, and a caller with a clip says `(clip/root c)`. nodes in a frame space, and a caller with a clip says `(clip/symbol c :main)`.
`snapshot` is what makes ops comparable at all: a resolved op carries `:pts` as `snapshot` is what makes ops comparable at all: a resolved op carries `:pts` as
a VIEW into a reused buffer, so two ops from different frames can be `=` while a VIEW into a reused buffer, so two ops from different frames can be `=` while
naming the same array, and holding one and then asking for the next frame naming the same array, and holding one and then asking for the next frame
changes what the first one says. Reading the points out is what pins the frame." changes what the first one says. Reading the points out is what pins the frame."
(:require [arthur.domain.palette :as pal] (:require [arthur.domain.palette :as pal]
[arthur.domain.timeline :as timeline])) [arthur.domain.symbol :as symbol]))
(defn points (defn points
"An op's points as a vector of [x y], read out of its buffer." "An op's points as a vector of [x y], read out of its buffer."
@ -50,13 +50,13 @@
(defn specified (defn specified
"(fn [f] -> snapshot) through `eval-frame`, the specification." "(fn [f] -> snapshot) through `eval-frame`, the specification."
([tl store] (specified tl store pal/index-of)) ([sym store] (specified sym store pal/index-of))
([tl store palette] ([sym store palette]
(fn [f] (snapshot (timeline/eval-frame tl f store palette))))) (fn [f] (snapshot (symbol/eval-frame sym f store palette)))))
(defn resolved (defn resolved
"(fn [f] -> snapshot) through `resolver`, the playback path." "(fn [f] -> snapshot) through `resolver`, the playback path."
([tl store] (resolved tl store pal/index-of)) ([sym store] (resolved sym store pal/index-of))
([tl store palette] ([sym store palette]
(let [res (timeline/resolver tl store palette)] (let [res (symbol/resolver sym store palette)]
(fn [f] (snapshot (res f)))))) (fn [f] (snapshot (res f))))))