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:
parent
179770d7d4
commit
5dff490162
61 changed files with 1587 additions and 1431 deletions
75
clips/migrations/0007_symbols_not_timelines.py
Normal file
75
clips/migrations/0007_symbols_not_timelines.py
Normal 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),
|
||||
]
|
||||
|
|
@ -206,7 +206,7 @@ class Project(models.Model):
|
|||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
name = models.CharField(max_length=200, default="untitled")
|
||||
schema_version = models.PositiveIntegerField(default=1)
|
||||
schema_version = models.PositiveIntegerField(default=2)
|
||||
seq = models.PositiveBigIntegerField(default=0)
|
||||
palette = models.CharField(max_length=64, default="arthur/default")
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
|
|
|||
|
|
@ -382,13 +382,13 @@ class DocumentTests(TestCase):
|
|||
# cache marker, keyword keys, and a frame-keyed inner map.
|
||||
return {
|
||||
"clip/c1/timing": ["^ ", "~:fps", 30],
|
||||
"clip/c1/timeline/main": ["^ ", "~:frames", 48],
|
||||
"clip/c1/timeline/main/node/mouth": ["^ ", "~:id", "~:mouth", "~:z", "a1"],
|
||||
"clip/c1/timeline/main/channel/mouth/geom.pts": [
|
||||
"clip/c1/symbol/main": ["^ ", "~:frames", 48],
|
||||
"clip/c1/symbol/main/node/mouth": ["^ ", "~:id", "~:mouth", "~:z", "a1"],
|
||||
"clip/c1/symbol/main/channel/mouth/geom.pts": [
|
||||
"^ ", "~:animated?", True, "~:dense",
|
||||
["^ ", "~:store", self.block, "~:offset", 0, "~:stride", 16],
|
||||
],
|
||||
"clip/c1/timeline/main/channel/mouth-in/vis": [
|
||||
"clip/c1/symbol/main/channel/mouth-in/vis": [
|
||||
"^ ", "~:animated?", True, "~:keys", ["^ ", "~i0", True, "~i12", False],
|
||||
],
|
||||
}
|
||||
|
|
@ -407,7 +407,7 @@ class DocumentTests(TestCase):
|
|||
self.assertEqual(5, len(response.json()["written"]))
|
||||
|
||||
loaded = self.client.get(f"/api/projects/{self.project.id}").json()
|
||||
self.assertEqual(1, loaded["schema_version"])
|
||||
self.assertEqual(2, loaded["schema_version"])
|
||||
self.assertEqual(1, len(loaded["clips"]))
|
||||
clip = loaded["clips"][0]
|
||||
self.assertEqual("c1", clip["cid"])
|
||||
|
|
@ -424,22 +424,22 @@ class DocumentTests(TestCase):
|
|||
self.save()
|
||||
first = {leaf.path: leaf.version for leaf in Leaf.objects.all()}
|
||||
moved = self.leaves()
|
||||
moved["clip/c1/timeline/main/channel/mouth-in/vis"] = [
|
||||
moved["clip/c1/symbol/main/channel/mouth-in/vis"] = [
|
||||
"^ ", "~:animated?", True, "~:keys", ["^ ", "~i0", False],
|
||||
]
|
||||
response = self.save(moved)
|
||||
self.assertEqual(["clip/c1/timeline/main/channel/mouth-in/vis"], response.json()["written"])
|
||||
self.assertEqual(["clip/c1/symbol/main/channel/mouth-in/vis"], response.json()["written"])
|
||||
self.assertEqual(4, response.json()["unchanged"])
|
||||
after = {leaf.path: leaf.version for leaf in Leaf.objects.all()}
|
||||
self.assertEqual(2, after["clip/c1/timeline/main/channel/mouth-in/vis"])
|
||||
self.assertEqual(2, after["clip/c1/symbol/main/channel/mouth-in/vis"])
|
||||
self.assertEqual(first["clip/c1/timing"], after["clip/c1/timing"])
|
||||
|
||||
def test_a_removed_node_removes_its_leaf(self):
|
||||
self.save()
|
||||
fewer = {k: v for k, v in self.leaves().items()
|
||||
if k != "clip/c1/timeline/main/node/mouth"}
|
||||
if k != "clip/c1/symbol/main/node/mouth"}
|
||||
response = self.save(fewer)
|
||||
self.assertEqual(["clip/c1/timeline/main/node/mouth"], response.json()["removed"])
|
||||
self.assertEqual(["clip/c1/symbol/main/node/mouth"], response.json()["removed"])
|
||||
self.assertEqual(4, Leaf.objects.count())
|
||||
|
||||
def test_a_save_does_not_disturb_another_clip(self):
|
||||
|
|
@ -472,7 +472,7 @@ class DocumentTests(TestCase):
|
|||
|
||||
def test_a_leaf_write_carries_an_etag(self):
|
||||
self.save()
|
||||
url = f"/api/projects/{self.project.id}/leaves/clip/c1/timeline/main/node/mouth"
|
||||
url = f"/api/projects/{self.project.id}/leaves/clip/c1/symbol/main/node/mouth"
|
||||
got = self.client.get(url)
|
||||
self.assertEqual('"1"', got["ETag"])
|
||||
|
||||
|
|
@ -488,7 +488,7 @@ class DocumentTests(TestCase):
|
|||
# take-theirs. A PUT that replaced unconditionally is the bug where the
|
||||
# loser's work disappears silently.
|
||||
self.save()
|
||||
url = f"/api/projects/{self.project.id}/leaves/clip/c1/timeline/main/node/mouth"
|
||||
url = f"/api/projects/{self.project.id}/leaves/clip/c1/symbol/main/node/mouth"
|
||||
self.put(url, {"value": ["^ ", "~:z", "a2"]}, HTTP_IF_MATCH='"1"')
|
||||
stale = self.put(url, {"value": ["^ ", "~:z", "a3"]}, HTTP_IF_MATCH='"1"')
|
||||
self.assertEqual(409, stale.status_code)
|
||||
|
|
|
|||
|
|
@ -415,9 +415,11 @@ different rules:
|
|||
*not* to the plate, which is the whole point of it — so the offset genuinely
|
||||
belongs at the node, not the clip.
|
||||
|
||||
## Timelines, and why a scene is one
|
||||
## Symbols, and why a scene is one
|
||||
|
||||
A **timeline** is an ordered bag of nodes in its own frame space:
|
||||
A **symbol** is an ordered bag of nodes in its own frame space. (Earlier drafts
|
||||
and code called this a *timeline*; that word now means only the UI pane that
|
||||
shows one.)
|
||||
|
||||
```clojure
|
||||
{:frames 91
|
||||
|
|
@ -427,9 +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**:
|
||||
|
||||
- a clip's **scene** is its root timeline,
|
||||
- a **symbol** in the library is a timeline,
|
||||
- a node with `:kind :symbol` is an **instance** of one.
|
||||
- what a document opens on is a symbol, and **no symbol is reserved** — a new
|
||||
document's is called `main` only because it has to be called something,
|
||||
- anything placed inside another symbol is a symbol,
|
||||
- a node with `:kind :instance` is an **instance** of one.
|
||||
|
||||
An earlier draft of this document had a scene and a `:kind :timeline` symbol as
|
||||
two structures with the same fields and never said they were the same thing.
|
||||
|
|
@ -474,7 +477,7 @@ for all three is the same — **their own**:
|
|||
|
||||
### Instances
|
||||
|
||||
A node with `:kind :symbol` and `:of :sym/blink` places one. Its own channels
|
||||
A node with `:kind :instance` and `:of :sym/blink` places one. Its own channels
|
||||
compose *over* the symbol's, so one definition is placed many times and tinted,
|
||||
offset or retimed at each placement — that is how a three-frame blink is reused
|
||||
at frames 40, 88 and 200 without copying it.
|
||||
|
|
|
|||
|
|
@ -639,10 +639,10 @@ is what step 9 implemented, for the subset that exists:
|
|||
clip/<cid>/name clip/<cid>/subject/<sid>
|
||||
clip/<cid>/timing clip/<cid>/feature/<fid>
|
||||
clip/<cid>/stage clip/<cid>/group/<gid>
|
||||
clip/<cid>/source clip/<cid>/timeline/<tid>
|
||||
clip/<cid>/timeline/<tid>/node/<nid>
|
||||
clip/<cid>/timeline/<tid>/measured/<nid>
|
||||
clip/<cid>/timeline/<tid>/channel/<nid>/<prop>
|
||||
clip/<cid>/source clip/<cid>/symbol/<sid>
|
||||
clip/<cid>/symbol/<sid>/node/<nid>
|
||||
clip/<cid>/symbol/<sid>/measured/<nid>
|
||||
clip/<cid>/symbol/<sid>/channel/<nid>/<prop>
|
||||
```
|
||||
|
||||
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/feature/:fid tracked feature and settings
|
||||
clip/:cid/group/:gid shared settings for an eye pair
|
||||
clip/:cid/timeline/:tid frame count, palette
|
||||
clip/:cid/timeline/:tid/node/:nid one node: parent, stencil, z, time
|
||||
clip/:cid/timeline/:tid/channel/:nid/:prop
|
||||
clip/:cid/timeline/:tid/measured/:nid
|
||||
clip/:cid/timeline/:tid/cel/:nid/:frame
|
||||
clip/:cid/timeline/:tid/overrides/:nid/:prop
|
||||
clip/:cid/symbol/:sid frame count, palette
|
||||
clip/:cid/symbol/:sid/node/:nid one node: parent, stencil, z, time
|
||||
clip/:cid/symbol/:sid/channel/:nid/:prop
|
||||
clip/:cid/symbol/:sid/measured/:nid
|
||||
clip/:cid/symbol/:sid/cel/:nid/:frame
|
||||
clip/:cid/symbol/:sid/overrides/:nid/:prop
|
||||
```
|
||||
|
||||
Each feature and node has its own leaf, so tuning separate features and adding
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ symbol instance. Timelines already provide local node names, independent playbac
|
|||
and persistence. No new kind of scene container is needed.
|
||||
|
||||
```clojure
|
||||
:timelines
|
||||
:symbols
|
||||
{:main {:nodes {:root {:time {:mode :map :expose 2}}
|
||||
:face {:parent :root :channels <source-to-stage placement>}
|
||||
:face-1 {:kind :symbol :of :face-1 :parent :face :z "a0"}
|
||||
:face-2 {:kind :symbol :of :face-2 :parent :face :z "a1"}}}
|
||||
:face-1 {:kind :instance :of :face-1 :parent :face :z "a0"}
|
||||
:face-2 {:kind :instance :of :face-2 :parent :face :z "a1"}}}
|
||||
:face-1 {:nodes {:head {...} :mouth {:parent :head ...} ...}}
|
||||
:face-2 {:nodes {:head {...} :mouth {:parent :head ...} ...}}}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ handling and the relevant key whitelist if its storage location requires it.
|
|||
- `freeze/performance-nodes` marks generated animated channels with
|
||||
`:pose-sampled?` and local `:pose-group` names. This includes keyed visibility
|
||||
as well as dense geometry. `:generated` remains provenance for regeneration.
|
||||
- `timeline/channel-frame` already applies explicit pose choices and default
|
||||
- `symbol/channel-frame` already applies explicit pose choices and default
|
||||
picture sampling to marked channels. Playback and export both use
|
||||
`clip/resolver` with `:picture-fps`; there is no need for a second sampling
|
||||
implementation. Export's pose count is still a rate-based estimate.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
has never heard of them.
|
||||
|
||||
Selection lives in app-db under `:ui`, as `[:node <timeline> <node>]`,
|
||||
`[:timeline <id>]` or `[:subject|:feature|:group <id>]` — four panes ask what is
|
||||
Everything that holds nodes is a **symbol**, and none is special: a new document
|
||||
has one called `main` because it has to be called something. Which symbol is on
|
||||
screen is editor state, `[:ui :open]`, not a fact about the document — the stage
|
||||
draws it, the timeline lists it, the transport plays it and a new shape goes into
|
||||
it. A document opens on the longest symbol nothing else places.
|
||||
|
||||
Selection lives in app-db under `:ui`, as `[:node <symbol> <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
|
||||
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
|
||||
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
|
||||
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
|
||||
shows the timeline it instances, with every frame number mapped back into the
|
||||
stage's own frame space — see the namespace docstring in `ui/timeline.cljs`, which
|
||||
than the bar does. Opening a row shows its channels; opening an **instance** row
|
||||
shows the symbol it places, with every frame number mapped back into the open
|
||||
symbol's frame space — see the namespace docstring in `ui/timeline.cljs`, which
|
||||
is where that mapping is argued.
|
||||
|
||||
### Paint sketch
|
||||
|
|
@ -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.
|
||||
|
||||
**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,
|
||||
and the two pictures overlap slightly in stage space. Audio has its own timeline
|
||||
and the two pictures overlap slightly in stage space. Audio has its own
|
||||
nodes, linked to the picture instances but with independent spans and gain
|
||||
channels. The right sound swells and pans across the stage, then fades out at
|
||||
frame 260 while its picture continues to
|
||||
|
|
@ -357,12 +363,12 @@ them is `clips/templates/clips/index.html`.
|
|||
|
||||
## Two evaluators, on purpose
|
||||
|
||||
`domain/timeline` has both `eval-frame` and `resolver`, and they are not
|
||||
`domain/symbol` has both `eval-frame` and `resolver`, and they are not
|
||||
alternatives:
|
||||
|
||||
- **`(eval-frame timeline f store)`** is the specification. Allocating, order-free,
|
||||
- **`(eval-frame symbol f store)`** is the specification. Allocating, order-free,
|
||||
obviously correct. Tests and one-off renders use it.
|
||||
- **`(resolver timeline store)` -> `(fn [f] ops)`** is what playback uses. It caches
|
||||
- **`(resolver symbol store)` -> `(fn [f] ops)`** is what playback uses. It caches
|
||||
the topological order and the z paths, holds a cursor per channel and reuses
|
||||
one point buffer per node, so a frame allocates the op maps and nothing else.
|
||||
|
||||
|
|
|
|||
|
|
@ -91,18 +91,15 @@
|
|||
(.setValueAtTime param (* factor v) (/ f fps)))))))
|
||||
|
||||
(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
|
||||
timeline and may carry its own sound. `:main` is the clip's own, which is what
|
||||
playback mixes."
|
||||
[document tid]
|
||||
(filter #(= :audio (:kind %)) (vals (:nodes (clip/timeline document tid)))))
|
||||
|
||||
(defn- render! [document tid sources store]
|
||||
(defn- render! [document sid sources store]
|
||||
(let [fps (:fps document)
|
||||
frames (:frames (clip/timeline document tid))
|
||||
tracks (tracks-of document tid)
|
||||
frames (:frames (clip/symbol document sid))
|
||||
tracks (tracks-of document sid)
|
||||
output (js/OfflineAudioContext.
|
||||
2 (js/Math.ceil (* (/ frames fps) 44100)) 44100)]
|
||||
(doseq [track tracks]
|
||||
|
|
@ -133,20 +130,20 @@
|
|||
(.startRendering output)))
|
||||
|
||||
(defn buffer!
|
||||
"Promise of the `AudioBuffer` one timeline's audio tracks mix down to, or nil
|
||||
"Promise of the `AudioBuffer` one symbol's audio tracks mix down to, or nil
|
||||
when it has none.
|
||||
|
||||
The raw product. `mix!` packages it as a WAV URL for the transport and
|
||||
`export/frames` packages it as WAV bytes in an archive; a muxer would take it as
|
||||
it is, which is why this is the function the others are written in terms of."
|
||||
([document tid] (buffer! document tid nil))
|
||||
([document tid store]
|
||||
(let [tracks (tracks-of document tid)]
|
||||
([document sid] (buffer! document sid nil))
|
||||
([document sid store]
|
||||
(let [tracks (tracks-of document sid)]
|
||||
(if (empty? tracks)
|
||||
(js/Promise.resolve nil)
|
||||
(-> (js/Promise.all
|
||||
(into-array (map source! (distinct (map #(get-in % [:source :footage]) tracks)))))
|
||||
(.then (fn [pairs] (render! document tid (into {} (array-seq pairs)) store))))))))
|
||||
(.then (fn [pairs] (render! document sid (into {} (array-seq pairs)) store))))))))
|
||||
|
||||
(defn decode!
|
||||
"Promise of the `AudioBuffer` behind a URL. What a clip whose audio is a plain
|
||||
|
|
@ -162,9 +159,9 @@
|
|||
(.decodeAudioData (js/OfflineAudioContext. 1 1 44100) bytes)))))
|
||||
|
||||
(defn mix!
|
||||
"Promise of a mixed WAV URL, or the original URL for a clip without audio
|
||||
tracks. Each track can be trimmed and faded independently of its linked picture."
|
||||
([document fallback-url] (mix! document fallback-url nil))
|
||||
([document fallback-url store]
|
||||
(-> (buffer! document clip/root-id store)
|
||||
(.then (fn [buffer] (if buffer (wav-url buffer) fallback-url))))))
|
||||
"Promise of a mixed WAV URL for symbol `sid`, or the original URL when it has
|
||||
no audio tracks. Each track can be trimmed and faded independently of its
|
||||
linked picture."
|
||||
[document sid fallback-url store]
|
||||
(-> (buffer! document sid store)
|
||||
(.then (fn [buffer] (if buffer (wav-url buffer) fallback-url)))))
|
||||
|
|
|
|||
|
|
@ -20,10 +20,8 @@
|
|||
|
||||
Read OFF the clip rather than written again beside it: copying a number by hand
|
||||
into this table is how it comes to disagree with the document it describes.
|
||||
`:frames` comes from the ROOT TIMELINE and `:fps` from the clip, which is the
|
||||
split `arthur.domain.clip` exists to make — a timeline is a frame space, a clip
|
||||
is a rate — and an earlier version of this docstring noted that they sat on one
|
||||
map \"only because there is one clip per scene today\". They do not any more."
|
||||
There is no `:frames` here, because a length belongs to a symbol and which
|
||||
symbol is open is the editor's state — see `events/playback/frames`."
|
||||
[label-key label clip store]
|
||||
(merge {:label label :clip clip :store store
|
||||
;; A static asset since step 9, and not the repo root's `audio.wav`.
|
||||
|
|
@ -32,8 +30,7 @@
|
|||
;; that the clock has something to run against with no footage ingested.
|
||||
:audio "/static/arthur/audio.wav"
|
||||
:cid (name label-key)
|
||||
:display-fps (:fps clip)
|
||||
:frames (domain-clip/frames clip)}
|
||||
:display-fps (:fps clip)}
|
||||
(select-keys clip [:fps :width :height])))
|
||||
|
||||
(def clips
|
||||
|
|
@ -72,7 +69,7 @@
|
|||
;; transform on a node, so nothing downstream of the freeze knows the frame
|
||||
;; size — and it is why ui/player no longer hardcodes 320x200.
|
||||
:clip (let [c (domain-clip/blank)]
|
||||
{:fps (:fps c) :frames (domain-clip/frames c)
|
||||
{:fps (:fps c)
|
||||
:width (:width c) :height (:height c)
|
||||
:audio nil :display-fps (:fps c)})
|
||||
|
||||
|
|
@ -106,11 +103,12 @@
|
|||
;; machinery that would share it.
|
||||
;; --- export ---
|
||||
;;
|
||||
;; The REQUEST and its progress, never the frames. Which timeline to write and
|
||||
;; The REQUEST and its progress, never the frames. Which symbol to write and
|
||||
;; at what integer zoom is authored state like anything else; the megabytes the
|
||||
;; render produces are handed straight to a download and never enter the db.
|
||||
;; `:isolate` is the placement to render alone, or nil for the whole timeline.
|
||||
:export {:timeline :main :isolate nil :zoom 4 :busy? false :done 0 :total 0
|
||||
;; `:isolate` is the placement to render alone, or nil for the whole symbol;
|
||||
;; `:symbol` nil means whichever symbol is open.
|
||||
:export {:symbol nil :isolate nil :zoom 4 :busy? false :done 0 :total 0
|
||||
:status nil}
|
||||
|
||||
:playback {:frame 0
|
||||
|
|
@ -134,20 +132,28 @@
|
|||
;; names, so a pane dispatches on it rather than on which of several
|
||||
;; "selected-x" keys happens to be non-nil:
|
||||
;;
|
||||
;; [:node <timeline> <node>] a shape or a placement
|
||||
;; [:timeline <id>] a timeline, root or library
|
||||
;; [:node <symbol> <node>] a shape or an instance
|
||||
;; [:symbol <id>] a symbol
|
||||
;; [:subject <id>] [:feature <id>] [:group <id>] a tracked object
|
||||
;;
|
||||
;; `:draft` is the polygon being clicked out, flat [x y x y …] as geometry is
|
||||
;; stored everywhere. `:expanded` holds timeline row PATHS — a path and not a
|
||||
;; node id, because one symbol placed twice is two rows that open separately.
|
||||
;;
|
||||
;; `:open` is the symbol on screen — the one the stage draws, the timeline
|
||||
;; lists, the transport plays and a new shape goes into — and `:tabs` the
|
||||
;; symbols open beside it. Editor state and not the document's, because no
|
||||
;; symbol is special to the document: which one you are looking at is a fact
|
||||
;; about you.
|
||||
;;
|
||||
;; `:knobs` holds a generated setting's value WHILE THE REGENERATION IS IN
|
||||
;; FLIGHT, keyed by [scope id knob]. Moving a slider dispatches a preview that
|
||||
;; re-freezes blocks asynchronously, so until it lands the clip still reports
|
||||
;; the old value — and a slider reading from the clip would spring back under
|
||||
;; the user's finger on every frame of the drag.
|
||||
:ui {:selection nil
|
||||
:ui {:open nil
|
||||
:tabs []
|
||||
:selection nil
|
||||
:tone :skin-base
|
||||
:tool nil
|
||||
:draft []
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
validates would not be the one that renders, and the model would be validated
|
||||
against a scene nobody ever looked at."
|
||||
(:require [arthur.domain.clip :as domain-clip]
|
||||
[arthur.domain.timeline :as timeline]
|
||||
[arthur.domain.symbol :as symbol]
|
||||
[cljs.reader :as reader]
|
||||
[shadow.resource :as rc]))
|
||||
|
||||
|
|
@ -14,15 +14,15 @@
|
|||
|
||||
(def clip (reader/read-string source))
|
||||
|
||||
(def timeline
|
||||
"The clip's root timeline: what an evaluator takes. `clip` is the document."
|
||||
(domain-clip/root clip))
|
||||
(def main
|
||||
"The scene's one symbol: what an evaluator takes. `clip` is the document."
|
||||
(domain-clip/symbol clip :main))
|
||||
|
||||
(def fps (:fps clip))
|
||||
(def frames (domain-clip/frames clip))
|
||||
(def frames (domain-clip/frames clip :main))
|
||||
|
||||
(defn ops-at
|
||||
"Draw ops for one frame, via the specification path. The page uses
|
||||
`timeline/resolver` instead; this is here for the REPL."
|
||||
`symbol/resolver` instead; this is here for the REPL."
|
||||
[f]
|
||||
(timeline/eval-frame timeline f))
|
||||
(symbol/eval-frame main f))
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
:width 320
|
||||
:height 200
|
||||
|
||||
:timelines
|
||||
:symbols
|
||||
{:main
|
||||
{:id :main
|
||||
:frames 229
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
(let [{:keys [name width height frames symbol instances audio scale]} layout
|
||||
default-anchor (or (:anchor layout)
|
||||
[(/ (:width source) 2) (/ (:height source) 2)])
|
||||
original (get-in source [:timelines :main])
|
||||
original (get-in source [:symbols :main])
|
||||
;; Authored id -> uuid, so the `:linked-to` in the EDN resolves to the
|
||||
;; identity the document uses. Built before either pass because the audio
|
||||
;; nodes refer to the instances.
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
{:root {:id :root :name "stage" :kind :group :z "a1"}}
|
||||
(map (fn [{:keys [uuid name z span at in center anchor drift phase]}]
|
||||
(let [anchor (or anchor default-anchor)]
|
||||
[uuid {:id uuid :name name :kind :symbol :of symbol
|
||||
[uuid {:id uuid :name name :kind :instance :of symbol
|
||||
:parent :root :z z :span span
|
||||
:time {:mode :map :at at :in in :rate 1}
|
||||
:channels {[:xform :pos] (if drift
|
||||
|
|
@ -68,6 +68,6 @@
|
|||
pan (assoc [:audio :pan] pan))}])
|
||||
audio))]
|
||||
(assoc source :name name :width width :height height
|
||||
:timelines (assoc (:timelines source)
|
||||
:symbols (assoc (:symbols source)
|
||||
:main {:id :main :frames frames :nodes nodes}
|
||||
symbol (assoc original :id symbol)))))
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@
|
|||
:fps fps
|
||||
:width 320
|
||||
:height 200
|
||||
:timelines
|
||||
:symbols
|
||||
{:main
|
||||
{:id :main
|
||||
:frames frames
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
│
|
||||
FREEZE ──▶ channels on nodes
|
||||
│
|
||||
timeline/resolver ──▶ raster
|
||||
symbol/resolver ──▶ raster
|
||||
|
||||
— and the order of that diagram is the whole argument for the stage split. The
|
||||
anchor fit is knob-free. Conditioning smooths its four parameters. The rings are
|
||||
|
|
|
|||
|
|
@ -1,47 +1,43 @@
|
|||
(ns arthur.domain.clip
|
||||
"A CLIP: the unit of work, and a library of timelines.
|
||||
"A CLIP: the unit of work, and a library of symbols.
|
||||
|
||||
{:name \"take\"
|
||||
:fps 30
|
||||
:width 320 :height 200
|
||||
:analysis {...}
|
||||
:subjects {...} :features {...} :groups {...}
|
||||
:timelines {:main {:id :main :frames 229 :nodes {...}}}}
|
||||
:symbols {:main {:id :main :frames 229 :nodes {...}}}}
|
||||
|
||||
Every field here is a fact about the clip and NOT about a bag of nodes, which is
|
||||
the cut this namespace exists to make. Before it, one map carried both: `:fps`,
|
||||
the stage dimensions, the analysis record and the tracking identities sat beside
|
||||
`:nodes`, and `arthur.db` said of it — correctly — that they \"sit on the scene
|
||||
map only because there is one clip per scene today\". The cost of leaving them
|
||||
together was not untidiness. It was that a SYMBOL had nowhere to live: a library
|
||||
timeline is a bag of nodes with a frame space and nothing else, so under the old
|
||||
shape it would have had to be a clip with seven meaningless fields, or a second
|
||||
structure with the same `:nodes` key that every walk had to be taught about.
|
||||
`:nodes`. The cost of leaving them together was not untidiness. It was that a
|
||||
SYMBOL had nowhere to live: a symbol is a bag of nodes with a frame space and
|
||||
nothing else, so under the old shape it would have had to be a clip with seven
|
||||
meaningless fields.
|
||||
|
||||
Now there is one node-holding type — `arthur.domain.timeline` — and a clip holds
|
||||
a MAP of them. A `:kind :symbol` instance names a timeline in `:timelines`,
|
||||
and the clip resolver gives each placement its own reading heads.
|
||||
Now there is one node-holding type — `arthur.domain.symbol` — and a clip holds
|
||||
a MAP of them. A `:kind :instance` node places one symbol inside another, and
|
||||
the clip resolver gives each instance its own reading heads.
|
||||
|
||||
THE ROOT TIMELINE HAS A RESERVED ID, `:main`, rather than the clip carrying a
|
||||
pointer to it. A pointer is a field that can be wrong — it can name a timeline
|
||||
that is not there, and then every reader needs a fallback — where a reserved name
|
||||
can only be absent, which `problems` reports once. Flash reserves `_root` the
|
||||
same way and for the same reason. Nothing else about `:main` is special: it is an
|
||||
ordinary entry in the map, and a symbol is another one.
|
||||
NO SYMBOL IS SPECIAL. There is no reserved root and no pointer to one: which
|
||||
symbol is on screen is the editor's state, not the document's, and every
|
||||
function here that needs a symbol is told which. A new document has one symbol
|
||||
called `:main` because it has to be called something, and that is all the name
|
||||
means — it can be renamed, placed inside another symbol or deleted like any of
|
||||
them. `unplaced` answers the question a reserved root used to: which symbols
|
||||
nothing else places, and so which ones a person opening the document wants.
|
||||
|
||||
WHY :fps IS HERE AND :frames IS NOT. A rate is how fast the whole clip plays
|
||||
against its audio, and a nested timeline cannot have one of its own — retiming an
|
||||
against its audio, and a nested symbol cannot have one of its own — retiming an
|
||||
instance is `:rate` on its `:time` map, which is a factor and not a rate. A
|
||||
frame COUNT is a property of a frame space, so every timeline has its own."
|
||||
frame COUNT is a property of a frame space, so every symbol has its own."
|
||||
(:refer-clojure :exclude [symbol])
|
||||
(:require [arthur.domain.feature :as feature]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.pose :as pose]
|
||||
[arthur.domain.timeline :as timeline]))
|
||||
|
||||
(def ^:const root-id
|
||||
"The reserved id of the timeline a clip plays. See the namespace docstring."
|
||||
:main)
|
||||
[arthur.domain.symbol :as symbol]))
|
||||
|
||||
(def clip-keys
|
||||
"Every top-level field of a clip, and the reason `arthur.domain.leaf` refuses
|
||||
|
|
@ -50,38 +46,56 @@
|
|||
that loses something on every round trip, which is the one bug a persistence
|
||||
layer must not be able to have. Add the field here and to `leaf/leaves` and
|
||||
`leaf/clip` in the same commit."
|
||||
#{:name :fps :analysis :subjects :features :groups :width :height :timelines})
|
||||
#{:name :fps :analysis :subjects :features :groups :width :height :symbols})
|
||||
|
||||
(defn timeline
|
||||
"One of the clip's timelines, by id."
|
||||
[clip id]
|
||||
(get-in clip [:timelines id]))
|
||||
|
||||
(defn root
|
||||
"The timeline the clip plays."
|
||||
[clip]
|
||||
(timeline clip root-id))
|
||||
(defn symbol
|
||||
"One of the clip's symbols, by id."
|
||||
[clip sid]
|
||||
(get-in clip [:symbols sid]))
|
||||
|
||||
(defn frames
|
||||
"The clip's length, which is its root timeline's frame space and is not written
|
||||
down twice. Reading it off the root is what stops the two from disagreeing."
|
||||
"A symbol's length. Read off the symbol, never copied beside it."
|
||||
[clip sid]
|
||||
(:frames (symbol clip sid)))
|
||||
|
||||
(defn update-symbol
|
||||
"Apply f to one symbol in place."
|
||||
[clip sid f & args]
|
||||
(apply update-in clip [:symbols sid] f args))
|
||||
|
||||
(defn places
|
||||
"The ids of the symbols `sid` places, directly."
|
||||
[clip sid]
|
||||
(into #{} (keep (fn [n] (when (= :instance (:kind n)) (:of n))))
|
||||
(vals (:nodes (symbol clip sid)))))
|
||||
|
||||
(defn contains-symbol?
|
||||
"Whether `inner` is `outer` or is placed anywhere inside it. Placing `outer`
|
||||
into `inner` when this is true is a cycle."
|
||||
[clip outer inner]
|
||||
(let [seen (volatile! #{})]
|
||||
(letfn [(walk [sid]
|
||||
(or (= sid inner)
|
||||
(when-not (@seen sid)
|
||||
(vswap! seen conj sid)
|
||||
(some walk (places clip sid)))))]
|
||||
(boolean (walk outer)))))
|
||||
|
||||
(defn unplaced
|
||||
"The symbols no other symbol places, sorted by id. What to open when a
|
||||
document is opened."
|
||||
[clip]
|
||||
(:frames (root clip)))
|
||||
(let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))]
|
||||
(vec (sort-by str (remove placed (keys (:symbols clip)))))))
|
||||
|
||||
(defn update-timeline
|
||||
"Apply f to one timeline in place."
|
||||
[clip id f & args]
|
||||
(apply update-in clip [:timelines id] f args))
|
||||
|
||||
(defn update-root [clip f & args]
|
||||
(apply update-timeline clip root-id f args))
|
||||
|
||||
(defn nodes
|
||||
"The root timeline's nodes. A convenience for the many callers that mean the
|
||||
root and would otherwise spell it out; anything that could mean a symbol says
|
||||
which timeline instead."
|
||||
(defn opens-on
|
||||
"The symbol a document opens on: the longest one nothing else places, ties
|
||||
broken by id. The symbol that contains everything else is the longest of the
|
||||
unplaced ones in every document made so far, and a reserved name is what this
|
||||
replaces."
|
||||
[clip]
|
||||
(:nodes (root clip)))
|
||||
(first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)])
|
||||
(unplaced clip))))
|
||||
|
||||
(def ^:const blank-frames
|
||||
"How long a new document is before anything says otherwise. Four seconds at 30,
|
||||
|
|
@ -89,9 +103,9 @@
|
|||
120)
|
||||
|
||||
(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
|
||||
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."
|
||||
|
|
@ -100,39 +114,39 @@
|
|||
:fps 30
|
||||
:width 320 :height 200
|
||||
:subjects {} :features {} :groups {}
|
||||
:timelines {root-id {:id root-id :frames blank-frames :nodes {}}}})
|
||||
:symbols {:main {:id :main :frames blank-frames :nodes {}}}})
|
||||
|
||||
(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
|
||||
map — it is what `:linked-to`, an export target and a saved leaf all name — so
|
||||
generating one in here would make this function's result depend on when it was
|
||||
called, and this namespace is the pure one. `demo/stage_8625.edn` authors its
|
||||
placements' uuids by hand for the same reason, in more words.
|
||||
called, and this namespace is the pure one.
|
||||
|
||||
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
|
||||
dragging something onto a playhead is asking for. `:span` runs to the end of
|
||||
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
|
||||
disappear."
|
||||
[clip tid frame uuid [x y]]
|
||||
(let [target (timeline clip tid)
|
||||
end (frames clip)]
|
||||
(if (or (nil? target) (= root-id tid) (nil? frame) (neg? frame) (>= frame end))
|
||||
means local frame 0 of the symbol plays on `frame` of `into`, which is what
|
||||
dragging something onto a playhead is asking for.
|
||||
|
||||
Refused, returning the clip unchanged, when it would make a cycle: a symbol
|
||||
cannot be placed inside itself or inside anything it places."
|
||||
[clip into sid frame uuid [x y]]
|
||||
(let [target (symbol clip sid)
|
||||
end (frames clip into)]
|
||||
(if (or (nil? target) (nil? end) (nil? frame) (neg? frame) (>= frame end)
|
||||
(contains-symbol? clip sid into))
|
||||
clip
|
||||
(update-root
|
||||
clip assoc-in [:nodes uuid]
|
||||
(update-symbol
|
||||
clip into assoc-in [:nodes uuid]
|
||||
{:id uuid
|
||||
:name (name tid)
|
||||
:kind :symbol
|
||||
:of tid
|
||||
:name (name sid)
|
||||
:kind :instance
|
||||
:of sid
|
||||
:parent nil
|
||||
;; Lexicographic draw order, as `domain/paint` does it: a placement made
|
||||
;; later sits above one made earlier, and neither has to renumber.
|
||||
:z (str "z" (js/Date.now) "-" (name tid))
|
||||
:span [frame end]
|
||||
:z (str "z" (js/Date.now) "-" (name sid))
|
||||
:span [frame (min end (+ frame (:frames target)))]
|
||||
:time {:mode :map :at frame :in 0 :rate 1}
|
||||
:channels {[:xform :pos] {:animated? false :value [x y]}}}))))
|
||||
|
||||
|
|
@ -158,35 +172,30 @@
|
|||
op)))
|
||||
|
||||
(defn resolver
|
||||
"Resolve a clip, including each library timeline placed by a symbol instance.
|
||||
"Resolve symbol `sid` of a clip, including every symbol its instances place.
|
||||
|
||||
Each instance owns its own timeline resolver, so two offsets never share a
|
||||
Each instance owns its own symbol resolver, so two offsets never share a
|
||||
channel cursor or point buffer. The returned ops must be drawn before the next
|
||||
frame, as with timeline/resolver.
|
||||
frame, as with symbol/resolver.
|
||||
|
||||
`root` is which timeline to resolve AS the root, and it defaults to the clip's.
|
||||
Passing a symbol's id is the whole of \"render that symbol\": a library timeline
|
||||
and the clip's own are the same type, so a symbol resolves by being rooted
|
||||
rather than by a second code path — which is the return on collapsing the two
|
||||
into `domain/timeline`. Its frame space is its own `:frames`, and nested symbols
|
||||
inside it still resolve, because this is the function that knows how to do that."
|
||||
([clip store] (resolver clip store pal/index-of root-id))
|
||||
([clip store palette] (resolver clip store palette root-id))
|
||||
([clip store palette root] (resolver clip store palette root nil))
|
||||
([clip store palette root {:keys [picture-fps] :as opts}]
|
||||
(letfn [(build [tid chain pose-tracks]
|
||||
(when (some #{tid} chain)
|
||||
(throw (ex-info "symbol timeline cycle" {:chain (conj chain tid)})))
|
||||
(let [tl (or (timeline clip tid)
|
||||
(throw (ex-info "symbol names a missing timeline" {:timeline tid})))
|
||||
nodes (:nodes tl)
|
||||
rank (timeline/draw-rank nodes (timeline/order nodes))
|
||||
Any symbol can be resolved and none is the default: the frame space is the
|
||||
resolved symbol's own `:frames`, and nested instances inside it still resolve,
|
||||
because this is the function that knows how to do that."
|
||||
([clip store palette sid] (resolver clip store palette sid nil))
|
||||
([clip store palette sid {:keys [picture-fps] :as opts}]
|
||||
(letfn [(build [sid chain pose-tracks]
|
||||
(when (some #{sid} chain)
|
||||
(throw (ex-info "symbol cycle" {:chain (conj chain sid)})))
|
||||
(let [sym (or (symbol clip sid)
|
||||
(throw (ex-info "an instance names a missing symbol" {:symbol sid})))
|
||||
nodes (:nodes sym)
|
||||
rank (symbol/draw-rank nodes (symbol/order nodes))
|
||||
ids (sort-by rank (keys nodes))
|
||||
own (timeline/resolver tl store palette pose-tracks
|
||||
own (symbol/resolver sym store palette pose-tracks
|
||||
(assoc opts :source-fps (:fps clip)))
|
||||
children (into {}
|
||||
(for [[id n] nodes :when (= :symbol (:kind n))]
|
||||
[id (build (:of n) (conj chain tid)
|
||||
(for [[id n] nodes :when (= :instance (:kind n))]
|
||||
[id (build (:of n) (conj chain sid)
|
||||
(get-in n [:playback :tracks]))]))]
|
||||
(fn [f]
|
||||
(let [by-id (into {} (map (juxt :node identity)) (own f))]
|
||||
|
|
@ -194,10 +203,10 @@
|
|||
(mapcat
|
||||
(fn [id]
|
||||
(let [n (get nodes id)]
|
||||
(if (= :symbol (:kind n))
|
||||
(let [m (timeline/world-of own id)
|
||||
local (timeline/frame-of own id)
|
||||
target (timeline clip (:of n))
|
||||
(if (= :instance (:kind n))
|
||||
(let [m (symbol/world-of own id)
|
||||
local (symbol/frame-of own id)
|
||||
target (symbol clip (:of n))
|
||||
length (:frames target)
|
||||
frame (when (and m (number? local))
|
||||
(if (get-in n [:time :loop?])
|
||||
|
|
@ -208,7 +217,7 @@
|
|||
[]))
|
||||
(when-let [op (get by-id id)] [op]))))
|
||||
ids))))))]
|
||||
(build root [] nil))))
|
||||
(build sid [] nil))))
|
||||
|
||||
(defn problems
|
||||
"Human-readable reasons this clip will not evaluate or save."
|
||||
|
|
@ -217,28 +226,26 @@
|
|||
(concat
|
||||
(for [k (remove clip-keys (keys clip))]
|
||||
(str "clip has a field with no leaf to save it in: " (pr-str k)))
|
||||
(when-not (map? (:timelines clip))
|
||||
[":timelines must be a map of id -> timeline"])
|
||||
(when (and (map? (:timelines clip)) (nil? (root clip)))
|
||||
[(str "no " (pr-str root-id) " timeline — a clip plays the one with the reserved id")])
|
||||
(when-not (map? (:symbols clip))
|
||||
[":symbols must be a map of id -> symbol"])
|
||||
(when-not (or (nil? (:fps clip)) (and (number? (:fps clip)) (pos? (:fps clip))))
|
||||
[(str ":fps is " (pr-str (:fps clip)) " — a rate is a positive number")])
|
||||
(for [[id tl] (:timelines clip)
|
||||
:when (not= id (:id tl))]
|
||||
(str "timeline under key " (pr-str id) " has :id " (pr-str (:id tl))))
|
||||
(for [[id tl] (:timelines clip)
|
||||
p (timeline/problems tl)]
|
||||
(str "timeline " (pr-str id) ": " p))
|
||||
(for [[tid tl] (:timelines clip)
|
||||
[id n] (:nodes tl)
|
||||
:when (and (= :symbol (:kind n))
|
||||
(not (contains? (:timelines clip) (:of n))))]
|
||||
(str "timeline " (pr-str tid) " symbol " (pr-str id)
|
||||
" names missing timeline " (pr-str (:of n))))
|
||||
(for [[tid tl] (:timelines clip)
|
||||
[id n] (:nodes tl)
|
||||
:when (= :symbol (:kind n))
|
||||
:let [target (get-in clip [:timelines (:of n)])
|
||||
(for [[id sym] (:symbols clip)
|
||||
:when (not= id (:id sym))]
|
||||
(str "symbol under key " (pr-str id) " has :id " (pr-str (:id sym))))
|
||||
(for [[id sym] (:symbols clip)
|
||||
p (symbol/problems sym)]
|
||||
(str "symbol " (pr-str id) ": " p))
|
||||
(for [[sid sym] (:symbols clip)
|
||||
[id n] (:nodes sym)
|
||||
:when (and (= :instance (:kind n))
|
||||
(not (contains? (:symbols clip) (:of n))))]
|
||||
(str "symbol " (pr-str sid) " instance " (pr-str id)
|
||||
" names missing symbol " (pr-str (:of n))))
|
||||
(for [[sid sym] (:symbols clip)
|
||||
[id n] (:nodes sym)
|
||||
:when (= :instance (:kind n))
|
||||
:let [target (get-in clip [:symbols (:of n)])
|
||||
active (filter (fn [node]
|
||||
(some :pose-sampled? (vals (:channels node))))
|
||||
(vals (:nodes target)))
|
||||
|
|
@ -247,11 +254,11 @@
|
|||
(map #(vector :node (:id %)) active)))]
|
||||
p (pose/problems (get-in n [:playback :tracks])
|
||||
(:frames target) groups)]
|
||||
(str "timeline " (pr-str tid) " symbol " (pr-str id) ": " p))
|
||||
(for [[tid tl] (:timelines clip)
|
||||
[id n] (:nodes tl)
|
||||
(str "symbol " (pr-str sid) " instance " (pr-str id) ": " p))
|
||||
(for [[sid sym] (:symbols clip)
|
||||
[id n] (:nodes sym)
|
||||
:when (and (= :audio (:kind n)) (:linked-to n)
|
||||
(not (contains? (:nodes tl) (:linked-to n))))]
|
||||
(str "timeline " (pr-str tid) " audio " (pr-str id)
|
||||
(not (contains? (:nodes sym) (:linked-to n))))]
|
||||
(str "symbol " (pr-str sid) " audio " (pr-str id)
|
||||
" links to missing node " (pr-str (:linked-to n))))
|
||||
(feature/problems clip))))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
(ns arthur.domain.feature
|
||||
"Tracked subjects, feature ownership, and eye-pair settings.
|
||||
Features name their timeline explicitly; node ids are local to that timeline."
|
||||
Features name their symbol explicitly; node ids are local to that symbol."
|
||||
(:require [arthur.domain.params :as params]))
|
||||
|
||||
(defn owned
|
||||
|
|
@ -44,14 +44,14 @@
|
|||
clip))
|
||||
|
||||
(defn problems
|
||||
"Check tracked identities and timeline-local node ownership."
|
||||
"Check tracked identities and symbol-local node ownership."
|
||||
[clip]
|
||||
(let [subjects (:subjects clip)
|
||||
features (:features clip)
|
||||
groups (:groups clip)
|
||||
memberships (mapcat (comp :members val) groups)
|
||||
node-owners (for [[_ f] features n (:nodes f)]
|
||||
[(:timeline f) n])]
|
||||
[(:symbol f) n])]
|
||||
(vec
|
||||
(concat
|
||||
(for [[id s] subjects :when (not= id (:id s))]
|
||||
|
|
@ -60,8 +60,8 @@
|
|||
:when (not (params/valid-settings? :subject (or (:params s) {})))]
|
||||
(str "subject " (pr-str id) " has invalid settings"))
|
||||
(for [[id _] subjects
|
||||
:when (not (seq (get-in clip [:timelines id :nodes :head :measured])))]
|
||||
(str "subject " (pr-str id) " has no measured head in its timeline"))
|
||||
:when (not (seq (get-in clip [:symbols id :nodes :head :measured])))]
|
||||
(str "subject " (pr-str id) " has no measured head in its symbol"))
|
||||
(for [[id f] features :when (not= id (:id f))]
|
||||
(str "feature " (pr-str id) " has a different :id"))
|
||||
(for [[id f] features :when (not (contains? subjects (:subject f)))]
|
||||
|
|
@ -72,10 +72,10 @@
|
|||
:when (not (params/valid-settings? (:area f) (or (:params f) {})))]
|
||||
(str "feature " (pr-str id) " has invalid settings for " (pr-str (:area f))))
|
||||
(for [[id f] features
|
||||
:when (not (contains? (:timelines clip) (:timeline f)))]
|
||||
(str "feature " (pr-str id) " names a missing timeline"))
|
||||
:when (not (contains? (:symbols clip) (:symbol f)))]
|
||||
(str "feature " (pr-str id) " names a missing symbol"))
|
||||
(for [[id f] features node-id (:nodes f)
|
||||
:let [owned-nodes (get-in clip [:timelines (:timeline f) :nodes])]
|
||||
:let [owned-nodes (get-in clip [:symbols (:symbol f) :nodes])]
|
||||
:when (not (contains? owned-nodes node-id))]
|
||||
(str "feature " (pr-str id) " refers to missing node " (pr-str node-id)))
|
||||
(for [[id n] (frequencies node-owners) :when (> n 1)]
|
||||
|
|
|
|||
|
|
@ -11,21 +11,21 @@
|
|||
clip/<cid>/timing fps
|
||||
clip/<cid>/stage width, height
|
||||
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>/group/<gid> an eye pair and its shared params
|
||||
clip/<cid>/timeline/<tid> frames, and a palette one day
|
||||
clip/<cid>/timeline/<tid>/node/<nid> kind, parent, stencil, z, time
|
||||
clip/<cid>/timeline/<tid>/channel/<nid>/<prop>
|
||||
clip/<cid>/timeline/<tid>/measured/<nid> the channels a re-freeze owns
|
||||
clip/<cid>/symbol/<sid> frames, and a palette one day
|
||||
clip/<cid>/symbol/<sid>/node/<nid> kind, parent, stencil, z, time
|
||||
clip/<cid>/symbol/<sid>/channel/<nid>/<prop>
|
||||
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
|
||||
and each symbol have their own nodes, so the timeline id is a path segment.
|
||||
The root is `main`, and a symbol's nodes use the same path shape.
|
||||
WHY NODES SIT UNDER A SYMBOL. A clip holds a library of symbols and each has
|
||||
its own nodes, so the symbol id is a path segment. No symbol has a reserved
|
||||
segment: `main` in a path is an id like any other.
|
||||
|
||||
`:frames` MOVED OFF `timing` onto the timeline. A timeline is a frame space and a
|
||||
`:frames` MOVED OFF `timing` onto the symbol. A symbol is a frame space and a
|
||||
clip is a rate, so `timing` holds `:fps` alone. Both used to be in one leaf, which
|
||||
is how a nested timeline's length would have had nowhere to go.
|
||||
is how a nested symbol's length would have had nowhere to go.
|
||||
|
||||
WHY THESE BOUNDARIES. Last-writer-wins only clobbers when its unit is too big,
|
||||
so the cut is chosen so that the things people do simultaneously land on
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
it is one character rather than a scheme."
|
||||
(:require [arthur.domain.clip :as clip]
|
||||
[arthur.domain.sha256 :as sha]
|
||||
[arthur.domain.timeline :as timeline]
|
||||
[arthur.domain.symbol :as symbol]
|
||||
[clojure.string :as str]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
|
@ -124,11 +124,11 @@
|
|||
(when (seq unknown)
|
||||
(throw (ex-info "the clip has a field with no leaf to save it in; see arthur.domain.clip/clip-keys"
|
||||
{:unknown (vec (sort-by str unknown))}))))
|
||||
(doseq [[id tl] (:timelines clip)]
|
||||
(let [unknown (remove timeline/timeline-keys (keys tl))]
|
||||
(doseq [[id sym] (:symbols clip)]
|
||||
(let [unknown (remove symbol/symbol-keys (keys sym))]
|
||||
(when (seq unknown)
|
||||
(throw (ex-info "a timeline has a field with no leaf to save it in; see arthur.domain.timeline/timeline-keys"
|
||||
{:timeline id :unknown (vec (sort-by str unknown))})))))
|
||||
(throw (ex-info "a symbol has a field with no leaf to save it in; see arthur.domain.symbol/symbol-keys"
|
||||
{:symbol id :unknown (vec (sort-by str unknown))})))))
|
||||
(let [at (fn [& parts] (str/join "/" (into ["clip" (segment cid)] parts)))
|
||||
some-leaf (fn [path v] (when (seq v) {path v}))]
|
||||
(apply merge
|
||||
|
|
@ -140,30 +140,30 @@
|
|||
(for [[id v] (:subjects clip)] {(at "subject" (segment id)) v})
|
||||
(for [[id v] (:features clip)] {(at "feature" (segment id)) v})
|
||||
(for [[id v] (:groups clip)] {(at "group" (segment id)) v})
|
||||
;; The timeline's own facts. `:id` is the path segment, so writing it
|
||||
;; The symbol's own facts. `:id` is the path segment, so writing it
|
||||
;; into the value as well would be the one field a rename could
|
||||
;; disagree with itself about; `clip` puts it back.
|
||||
(for [[tid tl] (:timelines clip)]
|
||||
{(at "timeline" (segment tid))
|
||||
(select-keys tl [:frames :palette])})
|
||||
(for [[tid tl] (:timelines clip)
|
||||
[id n] (:nodes tl)]
|
||||
{(at "timeline" (segment tid) "node" (segment id))
|
||||
(for [[sid sym] (:symbols clip)]
|
||||
{(at "symbol" (segment sid))
|
||||
(select-keys sym [:frames :palette])})
|
||||
(for [[sid sym] (:symbols clip)
|
||||
[id n] (:nodes sym)]
|
||||
{(at "symbol" (segment sid) "node" (segment id))
|
||||
(apply dissoc n node-channel-keys)})
|
||||
(for [[tid tl] (:timelines clip)
|
||||
[id n] (:nodes tl)
|
||||
(for [[sid sym] (:symbols clip)
|
||||
[id n] (:nodes sym)
|
||||
:when (seq (:measured n))]
|
||||
{(at "timeline" (segment tid) "measured" (segment id)) (:measured n)})
|
||||
(for [[tid tl] (:timelines clip)
|
||||
[id n] (:nodes tl)
|
||||
{(at "symbol" (segment sid) "measured" (segment id)) (:measured n)})
|
||||
(for [[sid sym] (:symbols clip)
|
||||
[id n] (:nodes sym)
|
||||
[prop ch] (:channels n)]
|
||||
{(at "timeline" (segment tid) "channel" (segment id) (prop->path prop)) ch})))))
|
||||
{(at "symbol" (segment sid) "channel" (segment id) (prop->path prop)) ch})))))
|
||||
|
||||
(defn clip
|
||||
"The inverse of `leaves`, for one clip. Paths belonging to another clip are
|
||||
ignored, so a project's whole leaf map can be handed straight in.
|
||||
|
||||
A timeline's `:id` is restored from its path segment rather than read out of the
|
||||
A symbol's `:id` is restored from its path segment rather than read out of the
|
||||
value, which is why `leaves` does not write it: a segment and a field that both
|
||||
claim to be the id are two places for one fact."
|
||||
[cid leaves]
|
||||
|
|
@ -173,14 +173,14 @@
|
|||
(let [[_ found kind a b c] (str/split path #"/")]
|
||||
(if-not (= want found)
|
||||
acc
|
||||
(if (= "timeline" kind)
|
||||
(let [tid (unsegment a)
|
||||
acc (assoc-in acc [:timelines tid :id] tid)]
|
||||
(if (= "symbol" kind)
|
||||
(let [sid (unsegment a)
|
||||
acc (assoc-in acc [:symbols sid :id] sid)]
|
||||
(case b
|
||||
nil (update-in acc [:timelines tid] merge v)
|
||||
"node" (update-in acc [:timelines tid :nodes (unsegment c)] merge v)
|
||||
"measured" (assoc-in acc [:timelines tid :nodes (unsegment c) :measured] v)
|
||||
"channel" (assoc-in acc [:timelines tid :nodes (unsegment c)
|
||||
nil (update-in acc [:symbols sid] merge v)
|
||||
"node" (update-in acc [:symbols sid :nodes (unsegment c)] merge v)
|
||||
"measured" (assoc-in acc [:symbols sid :nodes (unsegment c) :measured] v)
|
||||
"channel" (assoc-in acc [:symbols sid :nodes (unsegment c)
|
||||
:channels (path->prop (nth (str/split path #"/") 6))]
|
||||
v)
|
||||
(throw (ex-info "not a leaf path" {:path path}))))
|
||||
|
|
@ -210,20 +210,20 @@
|
|||
content-addressed is that it does not have to travel with tier 1 to be found."
|
||||
[leaves]
|
||||
(let [parts (into {} (map (juxt identity #(vec (str/split % #"/")))) (keys leaves))
|
||||
;; A node leaf, by (clip, timeline, node). Under a timeline id, because a
|
||||
;; symbol and the root may both hold a `:mouth` and a channel of one is not
|
||||
;; a channel of the other.
|
||||
;; A node leaf, by (clip, symbol, node). Under a symbol id, because two
|
||||
;; symbols may both hold a `:mouth` and a channel of one is not a channel
|
||||
;; of the other.
|
||||
nodes (into #{} (keep (fn [[_ p]]
|
||||
(when (and (= 6 (count p)) (= "timeline" (nth p 2))
|
||||
(when (and (= 6 (count p)) (= "symbol" (nth p 2))
|
||||
(= "node" (nth p 4)))
|
||||
[(nth p 1) (nth p 3) (nth p 5)])))
|
||||
parts)
|
||||
;; Which segment index holds the kind, and what shapes are legal.
|
||||
legal? (fn [p]
|
||||
(and (= "clip" (first p)) (second p)
|
||||
(if (= "timeline" (nth p 2 nil))
|
||||
(if (= "symbol" (nth p 2 nil))
|
||||
(case (count p)
|
||||
4 true ; the timeline itself
|
||||
4 true ; the symbol itself
|
||||
6 (#{"node" "measured"} (nth p 4))
|
||||
7 (= "channel" (nth p 4))
|
||||
false)
|
||||
|
|
@ -238,13 +238,13 @@
|
|||
:when (not (legal? p))]
|
||||
(str (pr-str path) " is not a leaf path"))
|
||||
(for [[path p] (sort-by key parts)
|
||||
:when (and (legal? p) (= "timeline" (nth p 2 nil)) (>= (count p) 6)
|
||||
:when (and (legal? p) (= "symbol" (nth p 2 nil)) (>= (count p) 6)
|
||||
(#{"channel" "measured"} (nth p 4))
|
||||
(not (contains? nodes [(nth p 1) (nth p 3) (nth p 5)])))]
|
||||
(str (pr-str path) " addresses a node with no node leaf"))
|
||||
(for [[path p] (sort-by key parts)
|
||||
:let [v (get leaves path)]
|
||||
:when (and (legal? p) (= "timeline" (nth p 2 nil)) (= 7 (count p))
|
||||
:when (and (legal? p) (= "symbol" (nth p 2 nil)) (= 7 (count p))
|
||||
(:dense v) (not (sha/key? (:store (:dense v)))))]
|
||||
(str (pr-str path) " names tier 2 as " (pr-str (:store (:dense v)))
|
||||
" — a dense channel in a saved document names a content address"))))))
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@
|
|||
"`:bitmap` is in the vocabulary and not implemented; it is
|
||||
here so that a scene that names one fails as \"not implemented\" rather than as
|
||||
\"not a kind\"."
|
||||
#{:poly :disc :rect :group :bitmap :symbol :audio})
|
||||
#{:poly :disc :rect :group :bitmap :instance :audio})
|
||||
|
||||
(def implemented-kinds #{:poly :disc :rect :group :symbol :audio})
|
||||
(def implemented-kinds #{:poly :disc :rect :group :instance :audio})
|
||||
|
||||
(def xform-paths
|
||||
"In composition order, which is also the order they have to be sampled in.
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
change to this spec silently change what gets drawn."
|
||||
(let [base (into #{[:vis]} xform-paths)]
|
||||
{:group base
|
||||
:symbol base
|
||||
:instance base
|
||||
:audio (into base [[:audio :gain] [:audio :pan] [:audio :rate]])
|
||||
:poly (into base [[:geom :pts] [:style :color]])
|
||||
;; A disc's radius is framed in practice — iris size is a knob, not a
|
||||
|
|
@ -111,7 +111,7 @@
|
|||
it on most frames, so the lead slider reads as doing nothing at exposures above
|
||||
1, which is indistinguishable from the slider being unwired.
|
||||
|
||||
Composed along the parent chain, outermost first, by timeline/eval-frame. Two
|
||||
Composed along the parent chain, outermost first, by symbol/eval-frame. Two
|
||||
rules fall out and they are different rules: exposure INHERITS STRICTLY,
|
||||
because a head cutting on odd frames against a mouth cutting on even ones reads
|
||||
as two performances; offset is PER-NODE by design, because mouth lead applies
|
||||
|
|
@ -122,13 +122,13 @@
|
|||
(if (= mode :inherit)
|
||||
f
|
||||
(do
|
||||
(when (and (not (#{:symbol :audio} (:kind n))) rate (not= rate 1.0) (not= rate 1))
|
||||
(throw (ex-info "time map :rate belongs to a symbol or audio instance"
|
||||
(when (and (not (#{:instance :audio} (:kind n))) rate (not= rate 1.0) (not= rate 1))
|
||||
(throw (ex-info "time map :rate belongs to an instance or an audio node"
|
||||
{:node (:id n) :time (:time n)})))
|
||||
(when (and sample-fps (not (and source-fps (pos? source-fps))))
|
||||
(throw (ex-info "picture sampling needs a positive source fps"
|
||||
{:node (:id n) :time (:time n)})))
|
||||
(cond-> (if (#{:symbol :audio} (:kind n))
|
||||
(cond-> (if (#{:instance :audio} (:kind n))
|
||||
(+ (or in 0) (* (or rate 1) (- f (or at 0))))
|
||||
f)
|
||||
sample-fps (sample-frame source-fps sample-fps)
|
||||
|
|
@ -265,10 +265,10 @@
|
|||
(not (contains? implemented-kinds k)))
|
||||
(conj (str ":kind " k " is in the vocabulary but not implemented"))
|
||||
|
||||
(and (= k :symbol) (nil? (:of n))) (conj "a symbol instance needs :of")
|
||||
(and (= k :instance) (nil? (:of n))) (conj "an instance needs :of")
|
||||
(and (= k :audio) (nil? (get-in n [:source :footage])))
|
||||
(conj "an audio instance needs :source :footage")
|
||||
(and (#{:symbol :audio} k) (some? (get-in n [:time :rate]))
|
||||
(and (#{:instance :audio} k) (some? (get-in n [:time :rate]))
|
||||
(not (pos? (get-in n [:time :rate]))))
|
||||
(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")
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
(ns arthur.domain.paint
|
||||
"Small authored polygon operations. Paint nodes read timeline frames directly;
|
||||
the roto root's exposure and picture sampling must not quantise a hand edit."
|
||||
"Small authored polygon operations, each on a named symbol. Paint nodes read
|
||||
their symbol's frames directly; a roto instance's exposure and picture sampling
|
||||
must not quantise a hand edit."
|
||||
(:require [arthur.domain.channel :as channel]))
|
||||
|
||||
(def geometry [:geom :pts])
|
||||
|
||||
(defn shapes [clip]
|
||||
(->> (get-in clip [:timelines :main :nodes])
|
||||
(defn shapes [clip sid]
|
||||
(->> (get-in clip [:symbols sid :nodes])
|
||||
(filter (fn [[_ node]] (:paint? node)))
|
||||
(sort-by (comp :z val))
|
||||
vec))
|
||||
|
|
@ -15,21 +16,21 @@
|
|||
(let [frames (sort (keys (:keys ch)))]
|
||||
(or (last (take-while #(<= % frame) frames)) (first frames))))
|
||||
|
||||
(defn new-shape [clip id frame points color]
|
||||
(let [end (get-in clip [:timelines :main :frames])
|
||||
(defn new-shape [clip sid id frame points color]
|
||||
(let [end (get-in clip [:symbols sid :frames])
|
||||
z (str "z" (js/Date.now) "-" (name id))]
|
||||
(if (and (<= 0 frame) (< frame end) (>= (count points) 6)
|
||||
(even? (count points)))
|
||||
(assoc-in clip [:timelines :main :nodes id]
|
||||
{:id id :name (str "shape " (inc (count (shapes clip))))
|
||||
(assoc-in clip [:symbols sid :nodes id]
|
||||
{:id id :name (str "shape " (inc (count (shapes clip sid))))
|
||||
:kind :poly :paint? true :parent nil :z z
|
||||
:span [frame end]
|
||||
:channels {geometry (channel/keyed {frame points})
|
||||
[:style :color] (channel/framed color)}})
|
||||
clip)))
|
||||
|
||||
(defn add-key [clip id frame]
|
||||
(let [path [:timelines :main :nodes id]
|
||||
(defn add-key [clip sid id frame]
|
||||
(let [path [:symbols sid :nodes id]
|
||||
node (get-in clip path)
|
||||
ch (get-in node [:channels geometry])
|
||||
[start end] (:span node)]
|
||||
|
|
@ -38,20 +39,20 @@
|
|||
(vec (channel/value-at ch frame)))
|
||||
clip)))
|
||||
|
||||
(defn set-vertex [clip id key-frame vertex [x y]]
|
||||
(let [path [:timelines :main :nodes id :channels geometry :keys key-frame]
|
||||
(defn set-vertex [clip sid id key-frame vertex [x y]]
|
||||
(let [path [:symbols sid :nodes id :channels geometry :keys key-frame]
|
||||
points (get-in clip path)
|
||||
i (* 2 vertex)]
|
||||
(if (and points (< (inc i) (count points)))
|
||||
(assoc-in clip path (-> points (assoc i x) (assoc (inc i) y)))
|
||||
clip)))
|
||||
|
||||
(defn set-segment-interp [clip id key-frame interp]
|
||||
(let [node (get-in clip [:timelines :main :nodes id])
|
||||
(defn set-segment-interp [clip sid id key-frame interp]
|
||||
(let [node (get-in clip [:symbols sid :nodes id])
|
||||
keys (get-in node [:channels geometry :keys])]
|
||||
(if (and (:paint? node) (contains? keys key-frame)
|
||||
(some #(< key-frame %) (clojure.core/keys keys))
|
||||
(#{:hold :linear} interp))
|
||||
(assoc-in clip [:timelines :main :nodes id :channels geometry
|
||||
(assoc-in clip [:symbols sid :nodes id :channels geometry
|
||||
:segments key-frame] interp)
|
||||
clip)))
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@
|
|||
(defn encoder
|
||||
"(fn [raster ramp] -> promise of PNG bytes), for one stage size and one zoom.
|
||||
|
||||
Built once per export rather than per frame, in the shape `timeline/resolver`
|
||||
Built once per export rather than per frame, in the shape `symbol/resolver`
|
||||
already uses: everything that does not change frame to frame is held here. What
|
||||
that buys is the scanline scratch, which at zoom 6 is seven megabytes — a
|
||||
per-frame allocation of that size is the one thing that would make a long export
|
||||
|
|
|
|||
|
|
@ -32,16 +32,17 @@
|
|||
default-frame))
|
||||
|
||||
(defn put-cut
|
||||
"Set one held pose on a symbol instance. Earlier motion stays untouched."
|
||||
[clip instance group at source]
|
||||
(let [node (get-in clip [:timelines :main :nodes instance])
|
||||
symbol (get-in clip [:timelines (:of node)])
|
||||
length (:frames symbol)
|
||||
"Set one held pose on an instance inside symbol `sid`. Earlier motion stays
|
||||
untouched."
|
||||
[clip sid instance group at source]
|
||||
(let [node (get-in clip [:symbols sid :nodes instance])
|
||||
placed (get-in clip [:symbols (:of node)])
|
||||
length (:frames placed)
|
||||
active (filter (fn [n] (some :pose-sampled? (vals (:channels n))))
|
||||
(vals (:nodes symbol)))
|
||||
(vals (:nodes placed)))
|
||||
groups (set (map #(or (:pose-group %) (:id %)) active))
|
||||
ids (set (map :id active))]
|
||||
(when-not (and (= :symbol (:kind node))
|
||||
(when-not (and (= :instance (:kind node))
|
||||
(or (contains? groups group)
|
||||
(and (vector? group) (= 2 (count group))
|
||||
(= :node (first group))
|
||||
|
|
@ -50,17 +51,17 @@
|
|||
(integer? source) (<= 0 source) (< source length))
|
||||
(throw (ex-info "invalid stage pose cut"
|
||||
{:instance instance :group group :at at :source source})))
|
||||
(update-in clip [:timelines :main :nodes instance :playback :tracks group]
|
||||
(update-in clip [:symbols sid :nodes instance :playback :tracks group]
|
||||
#(assoc (or % {}) at source))))
|
||||
|
||||
(defn remove-cut
|
||||
"Remove a cut; an empty track again follows the normal generated motion."
|
||||
[clip instance group at]
|
||||
(let [path [:timelines :main :nodes instance :playback :tracks group]]
|
||||
[clip sid instance group at]
|
||||
(let [path [:symbols sid :nodes instance :playback :tracks group]]
|
||||
(if-let [entries (get-in clip path)]
|
||||
(if-let [remaining (not-empty (dissoc entries at))]
|
||||
(assoc-in clip path remaining)
|
||||
(update-in clip [:timelines :main :nodes instance :playback :tracks]
|
||||
(update-in clip [:symbols sid :nodes instance :playback :tracks]
|
||||
dissoc group))
|
||||
clip)))
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@
|
|||
(:require [arthur.domain.leaf :as leaf]
|
||||
[arthur.domain.wire :as wire]))
|
||||
|
||||
(def schema-version
|
||||
"The stored document format this client reads and writes. 2 is symbols: leaf
|
||||
paths say `symbol`, a placing node is `:kind :instance`, and no symbol id is
|
||||
reserved. `clips/migrations/0007` moved every saved project from 1."
|
||||
2)
|
||||
|
||||
(defn block-keys
|
||||
"Every tier-2 key a leaf map names, in a stable order."
|
||||
[leaves]
|
||||
|
|
@ -53,8 +59,8 @@
|
|||
round-trip a clip through `JSON.parse(JSON.stringify(...))` and be running the
|
||||
same conversion the network runs, rather than a CLJS-shaped rehearsal of it. The
|
||||
one thing a keywordising `js->clj` would quietly break is the leaf paths —
|
||||
`:clip/c1/timeline/main/node/mouth` is a keyword whose `name` is
|
||||
\"c1/timeline/main/node/mouth\", so the
|
||||
`:clip/c1/symbol/main/node/mouth` is a keyword whose `name` is
|
||||
\"c1/symbol/main/node/mouth\", so the
|
||||
\"clip/\" would be lost on the way back in.
|
||||
|
||||
Refuses a document `domain/leaf` calls unaddressable, which is where a hand-made
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
edge landing exactly on a pixel boundary resolves consistently.
|
||||
|
||||
Flat and preallocated because this is the per-frame path: fixed topology means
|
||||
a node's vertex count is known at freeze time, so timeline/resolver hands the same
|
||||
a node's vertex count is known at freeze time, so symbol/resolver hands the same
|
||||
buffer back every frame and a frame allocates nothing. At 30fps per-frame
|
||||
allocation is the only thing that will make this stutter.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +1,36 @@
|
|||
(ns arthur.domain.timeline
|
||||
"A TIMELINE: an ordered bag of nodes in its own frame space, and the two ways to
|
||||
(ns arthur.domain.symbol
|
||||
"A SYMBOL: an ordered bag of nodes in its own frame space, and the two ways to
|
||||
evaluate it at a frame.
|
||||
|
||||
{:id :main :frames 229 :nodes {id -> node} :palette nil}
|
||||
|
||||
That is the whole type, and EVERYTHING THAT HOLDS NODES IS ONE OF THESE. A
|
||||
clip's root timeline is one; a symbol in the library is one; a `:kind :symbol`
|
||||
node is an INSTANCE of one. An earlier arrangement had the clip's node tree and
|
||||
a library symbol as two structures with the same fields and never said they were
|
||||
the same thing — the clip map carried `:fps`, `:width`, `:height`, `:analysis`
|
||||
and the tracking identities alongside `:nodes`, so a symbol had nowhere to live
|
||||
that was not a clip with seven meaningless fields. Flash's `_root` is a
|
||||
MovieClip and After Effects' pre-comp is just a layer; collapsing them is what
|
||||
makes nesting arbitrary and free rather than a feature to be added.
|
||||
That is the whole type, and EVERYTHING THAT HOLDS NODES IS ONE OF THESE. What
|
||||
a document opens on is a symbol; what a `:kind :instance` node places is a
|
||||
symbol; there is no second structure. An earlier arrangement had a root node
|
||||
tree and a library entry as two structures with the same fields and never said
|
||||
they were the same thing. Flash's `_root` is a MovieClip and After Effects'
|
||||
pre-comp is just a layer; collapsing them is what makes nesting arbitrary and
|
||||
free rather than a feature to be added.
|
||||
|
||||
The clip-level facts are in `arthur.domain.clip`. A timeline has a FRAME SPACE,
|
||||
The clip-level facts are in `arthur.domain.clip`. A symbol has a FRAME SPACE,
|
||||
not a rate and not a size: `:fps` is the clip's, because a rate is a fact about
|
||||
how fast the whole thing plays, and a nested timeline cannot have its own.
|
||||
how fast the whole thing plays, and a nested symbol cannot have its own.
|
||||
|
||||
TWO AXES OF NESTING, and conflating them is why \"nested\" and \"flat with parent
|
||||
pointers\" sound contradictory when they are not. Parent/child is transform
|
||||
composition WITHIN one timeline and is stored flat with pointers. Instance is a
|
||||
timeline inside another timeline and is stored by reference into the library.
|
||||
Each timeline is flat; timelines nest. Every argument for flat storage —
|
||||
composition WITHIN one symbol and is stored flat with pointers. Instance is a
|
||||
symbol inside another symbol and is stored by reference into the library.
|
||||
Each symbol is flat; symbols nest. Every argument for flat storage —
|
||||
addressability, one-field reparenting, structural sharing, per-node sync leaves —
|
||||
is about the first axis and is untouched by the second.
|
||||
|
||||
Two ways to evaluate one at a frame:
|
||||
|
||||
(eval-frame tl f store) THE SPECIFICATION. Allocating, order-free,
|
||||
(eval-frame sym f store) THE SPECIFICATION. Allocating, order-free,
|
||||
obviously correct. Use it in tests and for a
|
||||
one-off render.
|
||||
|
||||
(resolver tl store) -> (fn [f] ops). What playback uses. Caches the
|
||||
(resolver sym store) -> (fn [f] ops). What playback uses. Caches the
|
||||
topological order and the z paths, holds one
|
||||
CURSOR per channel and one PREALLOCATED point
|
||||
buffer per node, so a frame allocates the op
|
||||
|
|
@ -42,7 +40,7 @@
|
|||
read and where points are written. That is deliberate: two independent
|
||||
implementations of frame evaluation would drift, and the drift would look like
|
||||
a rendering bug rather than like two functions disagreeing. What differs
|
||||
between them is exactly the part that can be wrong, and timeline-test asserts
|
||||
between them is exactly the part that can be wrong, and symbol-test asserts
|
||||
they agree frame for frame in forward, backward and random order.
|
||||
|
||||
The output is a list of DRAW OPS, and it is the boundary with the rasteriser:
|
||||
|
|
@ -76,12 +74,12 @@
|
|||
(when-let [p (:parent (get nodes i))]
|
||||
(if (contains? nodes p)
|
||||
p
|
||||
(throw (ex-info "node's :parent is not in the timeline"
|
||||
(throw (ex-info "node's :parent is not in the symbol"
|
||||
{:node i :parent p})))))
|
||||
chain (into [] (comp (take-while some?) (take (inc (count nodes))))
|
||||
(iterate up id))]
|
||||
(when (> (count chain) (count nodes))
|
||||
(throw (ex-info "parent cycle in timeline" {:node id :chain chain})))
|
||||
(throw (ex-info "parent cycle in symbol" {:node id :chain chain})))
|
||||
chain))
|
||||
|
||||
(defn depth
|
||||
|
|
@ -129,9 +127,9 @@
|
|||
"id -> its position in draw order.
|
||||
|
||||
Computed ONCE. Draw order is a function of the z paths, which are structural —
|
||||
they change when the timeline changes and never because the playhead moved — so
|
||||
they change when the symbol changes and never because the playhead moved — so
|
||||
sorting ops by z on every frame was re-deriving a constant thirty times a
|
||||
second. Here it is derived when the timeline is, and a frame sorts small integers.
|
||||
second. Here it is derived when the symbol is, and a frame sorts small integers.
|
||||
|
||||
`sort-by` is stable and `ord` is topological, so nodes sharing a z path keep
|
||||
parent-before-child order without a tiebreak field on every op."
|
||||
|
|
@ -146,9 +144,9 @@
|
|||
"Tone keyword -> the index the raster writes, in a given palette.
|
||||
|
||||
`palette` is a map of tone -> index. It is a PARAMETER, not a global: a tone
|
||||
names which mark this is, and which ramp it is read in belongs to the timeline
|
||||
names which mark this is, and which ramp it is read in belongs to the symbol
|
||||
the node sits in, so resolution cannot reach for one ambient answer. Today
|
||||
there is one palette and it is passed in anyway; when timelines carry a
|
||||
there is one palette and it is passed in anyway; when symbols carry a
|
||||
`:palette` channel, the walk carries the palette in scope exactly as it already
|
||||
carries the parent transform and the local frame.
|
||||
|
||||
|
|
@ -229,7 +227,7 @@
|
|||
flow/freeze writes it KEYED, because a threshold crossing is a handful of
|
||||
transitions and hold is the default, and because a human has to be able to fix
|
||||
one frame of it. When something does want a dense one it will land here loudly
|
||||
instead of blanking the timeline.
|
||||
instead of blanking the symbol.
|
||||
|
||||
Absence is not a boolean and is not an error: a subject that is not on the
|
||||
frame has nothing to show."
|
||||
|
|
@ -271,13 +269,13 @@
|
|||
:rd rd})))))))
|
||||
|
||||
(defn- emit
|
||||
"Emit geometry in the timeline's space. Rect sizes stay fractional until
|
||||
"Emit geometry in the symbol's space. Rect sizes stay fractional until
|
||||
rasterization, so enclosing symbol transforms can still scale them."
|
||||
[{:keys [palette buf-for]} n {:keys [m rd]} base]
|
||||
(let [colour #(colour-index palette (rd [:style :color]))]
|
||||
(case (:kind n)
|
||||
:group nil
|
||||
:symbol nil
|
||||
:instance nil
|
||||
:audio nil
|
||||
|
||||
:poly
|
||||
|
|
@ -311,20 +309,20 @@
|
|||
{:node (:id n) :kind (:kind n)})))))
|
||||
|
||||
(defn- nodes-of
|
||||
"The timeline's node map, REFUSING a map that has none.
|
||||
"The symbol's node map, REFUSING a map that has none.
|
||||
|
||||
A clip and a timeline both have an `:id` and both are maps, so handing a CLIP to
|
||||
A clip and a symbol both have an `:id` and both are maps, so handing a CLIP to
|
||||
an evaluator is the one mistake this type split makes easy — and the result is
|
||||
not an error, it is `(:nodes clip)` being nil and a frame resolving to no ops at
|
||||
all. That reads as a black stage, or, in a benchmark, as \"0 nodes\" and a
|
||||
flattering number. It happened once while the split was being made, which is why
|
||||
this is a guard and not a comment."
|
||||
[tl]
|
||||
(let [nodes (:nodes tl)]
|
||||
[sym]
|
||||
(let [nodes (:nodes sym)]
|
||||
(when-not (map? nodes)
|
||||
(throw (ex-info (str "not a timeline: :nodes is " (pr-str nodes)
|
||||
" — a clip is not a timeline, its `:timelines` hold them")
|
||||
{:keys (vec (sort-by str (keys tl)))})))
|
||||
(throw (ex-info (str "not a symbol: :nodes is " (pr-str nodes)
|
||||
" — a clip is not a symbol, its `:symbols` hold them")
|
||||
{:keys (vec (sort-by str (keys sym)))})))
|
||||
nodes))
|
||||
|
||||
(defn- channel-frame
|
||||
|
|
@ -386,18 +384,18 @@
|
|||
;; the specification
|
||||
|
||||
(defn eval-frame
|
||||
"Timeline at frame f -> draw ops in z order. Pure, and allocates freely.
|
||||
"Symbol at frame f -> draw ops in z order. Pure, and allocates freely.
|
||||
|
||||
`f` is in THIS timeline's frame space. At the clip's root that is clip frames;
|
||||
inside an instance it is the instance's own space, and the instance boundary is
|
||||
`f` is in THIS symbol's frame space. For the symbol on screen that is the
|
||||
transport's frame; inside an instance it is the instance's own space, and the instance boundary is
|
||||
the only place the space changes.
|
||||
|
||||
This is the definition of what a frame means. `resolver` is what plays it."
|
||||
([tl f] (eval-frame tl f nil pal/index-of))
|
||||
([tl f store] (eval-frame tl f store pal/index-of))
|
||||
([tl f store palette] (eval-frame tl f store palette nil nil))
|
||||
([tl f store palette pose-tracks opts]
|
||||
(let [nodes (nodes-of tl)
|
||||
([sym f] (eval-frame sym f nil pal/index-of))
|
||||
([sym f store] (eval-frame sym f store pal/index-of))
|
||||
([sym f store palette] (eval-frame sym f store palette nil nil))
|
||||
([sym f store palette pose-tracks opts]
|
||||
(let [nodes (nodes-of sym)
|
||||
choices (pose/prepare pose-tracks)
|
||||
anchors (prepared-anchors nodes)
|
||||
{:keys [source-fps picture-fps]} opts
|
||||
|
|
@ -456,12 +454,12 @@
|
|||
The op maps themselves are allocated fresh, and deliberately: there are a dozen
|
||||
of them per frame against hundreds of points, so pooling them would buy
|
||||
nothing and cost the ability to hand an op list around as plain data."
|
||||
([tl] (resolver tl nil pal/index-of nil nil))
|
||||
([tl store] (resolver tl store pal/index-of nil nil))
|
||||
([tl store palette] (resolver tl store palette nil nil))
|
||||
([tl store palette pose-tracks] (resolver tl store palette pose-tracks nil))
|
||||
([tl store palette pose-tracks {:keys [source-fps picture-fps]}]
|
||||
(let [nodes (nodes-of tl)
|
||||
([sym] (resolver sym nil pal/index-of nil nil))
|
||||
([sym store] (resolver sym store pal/index-of nil nil))
|
||||
([sym store palette] (resolver sym store palette nil nil))
|
||||
([sym store palette pose-tracks] (resolver sym store palette pose-tracks nil))
|
||||
([sym store palette pose-tracks {:keys [source-fps picture-fps]}]
|
||||
(let [nodes (nodes-of sym)
|
||||
choices (pose/prepare pose-tracks)
|
||||
anchors (prepared-anchors nodes)
|
||||
ord (order nodes)
|
||||
|
|
@ -507,20 +505,20 @@
|
|||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def timeline-keys
|
||||
"Every field a timeline may carry, and the reason `arthur.domain.leaf` refuses
|
||||
(def symbol-keys
|
||||
"Every field a symbol may carry, and the reason `arthur.domain.leaf` refuses
|
||||
one it does not know: a field added without a leaf to save it in is a field that
|
||||
saves silently and comes back missing.
|
||||
|
||||
`:palette` is in the vocabulary and nothing writes one yet. A timeline is where
|
||||
a ramp belongs — `domain/timeline` takes the palette as a PARAMETER rather than
|
||||
reaching for a global precisely so that a nested timeline can carry its own —
|
||||
`:palette` is in the vocabulary and nothing writes one yet. A symbol is where
|
||||
a ramp belongs — `domain/symbol` takes the palette as a PARAMETER rather than
|
||||
reaching for a global precisely so that a nested symbol can carry its own —
|
||||
and leaving the field out would make the first one a migration instead of a
|
||||
write."
|
||||
#{:id :frames :nodes :palette})
|
||||
|
||||
(defn problems
|
||||
"Human-readable reasons this timeline will not evaluate. Empty means it will.
|
||||
"Human-readable reasons this symbol will not evaluate. Empty means it will.
|
||||
|
||||
Node structure only. The tracking identities — subjects, features, groups — are
|
||||
the CLIP's and are checked by `arthur.domain.clip/problems`, which is not a
|
||||
|
|
@ -530,8 +528,8 @@
|
|||
Total by construction — it reports a cycle rather than looping on one — because
|
||||
its whole job is to be safe to run over authored data before that data is
|
||||
trusted."
|
||||
[tl]
|
||||
(let [nodes (:nodes tl)]
|
||||
[sym]
|
||||
(let [nodes (:nodes sym)]
|
||||
(if-not (map? nodes)
|
||||
[":nodes must be a map of id -> node"]
|
||||
(-> []
|
||||
|
|
@ -541,11 +539,11 @@
|
|||
(into (for [[id n] nodes
|
||||
:when (and (:parent n) (not (contains? nodes (:parent n))))]
|
||||
(str "node " (pr-str id) " has :parent " (pr-str (:parent n))
|
||||
" which is not in the timeline")))
|
||||
" which is not in the symbol")))
|
||||
(into (for [[id n] nodes
|
||||
:when (and (:stencil n) (not (contains? nodes (:stencil n))))]
|
||||
(str "node " (pr-str id) " has :stencil " (pr-str (:stencil n))
|
||||
" which is not in the timeline")))
|
||||
" which is not in the symbol")))
|
||||
(into (for [[id n] nodes
|
||||
p (node/problems n)]
|
||||
(str "node " (pr-str id) ": " p)))
|
||||
|
|
@ -554,19 +552,19 @@
|
|||
:let [anchors (:anchors n)]
|
||||
:when (some? anchors)
|
||||
:when (not (and (map? anchors) (contains? anchors 0)
|
||||
(integer? (:frames tl))
|
||||
(integer? (:frames sym))
|
||||
(every? #(and (integer? %) (<= 0 %)
|
||||
(< % (:frames tl)))
|
||||
(< % (:frames sym)))
|
||||
(concat (keys anchors) (vals anchors)))
|
||||
(seq (:measured n))
|
||||
(= (:channels n) (:measured n))))]
|
||||
(str "node " (pr-str id)
|
||||
": :anchors must start at frame 0, name valid measured frames, and read that node's own measured channels")))
|
||||
(into (for [k (remove timeline-keys (keys tl))]
|
||||
(str "timeline has a field with no leaf to save it in: " (pr-str k))))
|
||||
(into (when-not (or (nil? (:frames tl)) (and (integer? (:frames tl)) (pos? (:frames tl))))
|
||||
[(str ":frames is " (pr-str (:frames tl))
|
||||
" — a timeline is a frame SPACE, so its length is a positive integer")]))
|
||||
(into (for [k (remove symbol-keys (keys sym))]
|
||||
(str "symbol has a field with no leaf to save it in: " (pr-str k))))
|
||||
(into (when-not (or (nil? (:frames sym)) (and (integer? (:frames sym)) (pos? (:frames sym))))
|
||||
[(str ":frames is " (pr-str (:frames sym))
|
||||
" — a symbol is a frame SPACE, so its length is a positive integer")]))
|
||||
(into (try
|
||||
(doall (map #(depth nodes %) (keys nodes)))
|
||||
nil
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"Export, as intents and one effect.
|
||||
|
||||
The walk is not an event and must not become one: it is a promise chain that
|
||||
runs for as long as the timeline is long, and re-frame events are the wrong unit
|
||||
runs for as long as the symbol is long, and re-frame events are the wrong unit
|
||||
for something with a middle. So `::start` collects what the render needs out of
|
||||
the db and hands it to an fx, and the fx dispatches progress back — the same
|
||||
arrangement `events/project`'s save uses, and for the same reason.
|
||||
|
|
@ -44,84 +44,88 @@
|
|||
(defn target-value
|
||||
"An export target as a `<select>` option value.
|
||||
|
||||
Two kinds, told apart by a leading letter: `t:<timeline>` is a whole timeline,
|
||||
`n:<timeline>:<node>` is one placement inside one. The parts are joined with `:`
|
||||
because neither a timeline id nor a uuid contains one.
|
||||
Two kinds, told apart by a leading letter: `s:<symbol>` is a whole symbol,
|
||||
`n:<symbol>:<node>` is one instance inside one. The parts are joined with `:`
|
||||
because neither a symbol id nor a uuid contains one.
|
||||
|
||||
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
|
||||
is not a key in `:timelines`, so the plan silently becomes nil and the export
|
||||
throws \"there is no such timeline\" from inside re-frame's `:do-fx`. That
|
||||
is not a key in `:symbols`, so the plan silently becomes nil and the export
|
||||
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
|
||||
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."
|
||||
[{:keys [timeline isolate]}]
|
||||
(let [tl (subs (str (or timeline :main)) 1)]
|
||||
(if isolate (str "n:" tl ":" isolate) (str "t:" tl))))
|
||||
[{sid :symbol isolate :isolate}]
|
||||
(let [s (subs (str sid) 1)]
|
||||
(if isolate (str "n:" s ":" isolate) (str "s:" s))))
|
||||
|
||||
(defn target-id
|
||||
"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."
|
||||
[v]
|
||||
(let [[kind tl node] (str/split v #":")]
|
||||
(cond-> {:timeline (keyword tl)}
|
||||
(let [[kind s node] (str/split v #":")]
|
||||
(cond-> {:symbol (keyword s)}
|
||||
(= "n" kind) (assoc :isolate (uuid node)))))
|
||||
|
||||
(defn targets
|
||||
"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
|
||||
timeline is the DRAWING — one file however many times it is placed, in its own
|
||||
frame space. A placement is that drawing WHERE IT SITS: the stage's length and
|
||||
rate, with the other placements removed, which is why seven instances of one
|
||||
symbol are seven different exports rather than seven copies of one.
|
||||
TWO KINDS, and the distinction is the point. A symbol is the DRAWING — one file
|
||||
however many times it is placed, in its own frame space. An instance is that
|
||||
drawing WHERE IT SITS in the open symbol: that symbol's length and rate, with
|
||||
the other instances removed, which is why seven instances of one symbol are
|
||||
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."
|
||||
[clip]
|
||||
(let [libs (cons :main (sort-by str (remove #{:main} (keys (:timelines clip)))))
|
||||
placements (->> (get-in clip [:timelines :main :nodes])
|
||||
(filter (comp #{:symbol} :kind val))
|
||||
[clip open]
|
||||
(let [instances (->> (get-in clip [:symbols open :nodes])
|
||||
(filter (comp #{:instance} :kind val))
|
||||
(sort-by (fn [[id n]] [(or (:name n) "") (str id)])))]
|
||||
(into (mapv (fn [tid]
|
||||
{:timeline tid
|
||||
:label (if (= :main tid) "main (the clip)" (name tid))})
|
||||
libs)
|
||||
(into (mapv (fn [sid] {:symbol sid :label (name sid)})
|
||||
(sort-by str (keys (:symbols clip))))
|
||||
(mapv (fn [[id n]]
|
||||
{:timeline :main :isolate id
|
||||
{:symbol open :isolate 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
|
||||
"The label of the target `db` currently points at, for the filename."
|
||||
[clip {:keys [timeline isolate]}]
|
||||
(:label (or (first (filter #(and (= timeline (:timeline %))
|
||||
(= isolate (:isolate %)))
|
||||
(targets clip)))
|
||||
{:label (some-> timeline name)})))
|
||||
[clip open {sid :symbol isolate :isolate}]
|
||||
(:label (or (first (filter #(and (= sid (:symbol %)) (= isolate (:isolate %)))
|
||||
(targets clip open)))
|
||||
{:label (some-> sid name)})))
|
||||
|
||||
(rf/reg-sub ::state (fn [db _] (:export db)))
|
||||
(rf/reg-sub ::state (fn [db _] (assoc (:export db) :target (target db))))
|
||||
|
||||
(rf/reg-sub
|
||||
::targets
|
||||
(fn [db _]
|
||||
(targets (:clip (store/entry (:clip/current db))))))
|
||||
(targets (:clip (store/entry (:clip/current db))) (get-in db [:ui :open]))))
|
||||
|
||||
(rf/reg-sub
|
||||
::plan
|
||||
(fn [db _]
|
||||
(let [{:keys [clip]} (store/entry (:clip/current db))
|
||||
{:keys [timeline zoom isolate]} (:export db)]
|
||||
(export/plan {:clip clip :timeline timeline :zoom zoom :isolate isolate
|
||||
{sid :symbol isolate :isolate} (target db)]
|
||||
(export/plan {:clip clip :symbol sid :zoom (get-in db [:export :zoom])
|
||||
:isolate isolate
|
||||
:picture-fps (get-in db [:clip :display-fps])}))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::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.
|
||||
(fn [db [_ {:keys [timeline isolate]}]]
|
||||
(update db :export merge {:timeline (or timeline :main) :isolate isolate})))
|
||||
(fn [db [_ {sid :symbol isolate :isolate}]]
|
||||
(update db :export merge {:symbol sid :isolate isolate})))
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-zoom
|
||||
|
|
@ -134,16 +138,17 @@
|
|||
{}
|
||||
(let [id (:clip/current db)
|
||||
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
|
||||
:total (:frames (export/plan
|
||||
{:clip (:clip entry)
|
||||
:timeline timeline
|
||||
:symbol sid
|
||||
:isolate isolate
|
||||
:zoom zoom}))
|
||||
:status "rendering…"})
|
||||
::run! {:clip (:clip entry)
|
||||
:timeline timeline
|
||||
:symbol sid
|
||||
:isolate isolate
|
||||
:store (:store entry)
|
||||
;; The same palette and ramp the preview resolves and blits
|
||||
|
|
@ -156,8 +161,8 @@
|
|||
:picture-fps (get-in db [:clip :display-fps])
|
||||
:audio-url (:audio entry)
|
||||
:name (stem (:label entry)
|
||||
(label-of (:clip entry)
|
||||
{:timeline timeline :isolate isolate}))}}))))
|
||||
(label-of (:clip entry) (get-in db [:ui :open])
|
||||
{:symbol sid :isolate isolate}))}}))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::progress
|
||||
|
|
@ -197,7 +202,7 @@
|
|||
::run!
|
||||
(fn [spec]
|
||||
;; 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
|
||||
;; `.catch` below, so `::failed` never dispatches and `:busy?` stays true: the
|
||||
;; button sits disabled on \"rendering…\" and the readout on \"frame 0 /\"
|
||||
|
|
|
|||
|
|
@ -142,7 +142,6 @@
|
|||
source-blocks (source/pack-subjects (:id (:analysis built)) subjects)
|
||||
_ (mark! "build-clip: pack source blocks")]
|
||||
(assoc (select-keys built [:fps :width :height])
|
||||
:frames (clip/frames built)
|
||||
:display-fps (:fps built)
|
||||
:clip built :store (:store frozen)
|
||||
:source-blocks source-blocks
|
||||
|
|
@ -389,11 +388,7 @@
|
|||
::loaded
|
||||
(fn [{:keys [db]} [_ id summary]]
|
||||
(let [clip (store/entry id)]
|
||||
{:db (-> db
|
||||
(assoc :clip/current id
|
||||
:clip (select-keys clip [:fps :frames :width :height :audio :display-fps])
|
||||
:footage (assoc (:footage db) :id id :label (:label clip)
|
||||
:loading? false :status summary))
|
||||
(assoc-in [:playback :frame] 0)
|
||||
(assoc-in [:playback :playing?] false))
|
||||
{:db (-> (pb/show db id)
|
||||
(assoc :footage (assoc (:footage db) :id id :label (:label clip)
|
||||
:loading? false :status summary)))
|
||||
::pb/pause! nil})))
|
||||
|
|
|
|||
|
|
@ -7,20 +7,20 @@
|
|||
|
||||
(rf/reg-event-db
|
||||
::new-shape
|
||||
(fn [db [_ id points color]]
|
||||
(edit/edit db #(paint/new-shape % id (get-in db [:playback :frame]) points color))))
|
||||
(fn [db [_ sid id points color]]
|
||||
(edit/edit db #(paint/new-shape % sid id (get-in db [:playback :frame]) points color))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::add-key
|
||||
(fn [db [_ id]]
|
||||
(edit/edit db #(paint/add-key % id (get-in db [:playback :frame])))))
|
||||
(fn [db [_ sid id]]
|
||||
(edit/edit db #(paint/add-key % sid id (get-in db [:playback :frame])))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-vertex
|
||||
(fn [db [_ id key-frame vertex point]]
|
||||
(edit/edit db #(paint/set-vertex % id key-frame vertex point))))
|
||||
(fn [db [_ sid id key-frame vertex point]]
|
||||
(edit/edit db #(paint/set-vertex % sid id key-frame vertex point))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-segment-interp
|
||||
(fn [db [_ id key-frame interp]]
|
||||
(edit/edit db #(paint/set-segment-interp % id key-frame interp))))
|
||||
(fn [db [_ sid id key-frame interp]]
|
||||
(edit/edit db #(paint/set-segment-interp % sid id key-frame interp))))
|
||||
|
|
|
|||
|
|
@ -11,11 +11,33 @@
|
|||
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`."
|
||||
(:require [arthur.clock :as clock]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.footage.store :as footage]
|
||||
[re-frame.core :as rf]))
|
||||
|
||||
(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
|
||||
::tick
|
||||
|
|
@ -99,21 +121,15 @@
|
|||
;; 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
|
||||
;; frame the new clip may not have.
|
||||
(let [{:keys [fps frames] :as clip} (footage/entry id)]
|
||||
{:db (-> db
|
||||
(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]))
|
||||
(let [{:keys [label cid]} (footage/entry id)
|
||||
db (-> (show db id)
|
||||
;; 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
|
||||
;; the last one — saving a fixture must create a document of its
|
||||
;; own, not overwrite whatever happened to be open before it.
|
||||
(assoc :project {:id nil :cid (:cid clip) :name (:label clip)
|
||||
;; project on the server, so this CLEARS the id rather than
|
||||
;; keeping the last one — saving a fixture must create a
|
||||
;; document of its own, not overwrite whatever was open before.
|
||||
(assoc :project {:id nil :cid cid :name label
|
||||
:seq nil :busy? false
|
||||
:status "built-in example · not a saved project"})
|
||||
(assoc-in [:playback :frame] 0)
|
||||
(assoc-in [:playback :playing?] false))
|
||||
:status "built-in example · not a saved project"}))]
|
||||
{:db db
|
||||
::pause! nil
|
||||
::seek! [fps frames 0]})))
|
||||
::seek! [(fps db) (frames db) 0]})))
|
||||
|
|
|
|||
|
|
@ -109,13 +109,13 @@
|
|||
built (:clip loaded)]
|
||||
(let [entry (merge (select-keys built [:fps :width :height])
|
||||
{:label (str (or (.-name clip-json) cid) " (saved)")
|
||||
:cid cid :frames (clip/frames built)
|
||||
:cid cid
|
||||
:display-fps (:fps built)
|
||||
:clip built :store (:store loaded)
|
||||
:footage-id footage-id
|
||||
:audio (if footage (.-audio footage)
|
||||
"/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)))))))))))
|
||||
|
||||
(rf/reg-fx
|
||||
|
|
@ -188,6 +188,11 @@
|
|||
(.then (fn [^js row] (http/GET (str "/api/projects/" (.-id row)))))
|
||||
(.then (fn [^js 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
|
||||
(throw (ex-info "that project has no clips" {})))
|
||||
(-> (opened-entry! clip-json)
|
||||
|
|
@ -213,9 +218,9 @@
|
|||
(.then (fn [entry]
|
||||
(let [built (stage/compose (:clip entry))
|
||||
entry (assoc entry :clip built :label (:name built)
|
||||
:cid "stage-8625" :frames (clip/frames built)
|
||||
:cid "stage-8625"
|
||||
: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]
|
||||
(rf/dispatch
|
||||
[::stage-opened
|
||||
|
|
@ -379,7 +384,6 @@
|
|||
;; it, and two untitled documents saved from two tabs are two documents.
|
||||
:cid (str (random-uuid))
|
||||
:display-fps (:fps c)
|
||||
:frames (clip/frames c)
|
||||
:fps (:fps c) :width (:width c) :height (:height c)}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
|
|
@ -390,14 +394,10 @@
|
|||
;; for, and the built-in scenes are rows in the media pool like anything else.
|
||||
(let [entry (blank-entry)
|
||||
id (store/install! entry "new")]
|
||||
{:db (-> db
|
||||
(assoc :clip/current id
|
||||
:clip (select-keys entry [:fps :frames :width :height :audio :display-fps]))
|
||||
{:db (-> (assoc db :ui (:ui db/default))
|
||||
(pb/show id)
|
||||
(assoc :project {:id nil :cid (:cid entry) :name nil :seq nil
|
||||
:busy? false :status "new document"})
|
||||
(assoc :ui (:ui db/default))
|
||||
(assoc-in [:playback :frame] 0)
|
||||
(assoc-in [:playback :playing?] false))
|
||||
:busy? false :status "new document"}))
|
||||
::pb/pause! nil})))
|
||||
|
||||
(rf/reg-event-fx
|
||||
|
|
@ -447,15 +447,11 @@
|
|||
(rf/reg-event-fx
|
||||
::stage-opened
|
||||
(fn [{:keys [db]} [_ clip-id]]
|
||||
(let [entry (store/entry clip-id)]
|
||||
{:db (-> db
|
||||
(assoc :clip/current clip-id
|
||||
:clip (select-keys entry [:fps :frames :width :height :audio :display-fps]))
|
||||
(let [db (-> (pb/show db clip-id)
|
||||
(assoc :project {:id nil :cid nil :name nil :seq nil
|
||||
:busy? false :status "loaded 8625 stage study"})
|
||||
(assoc-in [:playback :frame] 0)
|
||||
(assoc-in [:playback :playing?] false))
|
||||
::pb/seek! [(:fps entry) (:frames entry) 0]})))
|
||||
:busy? false :status "loaded 8625 stage study"}))]
|
||||
{:db db
|
||||
::pb/seek! [(get-in db [:clip :fps]) (pb/frames db) 0]})))
|
||||
|
||||
(rf/reg-event-db
|
||||
::saved
|
||||
|
|
@ -469,17 +465,13 @@
|
|||
(rf/reg-event-fx
|
||||
::opened
|
||||
(fn [{:keys [db]} [_ clip-id project-id name seq]]
|
||||
(let [clip (store/entry clip-id)]
|
||||
{:db (-> db
|
||||
(assoc :clip/current clip-id
|
||||
:clip (select-keys clip [:fps :frames :width :height :audio :display-fps]))
|
||||
{:db (-> (pb/show db clip-id)
|
||||
(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
|
||||
:status (str "opened " name " r" seq)})
|
||||
(assoc-in [:playback :frame] 0)
|
||||
(assoc-in [:playback :playing?] false))
|
||||
::pb/pause! nil})))
|
||||
:status (str "opened " name " r" seq)}))
|
||||
::pb/pause! nil}))
|
||||
|
||||
(rf/reg-event-db
|
||||
::failed
|
||||
|
|
|
|||
|
|
@ -58,9 +58,10 @@
|
|||
;; 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
|
||||
;; log ever has to reproduce a document exactly, this becomes a cofx.
|
||||
(let [id (keyword (str "paint-" (random-uuid)))]
|
||||
{:db (update db :ui merge {:tool nil :draft [] :selection [:node :main id]})
|
||||
:dispatch [::paint/new-shape id draft (get-in db [:ui :tone])]})))))
|
||||
(let [id (keyword (str "paint-" (random-uuid)))
|
||||
sid (get-in db [:ui :open])]
|
||||
{: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
|
||||
::set-knob
|
||||
|
|
@ -72,8 +73,9 @@
|
|||
|
||||
(rf/reg-event-db
|
||||
::place-symbol
|
||||
(fn [db [_ tid [x y]]]
|
||||
(let [uuid (random-uuid)]
|
||||
(fn [db [_ sid [x y]]]
|
||||
(let [uuid (random-uuid)
|
||||
into (get-in db [:ui :open])]
|
||||
(-> db
|
||||
(edit/edit #(clip/place-symbol % tid (get-in db [:playback :frame]) uuid [x y]))
|
||||
(assoc-in [:ui :selection] [:node :main uuid])))))
|
||||
(edit/edit #(clip/place-symbol % into sid (get-in db [:playback :frame]) uuid [x y]))
|
||||
(assoc-in [:ui :selection] [:node into uuid])))))
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
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
|
||||
work: rooting the resolver at the chosen timeline, generated-channel picture
|
||||
work: rooting the resolver at the chosen symbol, generated-channel picture
|
||||
sampling,
|
||||
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
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
|
||||
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 —
|
||||
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
|
||||
|
|
@ -37,14 +37,14 @@
|
|||
[arthur.domain.raster :as raster]))
|
||||
|
||||
(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
|
||||
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
|
||||
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
|
||||
capability a value has."
|
||||
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
:fps frames per second of the finished file — the CLIP's rate
|
||||
:frames how many frames will arrive
|
||||
: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
|
||||
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
|
||||
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
|
||||
to keep pixels has to copy or encode them here.")
|
||||
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
not the thing that was asked for.
|
||||
|
||||
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]
|
||||
(let [up (loop [i id acc #{}]
|
||||
(if (or (nil? i) (contains? acc i))
|
||||
|
|
@ -114,7 +114,7 @@
|
|||
k))))
|
||||
|
||||
(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 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
|
||||
— with the other six removed. The first is the drawing; the second is that face
|
||||
on the stage, and they are different deliverables."
|
||||
[tl id]
|
||||
(if (and id (get-in tl [:nodes id]))
|
||||
(update tl :nodes select-keys (kin (:nodes tl) id))
|
||||
tl))
|
||||
[sym id]
|
||||
(if (and id (get-in sym [:nodes id]))
|
||||
(update sym :nodes select-keys (kin (:nodes sym) id))
|
||||
sym))
|
||||
|
||||
(defn- yield!
|
||||
"Hand the event loop a turn between frames.
|
||||
|
|
@ -140,64 +140,64 @@
|
|||
(defn audio!
|
||||
"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
|
||||
only the root — falls back to the clip's audio file, which is where a take's
|
||||
sound lives before anyone has placed a track. A symbol exports silence rather
|
||||
than the whole clip's soundtrack, because a symbol's frame space is its own and
|
||||
the clip's audio is not a fact about it."
|
||||
[clip-doc tid store fallback-url]
|
||||
(-> (mix/buffer! clip-doc tid store)
|
||||
A symbol's own placed audio tracks win. Failing that, the symbol the document
|
||||
OPENS ON — and only that one — falls back to the clip's audio file, which is
|
||||
where a take's sound lives before anyone has placed a track. Any other symbol
|
||||
exports silence rather than the whole clip's soundtrack, because its frame
|
||||
space is its own and the clip's audio is not a fact about it."
|
||||
[clip-doc sid store fallback-url]
|
||||
(-> (mix/buffer! clip-doc sid store)
|
||||
(.then (fn [buffer]
|
||||
(cond
|
||||
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)))))
|
||||
|
||||
(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
|
||||
commit to, and so the arithmetic is assertable without a sink."
|
||||
[{:keys [clip timeline zoom picture-fps] isolate-id :isolate}]
|
||||
(let [tl (some-> (clip/timeline clip timeline) (isolate isolate-id))
|
||||
[{:keys [clip zoom picture-fps] sid :symbol isolate-id :isolate}]
|
||||
(let [sym (some-> (clip/symbol clip sid) (isolate isolate-id))
|
||||
zoom (max 1 (js/Math.floor (or zoom 1)))]
|
||||
(when tl
|
||||
{:frames (:frames tl)
|
||||
(when sym
|
||||
{:frames (:frames sym)
|
||||
:fps (:fps clip)
|
||||
:zoom zoom
|
||||
:width (* (:width 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
|
||||
;; remove changes, so this is only the grid's nominal count.
|
||||
:poses (if (and picture-fps (< picture-fps (:fps clip)))
|
||||
(js/Math.ceil (* (/ (:frames tl) (:fps clip)) picture-fps))
|
||||
(:frames tl))})))
|
||||
(js/Math.ceil (* (/ (:frames sym) (:fps clip)) picture-fps))
|
||||
(:frames sym))})))
|
||||
|
||||
(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
|
||||
UI hangs its readout."
|
||||
[{:keys [clip timeline store palette ramp zoom picture-fps name audio-url]
|
||||
isolate-id :isolate}
|
||||
[{:keys [clip store palette ramp zoom picture-fps name audio-url]
|
||||
sid :symbol isolate-id :isolate}
|
||||
exporter on-progress]
|
||||
(let [tl (some-> (clip/timeline clip timeline) (isolate isolate-id))]
|
||||
(when-not tl
|
||||
(throw (ex-info "there is no such timeline to export"
|
||||
{:timeline timeline
|
||||
:timelines (vec (sort-by str (keys (:timelines clip))))})))
|
||||
(let [{:keys [frames fps zoom]} (plan {:clip clip :timeline timeline :zoom zoom
|
||||
(let [sym (some-> (clip/symbol clip sid) (isolate isolate-id))]
|
||||
(when-not sym
|
||||
(throw (ex-info "there is no such symbol to export"
|
||||
{:symbol sid
|
||||
:symbols (vec (sort-by str (keys (:symbols clip))))})))
|
||||
(let [{:keys [frames fps zoom]} (plan {:clip clip :symbol sid :zoom zoom
|
||||
:isolate isolate-id})
|
||||
;; Rooted at the chosen timeline, so exporting a symbol is exporting a
|
||||
;; clip whose root that symbol is. Nested symbols inside it still
|
||||
;; resolve — clip/resolver is the function that knows how.
|
||||
doc (assoc-in clip [:timelines timeline] tl)
|
||||
resolve-frame (clip/resolver doc store palette timeline
|
||||
;; Rooted at the chosen symbol, as the stage is. Nested instances
|
||||
;; inside it still resolve — clip/resolver is the function that knows
|
||||
;; how.
|
||||
doc (assoc-in clip [:symbols sid] sym)
|
||||
resolve-frame (clip/resolver doc store palette sid
|
||||
{:picture-fps picture-fps})
|
||||
ras (raster/make (:width clip) (:height clip))
|
||||
bg (get palette :bg 0)]
|
||||
(-> (audio! doc timeline store audio-url)
|
||||
(-> (audio! doc sid store audio-url)
|
||||
(.then (fn [audio]
|
||||
(js/Promise.resolve
|
||||
(begin! exporter {:name name :width (:width clip)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@
|
|||
plate, which a human draws, is worth decimating. Sparse visibility keys capture
|
||||
decisions about the mouth cavity, blink and teeth without thinning geometry."
|
||||
(:require [arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.feature :as feature]
|
||||
[arthur.domain.geom :as geom]
|
||||
[arthur.domain.ring :as ring]
|
||||
|
|
@ -262,7 +261,7 @@
|
|||
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,
|
||||
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]}]
|
||||
(when-not (contains? head-modes mode)
|
||||
(throw (ex-info "head mode must be free or anchored"
|
||||
|
|
@ -274,14 +273,14 @@
|
|||
{:subject subject :subjects (vec (sort-by str (keys (:subjects clip))))})))
|
||||
(reduce
|
||||
(fn [c sid]
|
||||
(let [frames (get-in c [:timelines sid :frames])]
|
||||
(let [frames (get-in c [:symbols sid :frames])]
|
||||
(when (and (= mode :anchored)
|
||||
(not (and (map? anchors) (contains? anchors 0)
|
||||
(every? #(and (integer? %) (<= 0 %) (< % frames))
|
||||
(concat (keys anchors) (vals anchors))))))
|
||||
(throw (ex-info "anchored head needs a frame-zero key and valid source frames"
|
||||
{:subject sid :anchors anchors :frames frames})))
|
||||
(update-in c [:timelines sid :nodes :head]
|
||||
(update-in c [:symbols sid :nodes :head]
|
||||
(fn [n]
|
||||
(cond-> (assoc n :channels (:measured n))
|
||||
(= mode :anchored) (assoc :anchors anchors)
|
||||
|
|
@ -654,7 +653,7 @@
|
|||
;; the clip
|
||||
|
||||
(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}]
|
||||
(let [own (partial feature/owned subject)
|
||||
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]]
|
||||
:brow-r [:brow [:brow-r]] :brow-l [:brow [:brow-l]]})
|
||||
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"
|
||||
:measured (:measured head)}}
|
||||
(mapcat :nodes) parts)}
|
||||
:features (into {} (map (fn [[role [area nodes]]]
|
||||
[(own role) {:id (own role) :subject subject
|
||||
:timeline subject :area area
|
||||
:symbol subject :area area
|
||||
:nodes nodes :params {}}])) features)
|
||||
:groups (if (and eyes brows)
|
||||
{(own :eyes) {:id (own :eyes) :kind :eye-pair :subject subject
|
||||
|
|
@ -681,12 +680,13 @@
|
|||
:store (into (:store head) (mapcat :store) parts)}))
|
||||
|
||||
(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
|
||||
placed by an ordinary symbol instance, so pose choices and transforms have
|
||||
their existing instance scope. Features name local nodes in that subject's
|
||||
timeline; block descriptors still name globally distinct features.
|
||||
placed by an ordinary instance, so pose choices and transforms have their
|
||||
existing instance scope. Features name local nodes in that subject's symbol;
|
||||
block descriptors still name globally distinct features.
|
||||
|
||||
Subjects share a source frame space. :head and :anchors may be overridden
|
||||
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" {})))
|
||||
(let [ordered (sort-by (comp str key) subjects)
|
||||
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)))
|
||||
(throw (ex-info "subjects need the same positive frame count"
|
||||
{:frames (vec lengths)})))
|
||||
|
|
@ -707,9 +707,9 @@
|
|||
:width (first stage) :height (second stage)
|
||||
:subjects (into {} (map (fn [[id _]] [id {:id id :params {}}])) ordered)
|
||||
:features (merged :features) :groups (merged :groups)
|
||||
:timelines
|
||||
(into {clip/root-id
|
||||
{:id clip/root-id :frames nf
|
||||
:symbols
|
||||
(into {:main
|
||||
{:id :main :frames nf
|
||||
:nodes (into {:root {:id :root :name "clip" :kind :group :z "a1"
|
||||
:time {:mode :map :expose expose}}
|
||||
:face {:id :face :name "source placement" :kind :group
|
||||
|
|
@ -717,10 +717,10 @@
|
|||
:channels (face-placement params subjects)}}
|
||||
(map-indexed
|
||||
(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)}]))
|
||||
ordered)}}
|
||||
(map (fn [[id part]] [id (:timeline part)])) parts)}]
|
||||
(map (fn [[id part]] [id (:symbol part)])) parts)}]
|
||||
(doseq [[subject inputs] ordered
|
||||
[id track] (:presence inputs)]
|
||||
(when-not (and (= nf (count track))
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
:when (:generated channel)]
|
||||
[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]
|
||||
old (get-in entry at)]
|
||||
(assoc-in entry at
|
||||
|
|
@ -103,7 +103,7 @@
|
|||
by hand."
|
||||
[entry params base subject]
|
||||
(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)
|
||||
measured (:measured baked)]
|
||||
(cond-> (-> entry
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
Everything here returns a promise of a PARSED JS VALUE, not of CLJS data, and
|
||||
that is deliberate: a leaf is transit, and `domain/project` reads it straight out
|
||||
of the response object. A keywordising `js->clj` on the way past would turn the
|
||||
leaf path \"clip/c1/timeline/main/node/mouth\" into a keyword whose name is
|
||||
\"c1/timeline/main/node/mouth\",
|
||||
leaf path \"clip/c1/symbol/main/node/mouth\" into a keyword whose name is
|
||||
\"c1/symbol/main/node/mouth\",
|
||||
losing the prefix — a corruption that only shows up on the way back in.
|
||||
|
||||
CSRF IS NOT EXEMPTED. The page renders `{% csrf_token %}`, so Django sets its
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
(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 ::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
|
||||
;; 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.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
playhead is not an input, so moving it cannot invalidate this."
|
||||
(:require [arthur.domain.clip :as clip]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.timeline :as timeline]
|
||||
[arthur.footage.store :as footage]
|
||||
[arthur.subs.playback :as playback]
|
||||
[re-frame.core :as rf]))
|
||||
|
|
@ -23,27 +22,38 @@
|
|||
:<- [::paint-revision]
|
||||
(fn [[id _] _] (:clip (footage/entry id))))
|
||||
|
||||
(rf/reg-sub ::open (fn [db _] (get-in db [:ui :open])))
|
||||
|
||||
(rf/reg-sub
|
||||
::timeline
|
||||
::symbol
|
||||
:<- [::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
|
||||
::exposure
|
||||
:<- [::timeline]
|
||||
(fn [tl _]
|
||||
;; Exposure lives on the timeline's root node and is INHERITED, so reading it
|
||||
:<- [::symbol]
|
||||
(fn [sym _]
|
||||
;; 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`
|
||||
;; is visibly doing something at the transport rather than only inside the
|
||||
;; document.
|
||||
(or (get-in tl [:nodes :root :time :expose]) 1)))
|
||||
(or (get-in sym [:nodes :root :time :expose]) 1)))
|
||||
|
||||
(rf/reg-sub
|
||||
::palette
|
||||
(fn [db _]
|
||||
;; 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
|
||||
;; 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.
|
||||
(get {:arthur/default pal/index-of} (:palette db) pal/index-of)))
|
||||
|
||||
|
|
@ -68,11 +78,10 @@
|
|||
(rf/reg-sub
|
||||
::resolver
|
||||
:<- [::clip]
|
||||
:<- [::timeline]
|
||||
:<- [::open]
|
||||
:<- [::store]
|
||||
:<- [::palette]
|
||||
:<- [::playback/display-fps]
|
||||
(fn [[document tl store palette picture-fps] _]
|
||||
(when tl (clip/resolver (assoc-in document [:timelines :main] tl)
|
||||
store palette clip/root-id
|
||||
{:picture-fps picture-fps}))))
|
||||
(fn [[document sid store palette picture-fps] _]
|
||||
(when (and document (clip/symbol document sid))
|
||||
(clip/resolver document store palette sid {:picture-fps picture-fps}))))
|
||||
|
|
|
|||
|
|
@ -23,6 +23,6 @@
|
|||
;; 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.
|
||||
(when (and clip (= :node (first selection)))
|
||||
(let [[_ tid id] selection]
|
||||
(when-let [n (get-in clip [:timelines tid :nodes id])]
|
||||
[tid id n])))))
|
||||
(let [[_ sid id] selection]
|
||||
(when-let [n (get-in clip [:symbols sid :nodes id])]
|
||||
[sid id n])))))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"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
|
||||
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
|
||||
has analysis in it. Nothing here computes — every control dispatches an intent
|
||||
and every readout comes off a subscription."
|
||||
|
|
@ -45,12 +45,14 @@
|
|||
(let [clip @(rf/subscribe [::render/clip])
|
||||
fps @(rf/subscribe [::playback/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])]
|
||||
[section "clip"
|
||||
[facts
|
||||
"name" (:name clip)
|
||||
"stage" (str (:width clip) "×" (:height clip))
|
||||
"open" (some-> open name)
|
||||
"length" (str frames " frames")
|
||||
"source" (str fps " fps")
|
||||
"expose" (str "on " expose "s")]
|
||||
|
|
@ -84,7 +86,7 @@
|
|||
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
|
||||
parameters are."
|
||||
[id n frame]
|
||||
[sid id n frame]
|
||||
(let [geom (get-in n [:channels paint/geometry])
|
||||
active (when geom (paint/active-frame geom frame))
|
||||
ks (when geom (sort (keys (:keys geom))))
|
||||
|
|
@ -94,7 +96,7 @@
|
|||
[:div.row {:style {:margin "5px 0"}}
|
||||
[:button {:disabled (or (< frame start) (>= frame end)
|
||||
(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"]]
|
||||
(when (seq ks)
|
||||
[:div.row
|
||||
|
|
@ -110,22 +112,22 @@
|
|||
[:label.dim (str "key " active " → " next-k " ")
|
||||
[:select {:value (name (or (channel/segment-interp geom active) :hold))
|
||||
: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 "linear"} "tween"]]]])]))
|
||||
|
||||
(defn- node-section [[tid id n]]
|
||||
(defn- node-section [[sid id n]]
|
||||
(let [frame @(rf/subscribe [::playback/frame])
|
||||
[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
|
||||
"name" (or (:name n) (brief id))
|
||||
"id" (brief id)
|
||||
;; Which library timeline a placement plays. The one fact that makes a
|
||||
;; symbol instance legible as an instance rather than as a node.
|
||||
"of" (when (= :symbol (:kind n)) (str (:of n)))
|
||||
;; Which symbol an instance places. The one fact that makes an instance
|
||||
;; legible as an instance rather than as a node.
|
||||
"of" (when (= :instance (:kind n)) (str (:of n)))
|
||||
"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"]]
|
||||
[:dl.facts
|
||||
(doall
|
||||
|
|
@ -136,16 +138,15 @@
|
|||
[:dd (channel-state ch)]]))]]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; a timeline
|
||||
;; a symbol
|
||||
|
||||
(defn- timeline-section [tid]
|
||||
(let [tl @(rf/subscribe [::render/clip])
|
||||
tl (get-in tl [:timelines tid])]
|
||||
[section "timeline"
|
||||
(defn- symbol-section [sid]
|
||||
(let [sym (get-in @(rf/subscribe [::render/clip]) [:symbols sid])]
|
||||
[section "symbol"
|
||||
[facts
|
||||
"id" (str tid)
|
||||
"length" (str (:frames tl) " frames")
|
||||
"nodes" (str (count (:nodes tl)))]]))
|
||||
"id" (str sid)
|
||||
"length" (str (:frames sym) " frames")
|
||||
"nodes" (str (count (:nodes sym)))]]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; tracked objects
|
||||
|
|
@ -245,5 +246,5 @@
|
|||
[:div {:style {:min-height 0}}
|
||||
[clip-section]
|
||||
(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])]]))
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
:palette @(rf/subscribe [::render/palette])
|
||||
:ramp @(rf/subscribe [::render/ramp])
|
||||
:fps @(rf/subscribe [::sub/fps])
|
||||
:frames @(rf/subscribe [::sub/frames])
|
||||
:frames @(rf/subscribe [::render/frames])
|
||||
:width @(rf/subscribe [::sub/width])
|
||||
:height @(rf/subscribe [::sub/height])
|
||||
:frame @(rf/subscribe [::sub/frame])
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@
|
|||
the confusion this pane should be ending.
|
||||
|
||||
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
|
||||
library belongs to the document that holds it.
|
||||
|
||||
FOOTAGE is source media on the server: uploads that have been probed,
|
||||
transcoded and cut into frames. Running one through detection produces a
|
||||
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.
|
||||
|
||||
THE WHOLE PANE IS THE DROP TARGET. A video dropped anywhere in it uploads and
|
||||
|
|
@ -53,25 +53,25 @@
|
|||
(defn- symbols []
|
||||
(let [clip @(rf/subscribe [::render/clip])
|
||||
selection @(rf/subscribe [::sub/selection])
|
||||
library (sort-by str (remove #{:main} (keys (:timelines clip))))]
|
||||
library (sort-by str (keys (:symbols clip)))]
|
||||
(group "symbols"
|
||||
[:div.dim "timelines in this document"]
|
||||
[:div.dim "in this document"]
|
||||
(if (empty? library)
|
||||
[:div.dim "none yet"]
|
||||
(doall
|
||||
(for [tid library
|
||||
:let [tl (get-in clip [:timelines tid])]]
|
||||
^{:key tid}
|
||||
[item {:label (name tid)
|
||||
:sub (str (:frames tl) "f · " (count (:nodes tl)) " nodes")
|
||||
:on? (= selection [:timeline tid])
|
||||
(for [sid library
|
||||
:let [sym (get-in clip [:symbols sid])]]
|
||||
^{:key sid}
|
||||
[item {:label (name sid)
|
||||
:sub (str (:frames sym) "f · " (count (:nodes sym)) " nodes")
|
||||
:on? (= selection [:symbol sid])
|
||||
:draggable true
|
||||
:on-drag-start
|
||||
(fn [^js event]
|
||||
(.setData (.-dataTransfer event) "text/plain"
|
||||
(str "symbol:" (subs (str tid) 1)))
|
||||
(str "symbol:" (subs (str sid) 1)))
|
||||
(set! (.. event -dataTransfer -effectAllowed) "copy"))
|
||||
:on-click #(rf/dispatch [::ui/select [:timeline tid]])}])))
|
||||
:on-click #(rf/dispatch [::ui/select [:symbol sid]])}])))
|
||||
(when (seq library)
|
||||
[:div.dim "drag onto the stage to place"]))))
|
||||
|
||||
|
|
|
|||
|
|
@ -47,15 +47,17 @@
|
|||
(defonce ^:private dragging (atom nil))
|
||||
|
||||
(defn- editing
|
||||
"The selected node when it is a polygon on the root timeline, as
|
||||
`[id node geom active-key editable?]`. Nothing else is vertex-editable yet."
|
||||
[clip frame]
|
||||
(let [[tid id n] @(rf/subscribe [::sub/selected-node])]
|
||||
(when (and (= :main tid) (:paint? n))
|
||||
"The selected node when it is a polygon in the open symbol, as
|
||||
`[sid id node geom active-key editable?]`. Nothing else is vertex-editable yet:
|
||||
a shape inside an instance is drawn through that instance's transform, and
|
||||
handles in the open symbol's space would be in the wrong place."
|
||||
[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])
|
||||
active (when geom (paint/active-frame geom frame))
|
||||
[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
|
||||
;; of its own to move: what is on screen there is interpolated, and
|
||||
;; dragging it would silently edit the key behind it instead.
|
||||
|
|
@ -69,7 +71,7 @@
|
|||
tool @(rf/subscribe [::sub/tool])
|
||||
draft @(rf/subscribe [::sub/draft])
|
||||
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))]
|
||||
[:svg {:class (str "paint-overlay" (when drawing? " drawing"))
|
||||
:width (* zoom w) :height (* zoom h)
|
||||
|
|
@ -79,9 +81,9 @@
|
|||
(let [[x y] (stage-point event w h)]
|
||||
(rf/dispatch [::ui/add-draft-point x y]))))
|
||||
: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
|
||||
node key-frame vertex
|
||||
sid node key-frame vertex
|
||||
(stage-point event w h)])))
|
||||
:on-pointer-up (fn [_] (reset! dragging nil))
|
||||
:on-pointer-cancel (fn [_] (reset! dragging nil))}
|
||||
|
|
@ -104,10 +106,10 @@
|
|||
(.preventDefault event)
|
||||
(.setPointerCapture (.-currentTarget event)
|
||||
(.-pointerId event))
|
||||
(reset! dragging [id active i]))}])))])]))
|
||||
(reset! dragging [sid id active i]))}])))])]))
|
||||
|
||||
(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
|
||||
short string, every browser agrees about this type, and a drag that arrives
|
||||
|
|
@ -130,8 +132,8 @@
|
|||
(.preventDefault event)))
|
||||
:on-drop (fn [^js event]
|
||||
(.preventDefault event)
|
||||
(when-let [tid (dropped-symbol event)]
|
||||
(rf/dispatch [::ui/place-symbol tid (stage-point event w h)])))}
|
||||
(when-let [sid (dropped-symbol event)]
|
||||
(rf/dispatch [::ui/place-symbol sid (stage-point event w h)])))}
|
||||
[:canvas.stage {:ref #(player/set-canvas! %)
|
||||
:width w :height h
|
||||
:style {:width (str (* zoom w) "px")
|
||||
|
|
|
|||
|
|
@ -1,22 +1,24 @@
|
|||
(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
|
||||
clip and the set of open paths. It flattens the document's two axes of nesting
|
||||
— parent/child within a timeline, and instance into a library timeline — into
|
||||
clip, the open symbol and the set of open paths. It flattens the document's two
|
||||
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
|
||||
column render from the same vector and therefore stay aligned without measuring
|
||||
anything.
|
||||
|
||||
EVERY FRAME NUMBER A ROW CARRIES IS IN ROOT FRAME SPACE. A symbol's keys are
|
||||
its own timeline's, and drawing them against the stage's ruler unmapped would
|
||||
EVERY FRAME NUMBER A ROW CARRIES IS IN THE OPEN SYMBOL'S FRAME SPACE. An
|
||||
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
|
||||
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
|
||||
`node/local-frame`: `local = in + rate·(parent - at)`, so
|
||||
`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
|
||||
happen and not where they happen again."
|
||||
(:require [clojure.string :as str]
|
||||
|
|
@ -35,7 +37,7 @@
|
|||
|
||||
(defn- local->parent
|
||||
"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
|
||||
to prevent. A node's `:span` is checked against the frame its PARENT hands it,
|
||||
|
|
@ -52,7 +54,7 @@
|
|||
identity
|
||||
(fn [f]
|
||||
(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)))
|
||||
f))))))
|
||||
|
||||
|
|
@ -69,7 +71,7 @@
|
|||
(or (:name n)
|
||||
(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))]
|
||||
{:path (conj path cpath)
|
||||
:depth depth
|
||||
|
|
@ -77,15 +79,16 @@
|
|||
:kind :channel
|
||||
:select nil
|
||||
: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))}))
|
||||
|
||||
(defn rows
|
||||
"The visible rows, outermost first. `expanded` is a set of row paths."
|
||||
[clip expanded]
|
||||
(letfn [(walk [tid path depth ->root]
|
||||
(let [tl (get-in clip [:timelines tid])
|
||||
ordered (->> (:nodes tl)
|
||||
"The visible rows of symbol `sid`, outermost first. `expanded` is a set of row
|
||||
paths."
|
||||
[clip sid expanded]
|
||||
(letfn [(walk [sid path depth ->open]
|
||||
(let [sym (get-in clip [:symbols sid])
|
||||
ordered (->> (:nodes sym)
|
||||
;; Front-most at the top, as a layer list is drawn
|
||||
;; everywhere. `:z` is the lexicographic draw key;
|
||||
;; the id breaks ties so the order is stable.
|
||||
|
|
@ -99,18 +102,18 @@
|
|||
channels (node/channels n)
|
||||
;; The span is in the PARENT's space and the channels
|
||||
;; are in the node's own, so they take different
|
||||
;; mappings. `self` is also what the symbol's target
|
||||
;; timeline is resolved in — `clip/resolver` roots the
|
||||
;; mappings. `self` is also what the instance's
|
||||
;; symbol is resolved in — `clip/resolver` roots the
|
||||
;; child at this node's local frame — so the nested
|
||||
;; walk carries it down unchanged.
|
||||
self (comp ->root (local->parent n))
|
||||
span (mapv ->root (or (:span n) [0 (:frames tl)]))
|
||||
self (comp ->open (local->parent n))
|
||||
span (mapv ->open (or (:span n) [0 (:frames sym)]))
|
||||
row {:path rpath
|
||||
:depth depth
|
||||
:label (node-label id n)
|
||||
:kind :node
|
||||
:node-kind (:kind n)
|
||||
:select [:node tid id]
|
||||
:select [:node sid id]
|
||||
:expandable? true
|
||||
:expanded? open?
|
||||
:span span
|
||||
|
|
@ -123,11 +126,11 @@
|
|||
[row]
|
||||
(-> [row]
|
||||
(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)))))))
|
||||
ordered))))]
|
||||
(if (get-in clip [:timelines :main])
|
||||
(walk :main [] 0 identity)
|
||||
(if (get-in clip [:symbols sid])
|
||||
(walk sid [] 0 identity)
|
||||
[])))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
|
@ -153,7 +156,7 @@
|
|||
(let [playing? @(rf/subscribe [::playback/playing?])
|
||||
rate @(rf/subscribe [::playback/rate])
|
||||
frame @(rf/subscribe [::playback/frame])
|
||||
frames @(rf/subscribe [::playback/frames])
|
||||
frames @(rf/subscribe [::render/frames])
|
||||
{:keys [fps drop]} @player/meter]
|
||||
[:div.pane-head
|
||||
[:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")]
|
||||
|
|
@ -214,11 +217,11 @@
|
|||
(defn view []
|
||||
(r/with-let [scrubbing (r/atom false)]
|
||||
(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])
|
||||
selection @(rf/subscribe [::sub/selection])
|
||||
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.
|
||||
step (* 10 (js/Math.ceil (/ frames 100)))]
|
||||
[:section.pane.time
|
||||
|
|
@ -262,5 +265,5 @@
|
|||
(if (seq visible)
|
||||
(doall (for [row visible]
|
||||
^{: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)}}]]]])))
|
||||
|
|
|
|||
|
|
@ -12,17 +12,17 @@
|
|||
[re-frame.core :as rf]))
|
||||
|
||||
(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])]
|
||||
[:<>
|
||||
;; ONE SELECT over everything exportable: the clip, each symbol in its
|
||||
;; library, and each placement on the stage. They are one list because they
|
||||
;; ONE SELECT over everything exportable: each symbol, and each instance
|
||||
;; in the open one. They are one list because they
|
||||
;; are one kind of request — render this, alone.
|
||||
;;
|
||||
;; 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.
|
||||
[:select {:value (export/target-value {:timeline (or timeline :main) :isolate isolate})
|
||||
[:select {:value (export/target-value target)
|
||||
:disabled busy?
|
||||
:title "what to render"
|
||||
:on-change #(rf/dispatch [::export/set-target
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.raster :as raster]
|
||||
[arthur.domain.timeline :as timeline]))
|
||||
[arthur.domain.symbol :as symbol]))
|
||||
|
||||
(defn- ms [label n f]
|
||||
(let [t0 (js/Date.now)]
|
||||
|
|
@ -24,11 +24,11 @@
|
|||
(/ dt n))))
|
||||
|
||||
(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)
|
||||
dest (js/Uint8ClampedArray. (* 320 200 4))
|
||||
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))))
|
||||
b (ms "resolve+draw " n (fn [i]
|
||||
(raster/clear! ras 0)
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@
|
|||
{:subjects (into {} (map (fn [id] [id {:id id :params {}}]) people))
|
||||
:features (into {} (map-indexed (fn [i id]
|
||||
[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))
|
||||
:groups (into {} (map-indexed (fn [i ids]
|
||||
(let [id (keyword (str "pair-" i))]
|
||||
[id {:id id :kind :eye-pair
|
||||
:subject (nth people i)
|
||||
:members ids :params {}}])) members))
|
||||
:timelines (into {} (map (fn [id]
|
||||
:symbols (into {} (map (fn [id]
|
||||
[id {:id id :frames 1
|
||||
:nodes {:head {:id :head :kind :group :z "a1"
|
||||
:measured {[:xform :rot]
|
||||
|
|
|
|||
236
frontend/test/arthur/domain/instance_test.cljs
Normal file
236
frontend/test/arthur/domain/instance_test.cljs
Normal 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]))))))
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
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
|
||||
`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."
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[arthur.demo :as demo]
|
||||
|
|
@ -16,11 +16,11 @@
|
|||
[arthur.domain.clip :as clip]
|
||||
[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
|
||||
about a path rather than about a take."
|
||||
[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
|
||||
(doseq [[label c] [["the frozen take" @take/clip]
|
||||
|
|
@ -37,20 +37,20 @@
|
|||
(is (contains? ls "clip/c7/source"))
|
||||
;; 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.
|
||||
(is (contains? ls "clip/c7/timeline/main"))
|
||||
(is (= {:frames 229} (get ls "clip/c7/timeline/main"))
|
||||
(is (contains? ls "clip/c7/symbol/main"))
|
||||
(is (= {:frames 229} (get ls "clip/c7/symbol/main"))
|
||||
"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.
|
||||
(is (contains? ls "clip/c7/timeline/face-1/node/mouth"))
|
||||
(is (contains? ls "clip/c7/timeline/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/node/mouth"))
|
||||
(is (contains? ls "clip/c7/symbol/face-1/channel/mouth/geom.pts"))
|
||||
(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/group/face-1~eyes"))
|
||||
(is (contains? ls "clip/c7/subject/face-1"))
|
||||
;; 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.
|
||||
(is (contains? ls "clip/c7/timeline/face-1/measured/head"))
|
||||
(is (= 3 (count (get 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/symbol/face-1/measured/head"))))
|
||||
;; :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
|
||||
;; conflation `domain/clip` exists to undo.
|
||||
|
|
@ -60,11 +60,11 @@
|
|||
;; The boundary that lets two people key different parts without meeting. A node
|
||||
;; leaf carries structure and no geometry.
|
||||
(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
|
||||
:z "a1" :pose-group :mouth} 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
|
||||
;; 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 []))))
|
||||
(is (thrown-with-msg? ExceptionInfo #"no leaf to save it in"
|
||||
(leaf/leaves :c1 (assoc-in @take/clip
|
||||
[:timelines :main :markers] []))))
|
||||
[:symbols :main :markers] []))))
|
||||
(is (= clip/clip-keys (set (keys (assoc @take/clip :name "x"))))
|
||||
"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? (leaf/clip :c1 ls) :analysis)))
|
||||
(is (not (contains? (get-in (leaf/clip :c1 ls)
|
||||
[:timelines :main :nodes :root])
|
||||
[:symbols :main :nodes :root])
|
||||
:channels)))))
|
||||
|
||||
(deftest a-namespaced-id-is-one-path-segment
|
||||
;; docs/architecture.md draws a node as `:eye-r/iris`, and a leaf path is
|
||||
;; "/"-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)}}})
|
||||
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))))
|
||||
;; `(keyword "a~b")` rather than a literal: ~ is unquote in CLJS source.
|
||||
(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
|
||||
;; matches no node, with no error anywhere.
|
||||
(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"}})
|
||||
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")
|
||||
(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
|
||||
;; 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)))))
|
||||
|
||||
(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)))))
|
||||
|
||||
(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)
|
||||
;; 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
|
||||
;; the root's node leaf would have been excused by the symbol's.
|
||||
(let [ls (-> (leaf/leaves :c1 @take/clip)
|
||||
(assoc "clip/c1/timeline/sym~blink" {:frames 3}
|
||||
"clip/c1/timeline/sym~blink/node/mouth"
|
||||
(assoc "clip/c1/symbol/sym~blink" {:frames 3}
|
||||
"clip/c1/symbol/sym~blink/node/mouth"
|
||||
{: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)))))
|
||||
|
||||
(deftest a-property-with-path-punctuation-in-it-is-refused
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo #"cannot contain . or /"
|
||||
(leaf/leaves :c1 (one-timeline
|
||||
(leaf/leaves :c1 (one-symbol
|
||||
{:a {:id :a :kind :poly :parent nil :z "a1"
|
||||
:channels {[:geom :pts.x] (ch/framed [0 0])}}})))))
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@
|
|||
(is (empty? (node/problems {:id :x :kind :group :z "a1"})))
|
||||
(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 :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 :z "a1" :span [3]})) "a malformed span")
|
||||
(is (seq (node/problems {:id :x :kind :group :z "a1"
|
||||
|
|
|
|||
|
|
@ -4,22 +4,22 @@
|
|||
[arthur.domain.channel :as channel]
|
||||
[arthur.domain.leaf :as leaf]
|
||||
[arthur.domain.paint :as paint]
|
||||
[arthur.domain.timeline :as timeline]))
|
||||
[arthur.domain.symbol :as symbol]))
|
||||
|
||||
(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]
|
||||
c0 (paint/new-shape demo/clip :paint-test 3 a :brow)
|
||||
c1 (paint/add-key c0 :paint-test 9)
|
||||
c2 (paint/set-vertex c1 :paint-test 9 0 [22 10])
|
||||
c3 (paint/add-key c2 :paint-test 15)
|
||||
c4 (paint/set-vertex c3 :paint-test 15 0 [34 10])
|
||||
c0 (paint/new-shape demo/clip :main :paint-test 3 a :brow)
|
||||
c1 (paint/add-key c0 :main :paint-test 9)
|
||||
c2 (paint/set-vertex c1 :main :paint-test 9 0 [22 10])
|
||||
c3 (paint/add-key c2 :main :paint-test 15)
|
||||
c4 (paint/set-vertex c3 :main :paint-test 15 0 [34 10])
|
||||
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)]
|
||||
(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 (= 10 (first (channel/value-at held 8))))
|
||||
(is (= 22 (first (channel/value-at held 9))))
|
||||
|
|
@ -28,5 +28,5 @@
|
|||
(is (empty? (channel/problems mixed)))
|
||||
;; The demo's root is exposed on 2s. Paint at frame 3 must still appear at 3.
|
||||
(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))))))
|
||||
|
|
|
|||
|
|
@ -22,11 +22,11 @@
|
|||
[arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.project :as project]
|
||||
[arthur.domain.timeline :as timeline]
|
||||
[arthur.domain.symbol :as symbol]
|
||||
[arthur.flow.freeze :as freeze]
|
||||
[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
|
||||
"A clip out and back, over a wire that is really only JSON."
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
(def ^:private after (delay (wired :c1 @before)))
|
||||
|
||||
(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 clip-level check looks at those.
|
||||
(let [ps (clip/problems (:clip @after))]
|
||||
|
|
@ -53,14 +53,14 @@
|
|||
;; 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
|
||||
;; 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]
|
||||
"playback" [ops/resolved ops/resolved]
|
||||
"spec vs playback, after" [ops/specified ops/resolved]}]
|
||||
(doseq [[label [f g]] paths
|
||||
[order fs] (ops/orders n)]
|
||||
(let [a (f (face-timeline (:clip @before)) (:store @before))
|
||||
b (g (face-timeline (:clip @after)) (:store @after))]
|
||||
(let [a (f (face-symbol (:clip @before)) (:store @before))
|
||||
b (g (face-symbol (:clip @after)) (:store @after))]
|
||||
(testing (str label ", " order)
|
||||
(doseq [frame fs]
|
||||
(is (= (a frame) (b frame))
|
||||
|
|
@ -84,8 +84,8 @@
|
|||
;; everything above and lose the locked take's identity transform.
|
||||
(let [locked {:clip @take/locked :store @take/store}
|
||||
back (wired :c1 locked)
|
||||
a (ops/resolved (face-timeline (:clip locked)) (:store locked))
|
||||
b (ops/resolved (face-timeline (:clip back)) (:store back))]
|
||||
a (ops/resolved (face-symbol (:clip locked)) (:store locked))
|
||||
b (ops/resolved (face-symbol (:clip back)) (:store back))]
|
||||
(is (= (:clip locked) (:clip back)))
|
||||
(doseq [frame (range 0 take/frames 7)]
|
||||
(is (= (a frame) (b frame)) (str "frame " frame)))))
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
(deftest an-absence-mask-survives-the-wire
|
||||
(let [back (wired :c1 @gappy)
|
||||
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)))
|
||||
;; Every dense track of the eye, iris, brow and brow-position blocks, and
|
||||
;; the feature whose gap it must follow — the same table
|
||||
|
|
@ -147,7 +147,7 @@
|
|||
;; frame rather than hidden, and its partner is not.
|
||||
(let [back (wired :c1 @gappy)
|
||||
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 (contains? drawn :eye-l))
|
||||
(is (contains? drawn :mouth))))
|
||||
|
|
|
|||
|
|
@ -1,205 +1,397 @@
|
|||
(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]]
|
||||
[arthur.demo.stage :as stage]
|
||||
[arthur.demo :as demo]
|
||||
[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.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
|
||||
{:name "source" :fps 30 :width 320 :height 200
|
||||
:timelines
|
||||
{: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)}}}}}})
|
||||
(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))
|
||||
|
||||
(deftest two-instances-own-their-frame-and-placement
|
||||
(let [document
|
||||
(-> 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))))))
|
||||
(defn- sc [& nodes]
|
||||
{:nodes (into {} (map (juxt :id identity)) nodes)})
|
||||
|
||||
(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
|
||||
: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"))))
|
||||
(defn- ids-at [scene f]
|
||||
(mapv :node (symbol/eval-frame scene f)))
|
||||
|
||||
(deftest stage-pose-edits-preserve-earlier-motion-and-survive-save
|
||||
(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}))))))
|
||||
(def ^:private pts-of ops/points)
|
||||
|
||||
(defn- uuid-of
|
||||
"The uuid the layout authors for the placement whose handle is `id`.
|
||||
;; ---- structure ----
|
||||
|
||||
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}))))
|
||||
(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 (symbol/order (:nodes s))]
|
||||
(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
|
||||
"The composed node for the placement the layout calls `id`."
|
||||
[document id]
|
||||
(get-in document [:timelines :main :nodes (uuid-of 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" (symbol/order (:nodes s))))
|
||||
(is (seq (symbol/problems s)))))
|
||||
|
||||
(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 (:timelines 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 #{:symbol} :kind val)
|
||||
(get-in document [:timelines :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 #(= :symbol (:kind %))
|
||||
(vals (get-in document [:timelines :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))))))
|
||||
(deftest a-missing-parent-is-named-rather-than-silently-orphaning
|
||||
(let [s (sc {:id :a :kind :group :parent :nope :z "a1"})]
|
||||
(is (seq (symbol/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 %)) (symbol/eval-frame s 0))))))
|
||||
(is (= [[100 0] [110 0] [110 10]]
|
||||
(pts-of (first (filter #(= :c (:node %)) (symbol/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 (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")))
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
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
|
||||
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
|
||||
\"frame 0 /\" forever with nothing in the status line.
|
||||
|
||||
|
|
@ -17,12 +17,12 @@
|
|||
(def ^:private a-uuid #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218")
|
||||
|
||||
(deftest every-kind-of-target-survives-the-round-trip
|
||||
(doseq [t [{:timeline :main}
|
||||
{:timeline :sym/face-8625}
|
||||
{:timeline :main :isolate a-uuid}
|
||||
{:timeline :sym/face-8625 :isolate a-uuid}]]
|
||||
(doseq [t [{:symbol :main}
|
||||
{:symbol :sym/face-8625}
|
||||
{:symbol :main :isolate a-uuid}
|
||||
{:symbol :sym/face-8625 :isolate a-uuid}]]
|
||||
(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))))
|
||||
(is (= (:isolate t) (:isolate back)))
|
||||
(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
|
||||
;; Spelled out, because these are the strings that end up in the DOM.
|
||||
(is (= "t:main" (export/target-value {:timeline :main})))
|
||||
(is (= "t:sym/face-8625" (export/target-value {:timeline :sym/face-8625})))
|
||||
(is (= "s:main" (export/target-value {:symbol :main})))
|
||||
(is (= "s:sym/face-8625" (export/target-value {:symbol :sym/face-8625})))
|
||||
(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
|
||||
;; 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
|
||||
;; 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
|
||||
is not a symbol must not show up as a face."
|
||||
{:fps 30 :width 320 :height 200
|
||||
:timelines
|
||||
:symbols
|
||||
{:main {:frames 280
|
||||
:nodes {:root {:id :root :kind :group :z "a1"}
|
||||
#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"}
|
||||
#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"}
|
||||
:a-rect {:id :a-rect :kind :rect :parent :root :z "a3"}}}
|
||||
:sym/face {:frames 40 :nodes {:root {:id :root :kind :group :z "a1"}}}}})
|
||||
|
||||
(deftest the-picker-offers-the-clip-the-drawing-and-every-placement
|
||||
(let [ts (export/targets clip)]
|
||||
(is (= ["main (the clip)" "face" "8625 left" "8625 right"] (mapv :label ts))
|
||||
"the clip, then the library, then the placements")
|
||||
(testing "the placements isolate a node on :main and the library ones do not"
|
||||
(deftest the-picker-offers-every-symbol-then-every-instance-in-the-open-one
|
||||
(let [ts (export/targets clip :main)]
|
||||
(is (= ["main" "face" "8625 left" "8625 right"] (mapv :label ts))
|
||||
"every symbol, then the instances in the open one")
|
||||
(testing "the instances isolate a node in the open symbol and the symbols do not"
|
||||
(is (= [nil nil] (mapv :isolate (take 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"
|
||||
(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))))))
|
||||
|
||||
(deftest every-offered-target-round-trips
|
||||
;; The picker and the encoding asserted against each other, so neither can drift
|
||||
;; into offering something that cannot be selected.
|
||||
(doseq [t (export/targets clip)]
|
||||
(let [norm #(merge {:isolate nil} (select-keys % [:timeline :isolate]))
|
||||
(doseq [t (export/targets clip :main)]
|
||||
(let [norm #(merge {:isolate nil} (select-keys % [:symbol :isolate]))
|
||||
back (export/target-id (export/target-value t))]
|
||||
(is (= (norm t) (norm back)) (pr-str t)))))
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@
|
|||
(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
|
||||
(-> (run-default! {:n 2 :audio nil})
|
||||
(.then (fn [{:keys [entries]}]
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
{:id id :kind :poly :z z
|
||||
: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
|
||||
marked generated channels in the shared resolver, leaving this square alone."
|
||||
[frames]
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
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}}]
|
||||
{:name "t" :fps fps :width w :height h
|
||||
:timelines {clip/root-id (a-timeline frames)}})
|
||||
:symbols {:main (a-symbol frames)}})
|
||||
|
||||
(defn- recorder
|
||||
"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."
|
||||
[clip & {:as opts}]
|
||||
(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
|
||||
:name "t"}
|
||||
opts)
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
;; ---- plan ----
|
||||
|
||||
(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 (= 24 (:fps p)))
|
||||
(is (= 3 (:zoom p)))
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
|
||||
(deftest the-zoom-is-an-integer-of-at-least-one
|
||||
;; 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 (= 1 (zoom-of 0)))
|
||||
(is (= 1 (zoom-of -4)))
|
||||
|
|
@ -90,19 +90,19 @@
|
|||
;; 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
|
||||
;; 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})]
|
||||
(is (= 48 (:frames p)) "the frame count does not move")
|
||||
(is (= 2 (:seconds p)) "and neither does the duration")
|
||||
(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"
|
||||
(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})]
|
||||
(is (= 48 (:poses p)) (str "picture-fps " fps))))))
|
||||
|
||||
(deftest plan-of-a-timeline-that-is-not-there-is-nothing
|
||||
(is (nil? (export/plan {:clip (a-clip {}) :timeline :nope :zoom 1}))))
|
||||
(deftest plan-of-a-symbol-that-is-not-there-is-nothing
|
||||
(is (nil? (export/plan {:clip (a-clip {}) :symbol :nope :zoom 1}))))
|
||||
|
||||
;; ---- the walk ----
|
||||
|
||||
|
|
@ -176,25 +176,25 @@
|
|||
(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
|
||||
;; "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}
|
||||
(recorder (atom {})) nil)
|
||||
nil
|
||||
(catch :default e e))]
|
||||
(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
|
||||
;; "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.
|
||||
(async done
|
||||
(let [c (assoc-in (a-clip {:frames 30})
|
||||
[:timelines :sym]
|
||||
(a-timeline 4))]
|
||||
(-> (run!* c :timeline :sym)
|
||||
[:symbols :sym]
|
||||
(a-symbol 4))]
|
||||
(-> (run!* c :symbol :sym)
|
||||
(.then (fn [{:keys [frames spec]}]
|
||||
(is (= 4 (count frames)) "the symbol's four frames, not the clip's 30")
|
||||
(is (= 4 (:frames spec)))
|
||||
|
|
@ -218,23 +218,23 @@
|
|||
it needs no clock."
|
||||
[& {:keys [voice?]}]
|
||||
{:name "stage" :fps 30 :width 8 :height 6
|
||||
:timelines
|
||||
{clip/root-id
|
||||
:symbols
|
||||
{:main
|
||||
{:frames 12
|
||||
: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])}}
|
||||
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])}}
|
||||
:loose (assoc (poly :loose "a4" [0 0 1 0 1 1] :brow)
|
||||
:parent :root)}
|
||||
voice? (assoc v1 {:id v1 :kind :audio :parent :root :z "a3"
|
||||
: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
|
||||
(let [tl (clip/timeline (staged :voice? true) clip/root-id)
|
||||
kept (set (keys (:nodes (export/isolate tl p1))))]
|
||||
(let [sym (clip/symbol (staged :voice? true) :main)
|
||||
kept (set (keys (:nodes (export/isolate sym p1))))]
|
||||
(is (contains? kept p1) "the placement itself")
|
||||
(is (contains? kept :root) "and the root it hangs from, or it would move")
|
||||
(is (contains? kept v1) "and the voice linked to it")
|
||||
|
|
@ -246,24 +246,24 @@
|
|||
(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
|
||||
;; 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)
|
||||
kept (set (keys (:nodes (export/isolate tl p2))))]
|
||||
(let [sym (clip/symbol (staged :voice? true) :main)
|
||||
kept (set (keys (:nodes (export/isolate sym p2))))]
|
||||
(is (= #{:root p2} kept))))
|
||||
|
||||
(deftest isolating-nothing-leaves-the-timeline-alone
|
||||
(let [tl (clip/timeline (staged :voice? true) clip/root-id)]
|
||||
(is (= tl (export/isolate tl nil)))
|
||||
(deftest isolating-nothing-leaves-the-symbol-alone
|
||||
(let [sym (clip/symbol (staged :voice? true) :main)]
|
||||
(is (= sym (export/isolate sym nil)))
|
||||
(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
|
||||
;; 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.
|
||||
(let [c (staged)]
|
||||
(is (= 12 (:frames (export/plan {:clip c :timeline clip/root-id :isolate p1}))))
|
||||
(is (= 6 (:frames (export/plan {:clip c :timeline :sym/face})))
|
||||
(is (= 12 (:frames (export/plan {:clip c :symbol :main :isolate p1}))))
|
||||
(is (= 6 (:frames (export/plan {:clip c :symbol :sym/face})))
|
||||
"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
|
||||
(async done
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.landmarks :as lm]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.timeline :as timeline]
|
||||
[arthur.domain.symbol :as symbol]
|
||||
[arthur.flow.condition.eyes :as condition-eyes]
|
||||
[arthur.flow.ingest :as ingest]
|
||||
[arthur.flow.measure.eyes :as eyes]
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
(assoc take/params :aspect 1 :name "observed-gap")
|
||||
{:face-1 {:dense @take/analysis :presence presence}})
|
||||
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))]
|
||||
(is (empty? (clip/problems clip)))
|
||||
(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 :mouth f)))))
|
||||
(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 :iris-r)))
|
||||
(is (contains? drawn :eye-l))
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.raster :as raster]
|
||||
[arthur.domain.ring :as ring]
|
||||
[arthur.domain.timeline :as timeline]
|
||||
[arthur.domain.symbol :as symbol]
|
||||
[arthur.flow.freeze :as freeze]))
|
||||
|
||||
(def ^:private W 320)
|
||||
|
|
@ -32,9 +32,9 @@
|
|||
(def clip* (delay (:clip @frozen)))
|
||||
;; Geometry assertions read the face timeline. Rendering assertions resolve
|
||||
;; the whole clip, including placement and inherited exposure.
|
||||
(defn- face-timeline [c] (clip/timeline c :face-1))
|
||||
(defn- nodes [c] (merge (clip/nodes c) (:nodes (face-timeline c))))
|
||||
(def tl* (delay (face-timeline @clip*)))
|
||||
(defn- face-symbol [c] (clip/symbol c :face-1))
|
||||
(defn- nodes [c] (merge (:nodes (clip/symbol c :main)) (:nodes (face-symbol c))))
|
||||
(def sym* (delay (face-symbol @clip*)))
|
||||
(def store (delay (:store @frozen)))
|
||||
|
||||
(defn- node [id] (get (nodes @clip*) id))
|
||||
|
|
@ -53,8 +53,8 @@
|
|||
|
||||
(defn- ops-at
|
||||
"Ops for one frame of a TIMELINE."
|
||||
[tl f]
|
||||
((timeline/resolver tl @store pal/index-of) f))
|
||||
[sym f]
|
||||
((symbol/resolver sym @store pal/index-of) f))
|
||||
|
||||
(defn- render
|
||||
"One frame of a CLIP into a byte buffer. The stage's size comes off the clip and
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
[c f]
|
||||
(let [r (raster/make (:width c) (:height c))]
|
||||
(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)))))
|
||||
|
||||
(defn- drawn
|
||||
|
|
@ -86,11 +86,11 @@
|
|||
(is (empty? (clip/problems c)) (pr-str (clip/problems c)))))
|
||||
|
||||
(deftest the-tree-is-the-one-the-model-specifies
|
||||
(is (= [:face :root] (timeline/lineage (clip/nodes @clip*) :face)))
|
||||
(is (= :face-1 (get-in @clip* [:timelines :main :nodes :face-1 :of])))
|
||||
(is (= [:head] (timeline/lineage (:nodes @tl*) :head)))
|
||||
(is (= [:mouth :head] (timeline/lineage (:nodes @tl*) :mouth)))
|
||||
(is (= [:mouth-in :mouth :head] (timeline/lineage (:nodes @tl*) :mouth-in)))
|
||||
(is (= [:face :root] (symbol/lineage (:nodes (clip/symbol @clip* :main)) :face)))
|
||||
(is (= :face-1 (get-in @clip* [:symbols :main :nodes :face-1 :of])))
|
||||
(is (= [:head] (symbol/lineage (:nodes @sym*) :head)))
|
||||
(is (= [:mouth :head] (symbol/lineage (:nodes @sym*) :mouth)))
|
||||
(is (= [:mouth-in :mouth :head] (symbol/lineage (:nodes @sym*) :mouth-in)))
|
||||
(is (= {:mode :map :expose 2} (:time (node :root))))
|
||||
(is (every? #(nil? (:time (node %))) [:face :head :mouth :mouth-in])))
|
||||
|
||||
|
|
@ -195,7 +195,7 @@
|
|||
;; checking arithmetic against itself; this checks `node/local!`, `node/world!`
|
||||
;; and `emit` as well.
|
||||
(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])))
|
||||
anc (:value (chan :face [:xform :anchor]))
|
||||
pos (:value (chan :face [:xform :pos]))
|
||||
|
|
@ -252,16 +252,16 @@
|
|||
"anchor source addresses survive the document round trip")))
|
||||
|
||||
(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))
|
||||
@store pal/index-of)
|
||||
held (timeline/resolver (face-timeline
|
||||
held (symbol/resolver (face-symbol
|
||||
(freeze/head-mode {:mode :anchored
|
||||
:anchors {0 12, 40 88}} @frozen))
|
||||
@store pal/index-of)
|
||||
world (fn [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 38)))
|
||||
(is (= (world free 88) (world held 40)))
|
||||
|
|
@ -285,7 +285,7 @@
|
|||
(get-in (nodes x) [:head :measured])))
|
||||
;; 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.
|
||||
(is (= (dissoc a :timelines) (dissoc x :timelines))))))
|
||||
(is (= (dissoc a :symbols) (dissoc x :symbols))))))
|
||||
|
||||
(deftest invalid-head-anchor-maps-are-refused
|
||||
(is (thrown-with-msg? ExceptionInfo #"free or anchored"
|
||||
|
|
@ -371,7 +371,7 @@
|
|||
;; interior comes and goes.
|
||||
(is (nil? (chan :mouth [:vis])))
|
||||
(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"))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
|
@ -403,8 +403,8 @@
|
|||
(let [strip-node (fn [n]
|
||||
(update n :channels
|
||||
#(into {} (map (fn [[p c]] [p (dissoc c :generated)])) %)))
|
||||
stripped (clip/update-root
|
||||
@clip*
|
||||
stripped (clip/update-symbol
|
||||
@clip* :main
|
||||
update :nodes
|
||||
#(into {} (map (fn [[id n]] [id (strip-node n)])) %))]
|
||||
(doseq [f (range 0 take/frames 17)]
|
||||
|
|
@ -419,9 +419,9 @@
|
|||
presence {:eye-r (mapv #(not (contains? gap %)) (range take/frames))}
|
||||
c (freeze/clip (assoc take/params :name "one-eye-gappy")
|
||||
{:face-1 (assoc @take/measured :presence presence)})
|
||||
tl (face-timeline (:clip c))
|
||||
sym (face-symbol (:clip c))
|
||||
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
|
||||
;; 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])))
|
||||
|
|
@ -431,7 +431,7 @@
|
|||
(is (not (ch/nothing? (at :eye-r 60))))
|
||||
(is (not (ch/nothing? (at :eye-l 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 (contains? drawn-nodes :eye-l))
|
||||
(is (contains? drawn-nodes :mouth)))
|
||||
|
|
@ -457,9 +457,9 @@
|
|||
windows)
|
||||
c (freeze/clip (assoc take/params :name "per-track-gaps")
|
||||
{:face-1 (assoc @take/measured :presence presence)})
|
||||
tl (face-timeline (:clip c))
|
||||
sym (face-symbol (:clip c))
|
||||
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
|
||||
;; track of the eye, iris, brow and brow-position blocks appears once.
|
||||
tracks [[:eye-r [:geom :pts] :eye-r]
|
||||
|
|
@ -485,7 +485,7 @@
|
|||
(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
|
||||
;; 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
|
||||
;; 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.
|
||||
|
|
@ -506,7 +506,7 @@
|
|||
;; Hoisted: the resolver caches its order and reuses its buffers, so the
|
||||
;; node ids come out before the next frame is asked for.
|
||||
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)))))
|
||||
ref-at (nodes-at ref)
|
||||
occ-at (nodes-at occ)
|
||||
|
|
@ -542,8 +542,8 @@
|
|||
det (mapv #(not (contains? gap %)) (range take/frames))
|
||||
c (freeze/clip (assoc take/params :name "gappy")
|
||||
{:face-1 (assoc @take/measured :detected det)})
|
||||
tl (face-timeline (:clip c))
|
||||
res (timeline/resolver tl (:store c) pal/index-of)]
|
||||
sym (face-symbol (:clip c))
|
||||
res (symbol/resolver sym (:store c) pal/index-of)]
|
||||
(doseq [f [39 40 50 59 60]]
|
||||
(let [ops (res 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
|
||||
;; unchanged across the gap, because hiding and absence are different
|
||||
;; 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))
|
||||
(mapv #(ch/value-at (chan :mouth-in [:vis]) %) (range take/frames))))))
|
||||
|
||||
|
|
@ -602,7 +602,7 @@
|
|||
shot (fn [f]
|
||||
(let [r (raster/make W H)
|
||||
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/draw-ops! r mouth)
|
||||
(vec (array-seq (:buf r)))))
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
[arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.feature :as feature]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.pose :as pose]
|
||||
[arthur.domain.project :as project]
|
||||
[arthur.events.footage :as footage]
|
||||
|
|
@ -33,20 +34,20 @@
|
|||
(delay (assoc (take/build settings @inputs) :source-inputs {:subjects @inputs})))
|
||||
|
||||
(defn channel [entry subject node path]
|
||||
(get-in entry [:clip :timelines subject :nodes node :channels path]))
|
||||
(defn snapshot [c store f] (ops/snapshot ((clip/resolver c store) f)))
|
||||
(get-in entry [:clip :symbols subject :nodes node :channels path]))
|
||||
(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)))
|
||||
|
||||
(deftest subjects-share-local-names-without-sharing-blocks
|
||||
(let [{:keys [clip store]} @initial
|
||||
a (get-in clip [:timelines :face-1 :nodes])
|
||||
b (get-in clip [:timelines :face-2 :nodes])]
|
||||
a (get-in clip [:symbols :face-1 :nodes])
|
||||
b (get-in clip [:symbols :face-2 :nodes])]
|
||||
(is (empty? (clip/problems clip)))
|
||||
(is (= (set (keys a)) (set (keys b))))
|
||||
(is (= :head (get-in b [:mouth :parent])))
|
||||
(is (= :eye-r-in (get-in b [:iris-r :stencil])))
|
||||
(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]]]
|
||||
(is (not= (:store (:dense (get-in a [: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
|
||||
(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))
|
||||
after (regenerate/change before {:scope :feature :id :face-2/eye-r
|
||||
:knob :gaze-gain :value 2})
|
||||
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 [:clip :timelines :face-1])
|
||||
(get-in after [:clip :timelines :face-1])
|
||||
(get-in anchored [:timelines :face-1])))
|
||||
(is (= (get-in before [:clip :symbols :face-1])
|
||||
(get-in after [:clip :symbols :face-1])
|
||||
(get-in anchored [:symbols :face-1])))
|
||||
(is (not= (channel before :face-2 :iris-r [:xform :pos])
|
||||
(channel after :face-2 :iris-r [:xform :pos])))
|
||||
(is (= (channel before :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)))))
|
||||
|
||||
(deftest the-second-subject-regenerates-inside-a-composed-stage
|
||||
|
|
@ -84,11 +85,11 @@
|
|||
after (regenerate/change before {:scope :subject :id :face-2
|
||||
:knob :anchor-avg :value 4})
|
||||
full (take/build (assoc settings :anchor-avg 4) @inputs)]
|
||||
(is (= (get-in full [:clip :timelines :face-2 :nodes :head :measured])
|
||||
(get-in after [:clip :timelines :face-2 :nodes :head :measured])))
|
||||
(doseq [tid [:main :sym/face-8625 :face-1]]
|
||||
(is (= (get-in before [:clip :timelines tid])
|
||||
(get-in after [:clip :timelines tid]))))
|
||||
(is (= (get-in full [:clip :symbols :face-2 :nodes :head :measured])
|
||||
(get-in after [:clip :symbols :face-2 :nodes :head :measured])))
|
||||
(doseq [sid [:main :sym/face-8625 :face-1]]
|
||||
(is (= (get-in before [:clip :symbols sid])
|
||||
(get-in after [:clip :symbols sid]))))
|
||||
(is (empty? (clip/problems (:clip after))))
|
||||
(is (thrown? ExceptionInfo
|
||||
(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
|
||||
(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)
|
||||
held (by-node cut store 4)
|
||||
source (by-node clip store 20)]
|
||||
|
|
@ -173,12 +174,12 @@
|
|||
|
||||
(deftest the-instance-boundary-preserves-the-single-face-geometry
|
||||
(let [{:keys [clip store]} (take/build settings (select-keys @inputs [:face-1]))
|
||||
flat (assoc-in clip [:timelines :main :nodes]
|
||||
(merge (dissoc (clip/nodes clip) :face-1)
|
||||
(assoc-in (get-in clip [:timelines :face-1 :nodes])
|
||||
flat (assoc-in clip [:symbols :main :nodes]
|
||||
(merge (dissoc (:nodes (clip/symbol clip :main)) :face-1)
|
||||
(assoc-in (get-in clip [:symbols :face-1 :nodes])
|
||||
[:head :parent] :face)))
|
||||
nested (clip/resolver clip store)
|
||||
reference (clip/resolver flat store)]
|
||||
nested (clip/resolver clip store pal/index-of :main)
|
||||
reference (clip/resolver flat store pal/index-of :main)]
|
||||
(doseq [f [0 1 7 20 39]]
|
||||
(let [a (ops/snapshot (nested f)) b (ops/snapshot (reference f))]
|
||||
(is (= (mapv (comp second :node) a) (mapv :node b)))
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
{:face-1 @inputs}))
|
||||
|
||||
(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
|
||||
(let [before @initial
|
||||
|
|
@ -45,8 +45,8 @@
|
|||
(channel after :iris-l [:xform :pos])))
|
||||
(is (= (channel before :mouth [:geom :pts])
|
||||
(channel after :mouth [:geom :pts])))
|
||||
(is (= (get-in before [:clip :timelines :main :nodes :face])
|
||||
(get-in after [:clip :timelines :main :nodes :face])))
|
||||
(is (= (get-in before [:clip :symbols :main :nodes :face])
|
||||
(get-in after [:clip :symbols :main :nodes :face])))
|
||||
(is (= (channel (full-at {:gaze-gain 2}) :iris-r [:xform :pos])
|
||||
(channel after :iris-r [:xform :pos])))))
|
||||
|
||||
|
|
@ -68,8 +68,8 @@
|
|||
{:scope :feature :id :face-1/mouth :knob :verts :value 10})]
|
||||
(is (not= (channel before :mouth [:geom :pts])
|
||||
(channel after :mouth [:geom :pts])))
|
||||
(is (= (get-in before [:clip :timelines :face-1 :nodes :head])
|
||||
(get-in after [:clip :timelines :face-1 :nodes :head])))
|
||||
(is (= (get-in before [:clip :symbols :face-1 :nodes :head])
|
||||
(get-in after [:clip :symbols :face-1 :nodes :head])))
|
||||
(is (= (channel before :eye-r [:geom :pts])
|
||||
(channel after :eye-r [:geom :pts])))
|
||||
(is (= (channel (full-at {:verts 10}) :mouth [:geom :pts])
|
||||
|
|
@ -80,19 +80,19 @@
|
|||
after (regenerate/change before
|
||||
{:scope :subject :id :face-1 :knob :anchor-avg :value 4})
|
||||
full (full-at {:anchor-avg 4})]
|
||||
(is (not= (get-in before [:clip :timelines :face-1 :nodes :head :measured])
|
||||
(get-in after [:clip :timelines :face-1 :nodes :head :measured])))
|
||||
(is (= (get-in full [:clip :timelines :face-1 :nodes :head :measured])
|
||||
(get-in after [:clip :timelines :face-1 :nodes :head :measured])))
|
||||
(is (= (get-in before [:clip :timelines :main :nodes :face])
|
||||
(get-in after [:clip :timelines :main :nodes :face])))))
|
||||
(is (not= (get-in before [:clip :symbols :face-1 :nodes :head :measured])
|
||||
(get-in after [:clip :symbols :face-1 :nodes :head :measured])))
|
||||
(is (= (get-in full [:clip :symbols :face-1 :nodes :head :measured])
|
||||
(get-in after [:clip :symbols :face-1 :nodes :head :measured])))
|
||||
(is (= (get-in before [:clip :symbols :main :nodes :face])
|
||||
(get-in after [:clip :symbols :main :nodes :face])))))
|
||||
|
||||
(deftest contour-edit-does-not-rebuild-head-or-teeth
|
||||
(let [before @initial
|
||||
after (regenerate/change before
|
||||
{:scope :subject :id :face-1 :knob :contour-avg :value 3})]
|
||||
(is (= (get-in before [:clip :timelines :face-1 :nodes :head])
|
||||
(get-in after [:clip :timelines :face-1 :nodes :head])))
|
||||
(is (= (get-in before [:clip :symbols :face-1 :nodes :head])
|
||||
(get-in after [:clip :symbols :face-1 :nodes :head])))
|
||||
(is (= (channel before :teeth [:geom :pts])
|
||||
(channel after :teeth [:geom :pts])))))
|
||||
|
||||
|
|
@ -215,7 +215,7 @@
|
|||
"the composed stage keeps the analysis the edit needs")
|
||||
(is (some? (:source-inputs entry)))
|
||||
(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))))))))
|
||||
|
||||
(deftest a-stage-edit-plans-the-same-features-as-a-take-edit
|
||||
|
|
@ -238,8 +238,8 @@
|
|||
(sym-channel after :iris-l [:geom :radius]))
|
||||
"and only the edited side of it")
|
||||
(testing "the placements are left exactly as they were"
|
||||
(is (= (get-in before [:clip :timelines :main :nodes])
|
||||
(get-in after [:clip :timelines :main :nodes]))))
|
||||
(is (= (get-in before [:clip :symbols :main :nodes])
|
||||
(get-in after [:clip :symbols :main :nodes]))))
|
||||
(testing "and the document is still a document"
|
||||
(is (empty? (clip/problems (:clip after)))))))
|
||||
|
||||
|
|
@ -249,8 +249,8 @@
|
|||
;; successful edit of the drawing.
|
||||
(let [after (regenerate/change (staged)
|
||||
{:scope :feature :id :face-1/eye-r :knob :iris-size :value 0.6})
|
||||
nodes (get-in after [:clip :timelines :main :nodes])
|
||||
syms (filter (comp #{:symbol} :kind val) nodes)]
|
||||
nodes (get-in after [:clip :symbols :main :nodes])
|
||||
syms (filter (comp #{:instance} :kind val) nodes)]
|
||||
(is (= 7 (count syms)))
|
||||
(is (every? uuid? (map key syms)))
|
||||
(doseq [[_ n] (filter (comp #{:audio} :kind val) nodes)]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
(ns arthur.support.ops
|
||||
"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
|
||||
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
|
||||
|
|
@ -13,14 +13,14 @@
|
|||
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
|
||||
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
|
||||
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
|
||||
changes what the first one says. Reading the points out is what pins the frame."
|
||||
(:require [arthur.domain.palette :as pal]
|
||||
[arthur.domain.timeline :as timeline]))
|
||||
[arthur.domain.symbol :as symbol]))
|
||||
|
||||
(defn points
|
||||
"An op's points as a vector of [x y], read out of its buffer."
|
||||
|
|
@ -50,13 +50,13 @@
|
|||
|
||||
(defn specified
|
||||
"(fn [f] -> snapshot) through `eval-frame`, the specification."
|
||||
([tl store] (specified tl store pal/index-of))
|
||||
([tl store palette]
|
||||
(fn [f] (snapshot (timeline/eval-frame tl f store palette)))))
|
||||
([sym store] (specified sym store pal/index-of))
|
||||
([sym store palette]
|
||||
(fn [f] (snapshot (symbol/eval-frame sym f store palette)))))
|
||||
|
||||
(defn resolved
|
||||
"(fn [f] -> snapshot) through `resolver`, the playback path."
|
||||
([tl store] (resolved tl store pal/index-of))
|
||||
([tl store palette]
|
||||
(let [res (timeline/resolver tl store palette)]
|
||||
([sym store] (resolved sym store pal/index-of))
|
||||
([sym store palette]
|
||||
(let [res (symbol/resolver sym store palette)]
|
||||
(fn [f] (snapshot (res f))))))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue