Compare commits
128 commits
port/cljs-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddef5c6bfd | ||
|
|
e7f5f82845 | ||
|
|
2e021a13eb | ||
|
|
39c436584b | ||
|
|
357ff1f3b9 | ||
|
|
00b8ed34ee | ||
|
|
925c12fc77 | ||
|
|
a6b6c116c6 | ||
|
|
e26ad723fa | ||
|
|
8d20097e61 | ||
|
|
78fb120edd | ||
|
|
208dddee07 | ||
|
|
f238ff4f62 | ||
|
|
eca1a96b82 | ||
|
|
064a3d7c19 | ||
|
|
6de71c4c66 | ||
|
|
9bc406f413 | ||
|
|
1c520e6a68 | ||
|
|
52f25e0bb1 | ||
|
|
4d441ae606 | ||
|
|
606055382b | ||
|
|
f2fc261221 | ||
|
|
e5b61bfcdd | ||
|
|
ee603a351b | ||
|
|
981bef98f9 | ||
|
|
6df315b73d | ||
|
|
484b4f1698 | ||
|
|
1ae49a4015 | ||
|
|
f4dd047642 | ||
|
|
17e1b4f403 | ||
|
|
353cb6e050 | ||
|
|
1f0b4d9918 | ||
|
|
551d572347 | ||
|
|
5b5b9ae4c3 | ||
|
|
90b1fbe2f8 | ||
|
|
09b74de162 | ||
|
|
2460dce0a5 | ||
|
|
0d49db793b | ||
|
|
a90e0cfb61 | ||
|
|
2697401aad | ||
|
|
a834ccb1e2 | ||
|
|
15deaea19e | ||
|
|
5bcf22e458 | ||
|
|
664252e0fc | ||
|
|
edca82cd5d | ||
|
|
987e289f89 | ||
|
|
7c49c08bc7 | ||
|
|
26fab9392e | ||
|
|
dff23d7994 | ||
|
|
b41180db08 | ||
|
|
8f09b7b47f | ||
|
|
1b2b4ad3d2 | ||
|
|
f5a39aee39 | ||
|
|
0cb9d0ebf6 | ||
|
|
4e5e02c856 | ||
|
|
93f5112bb3 | ||
|
|
e459307a4a | ||
|
|
fb38990090 | ||
|
|
d029908f0b | ||
|
|
6e7827e03f | ||
|
|
10b96761fa | ||
|
|
2dc5735ded | ||
|
|
95451798d2 | ||
|
|
5ebe776ce4 | ||
|
|
26ada03591 | ||
|
|
4ddd6a8d1d | ||
|
|
0a53157b7e | ||
|
|
abefa1c452 | ||
|
|
6e9409b0de | ||
|
|
34f62ab007 | ||
|
|
3879d76d57 | ||
|
|
6443366748 | ||
|
|
340a8dbbd6 | ||
|
|
f7e16e5ef4 | ||
|
|
cc42155ffa | ||
|
|
02069e88f0 | ||
|
|
14673385d4 | ||
|
|
95cf2598ba | ||
|
|
05878ca48b | ||
|
|
a4ce750be2 | ||
|
|
e6d0ededb1 | ||
|
|
7e34d0c704 | ||
|
|
815ce449ea | ||
|
|
3dbbe285fc | ||
|
|
5fb04f6a7d | ||
|
|
2f1c9b9c02 | ||
|
|
7a54bfca56 | ||
|
|
598c186c4f | ||
|
|
76106d36ee | ||
|
|
72b57e3786 | ||
|
|
94c0a21de1 | ||
|
|
26517af2fd | ||
|
|
9446829774 | ||
|
|
3d3c1bbca0 | ||
|
|
624242b407 | ||
|
|
ee66680a0c | ||
|
|
11093079de | ||
|
|
ed88c5e674 | ||
|
|
ae03b61dca | ||
|
|
309c47e0a7 | ||
|
|
550cfe91e5 | ||
|
|
2a0426707a | ||
|
|
da7e293814 | ||
|
|
7aaf0a15bd | ||
|
|
277c0c3b63 | ||
|
|
c139e4d747 | ||
|
|
0f9ce826ef | ||
|
|
1ac21fdcab | ||
|
|
4b8123d5e2 | ||
|
|
7246534505 | ||
|
|
38850a29ba | ||
|
|
1b8bbc7372 | ||
|
|
6a53adb5e0 | ||
|
|
c17ee138f2 | ||
|
|
7bb80d315d | ||
|
|
490460bf45 | ||
|
|
eafbe6c4d2 | ||
|
|
6bec121108 | ||
|
|
1a42481575 | ||
|
|
41b4bdf110 | ||
|
|
270c5a4369 | ||
|
|
f590ff19cf | ||
|
|
d5f044c6da | ||
|
|
c81f91c442 | ||
|
|
6b41c6db94 | ||
|
|
2d2eb0fc9f | ||
|
|
5dff490162 | ||
|
|
179770d7d4 |
183 changed files with 32480 additions and 3617 deletions
1
.claude/worktrees/tracing-layers
Submodule
1
.claude/worktrees/tracing-layers
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit f4dd04764204506fc275180364d0366d693036f4
|
||||
|
|
@ -14,3 +14,5 @@ audio.wav
|
|||
manifest.json
|
||||
*.take
|
||||
*.tflite
|
||||
.claude
|
||||
.venv*
|
||||
|
|
|
|||
|
|
@ -40,4 +40,4 @@ RUN python manage.py collectstatic --noinput \
|
|||
|
||||
USER app
|
||||
EXPOSE 8000
|
||||
CMD ["sh", "-c", "python manage.py migrate --noinput && exec gunicorn server.wsgi:application --bind 0.0.0.0:8000 --workers 1 --threads 4 --timeout 120"]
|
||||
CMD ["sh", "-c", "python manage.py migrate --noinput && exec daphne --bind 0.0.0.0 --port 8000 server.asgi:application"]
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ mise exec -- python manage.py migrate
|
|||
./do start # Django + frontend watcher
|
||||
```
|
||||
|
||||
In the app, upload a video, choose its footage, click **load frames**, then
|
||||
**save**. Opening that project on another client reuses its saved landmarks and
|
||||
In the app, drop a video on the media pool — it uploads, extracts and runs
|
||||
detection — then **save**. Opening that project on another client reuses its saved landmarks and
|
||||
mouth crops without detecting source frames again. The upload path derives its
|
||||
footage response from database records; it does not create or consume a
|
||||
`manifest.json` file. See [frontend/README.md](frontend/README.md) for details.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class ProjectAdmin(admin.ModelAdmin):
|
|||
|
||||
@admin.register(Clip)
|
||||
class ClipAdmin(admin.ModelAdmin):
|
||||
list_display = ("cid", "project", "name", "footage", "analysis")
|
||||
list_display = ("cid", "project", "name")
|
||||
list_filter = ("project",)
|
||||
|
||||
|
||||
|
|
|
|||
87
clips/consumers.py
Normal file
87
clips/consumers.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""One socket per open project, and it is tl's, nearly line for line.
|
||||
|
||||
Two things ride it. DELTAS, which the server sends after a write commits — the
|
||||
socket is read-only for the document, and a dropped socket cannot lose a write.
|
||||
PRESENCE, which peers gossip between themselves: all the server does is hand out
|
||||
a connection id and stamp the sender's identity onto every message, so nobody can
|
||||
post as somebody else.
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
# Who is connected, per project: {group: {cid: presence}}. A cache of what has
|
||||
# already been relayed, so a joiner gets the room in one message. Process-local,
|
||||
# like the in-memory channel layer this runs on.
|
||||
ROOMS = {}
|
||||
|
||||
|
||||
def group(project_id):
|
||||
return f"project_{project_id}"
|
||||
|
||||
|
||||
def broadcast(project_id, delta, kind="delta"):
|
||||
"""Send a committed write to everyone in the project's room. `access` says
|
||||
only that who may write has changed, and each client asks for itself."""
|
||||
async_to_sync(get_channel_layer().group_send)(
|
||||
group(project_id), {"type": "project.delta", "delta": {"kind": kind, **delta}},
|
||||
)
|
||||
|
||||
|
||||
class ProjectConsumer(AsyncWebsocketConsumer):
|
||||
RELAYED = ("state",)
|
||||
|
||||
@property
|
||||
def room(self):
|
||||
return ROOMS.setdefault(self.group, {})
|
||||
|
||||
async def connect(self):
|
||||
self.group = group(self.scope["url_route"]["kwargs"]["project_id"])
|
||||
self.cid = uuid.uuid4().hex[:12]
|
||||
user = self.scope.get("user")
|
||||
self.username = user.get_username() if user and user.is_authenticated else None
|
||||
await self.channel_layer.group_add(self.group, self.channel_name)
|
||||
await self.accept()
|
||||
|
||||
me = {"cid": self.cid, "user": self.username}
|
||||
others = list(self.room.values())
|
||||
self.room[self.cid] = me
|
||||
await self.send(text_data=json.dumps({"kind": "welcome", **me}))
|
||||
await self.send(text_data=json.dumps({"kind": "roster", "peers": others}))
|
||||
await self._relay({"kind": "join"})
|
||||
|
||||
async def disconnect(self, code):
|
||||
if hasattr(self, "cid"):
|
||||
self.room.pop(self.cid, None)
|
||||
if not self.room:
|
||||
ROOMS.pop(self.group, None)
|
||||
await self._relay({"kind": "leave"})
|
||||
await self.channel_layer.group_discard(self.group, self.channel_name)
|
||||
|
||||
async def receive(self, text_data=None, bytes_data=None):
|
||||
try:
|
||||
msg = json.loads(text_data or "{}")
|
||||
except ValueError:
|
||||
return
|
||||
if not isinstance(msg, dict) or msg.get("kind") not in self.RELAYED:
|
||||
return
|
||||
if self.cid in self.room:
|
||||
self.room[self.cid].update(
|
||||
{k: v for k, v in msg.items() if k not in ("kind", "cid", "user")}
|
||||
)
|
||||
await self._relay(msg)
|
||||
|
||||
async def _relay(self, msg):
|
||||
await self.channel_layer.group_send(
|
||||
self.group,
|
||||
{"type": "peer.msg", "msg": {**msg, "cid": self.cid, "user": self.username}},
|
||||
)
|
||||
|
||||
async def peer_msg(self, event):
|
||||
await self.send(text_data=json.dumps(event["msg"]))
|
||||
|
||||
async def project_delta(self, event):
|
||||
await self.send(text_data=json.dumps(event["delta"]))
|
||||
|
|
@ -159,6 +159,108 @@ def _extract_stills(job, proxy_path, frames_dir, frames, root):
|
|||
|
||||
|
||||
MAX_RATE = 120 # a capture rate; past this the container is describing something else
|
||||
# How many packet timestamps `_measured_rate` reads, and the fewest intervals it
|
||||
# will draw a conclusion from. 300 is a flat cost on a long take and still a
|
||||
# wide enough sample for a median; below 8 intervals there is not enough of a
|
||||
# stream to outvote one odd timestamp, so the metadata is left to speak.
|
||||
RATE_SAMPLE = 300
|
||||
RATE_MINIMUM = 8
|
||||
# How far a declared rate may sit from the measured one and still be taken as
|
||||
# what the stream is: 2% covers 30 against 30000/1001 and nothing like 120
|
||||
# against 30.
|
||||
RATE_TOLERANCE = 0.02
|
||||
|
||||
|
||||
def probe_image(path):
|
||||
"""An uploaded still's pixel size as (width, height), refusing anything that
|
||||
is not one picture."""
|
||||
data = json.loads(_command(["ffprobe", "-v", "error", "-show_streams",
|
||||
"-of", "json", str(path)]))
|
||||
video = [s for s in data.get("streams", []) if s.get("codec_type") == "video"]
|
||||
if len(video) != 1 or not (video[0].get("width") and video[0].get("height")):
|
||||
raise ValueError("the uploaded file is not an image")
|
||||
return int(video[0]["width"]), int(video[0]["height"])
|
||||
|
||||
|
||||
def probe_audio(path):
|
||||
"""The length of an uploaded sound in seconds, refusing a file with no audio."""
|
||||
data = json.loads(_command(["ffprobe", "-v", "error", "-show_streams",
|
||||
"-show_format", "-of", "json", str(path)]))
|
||||
if not any(s.get("codec_type") == "audio" for s in data.get("streams", [])):
|
||||
raise ValueError("the uploaded file has no audio stream")
|
||||
duration = float(data.get("format", {}).get("duration") or 0)
|
||||
if duration <= 0:
|
||||
raise ValueError("the sound's length is unknown")
|
||||
return duration
|
||||
|
||||
|
||||
def _measured_rate(path):
|
||||
"""The rate the stream's own packet timestamps imply, or None.
|
||||
|
||||
THE CONTAINER'S SUMMARY OF ITSELF IS NOT EVIDENCE, and this is the function
|
||||
that goes and looks. An iPhone's `r_frame_rate` is 120 on footage whose
|
||||
timestamps are 1/30s apart, which is the difference between 323 frames and
|
||||
1293 — four times the encode, four times the tracing stills, four times the
|
||||
blobs, for 970 frames that are copies of their neighbours.
|
||||
|
||||
It reads TIMESTAMPS, not frames: `-show_entries packet=pts_time` demuxes
|
||||
without decoding, so this costs a file read and no pixels. The times are
|
||||
SORTED before differencing because a stream with B-frames arrives in decode
|
||||
order — an HEVC clip's first packets come out 0, 0.133, 0.067, 0.033 — and
|
||||
differencing that order measures the reordering rather than the rate.
|
||||
|
||||
THE MEDIAN INTERVAL, which is what makes this safe on genuinely variable
|
||||
input. It answers "how far apart are two frames normally", so a take held on
|
||||
one frame for a second still reports the rate of the parts that move, and
|
||||
choosing it keeps every distinct frame — the property `probe` used to reach
|
||||
for by taking the nominal rate. Only the last few intervals of the sample are
|
||||
unreliable (a frame whose turn comes after the window is missing from it), and
|
||||
a median does not care.
|
||||
|
||||
Returning None is the honest answer for a clip too short to sample, and this
|
||||
also swallows a probe that fails outright: the rate the metadata declares is
|
||||
the documented fallback, so an optimisation must not be able to refuse an
|
||||
upload that would otherwise have been accepted.
|
||||
"""
|
||||
try:
|
||||
text = _command(["ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "packet=pts_time", "-of", "json",
|
||||
"-read_intervals", f"%+#{RATE_SAMPLE}", str(path)])
|
||||
packets = json.loads(text).get("packets") or []
|
||||
times = sorted(float(packet["pts_time"]) for packet in packets
|
||||
if (packet.get("pts_time") or "N/A") != "N/A")
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
intervals = sorted(b - a for a, b in zip(times, times[1:]) if b > a)
|
||||
if len(intervals) < RATE_MINIMUM:
|
||||
return None
|
||||
median = intervals[len(intervals) // 2]
|
||||
return 1.0 / median if median > 0 else None
|
||||
|
||||
|
||||
def _choose_rate(nominal, average, measured):
|
||||
"""The rate to resample onto, as an exact Fraction.
|
||||
|
||||
A DECLARED RATE IS PREFERRED WHEN IT AGREES WITH THE TIMESTAMPS, because it is
|
||||
the exact rational the stream was authored at — 30000/1001 is not a float, and
|
||||
`limit_denominator` on a measured 29.97 is a guess at a number the container
|
||||
already states. So the measured rate is used to CHOOSE between what the
|
||||
container declares, and only stands in itself when neither declaration
|
||||
describes the stream.
|
||||
"""
|
||||
candidates = [rate for rate in (nominal, average) if 0 < rate <= MAX_RATE]
|
||||
if measured:
|
||||
agreeing = [rate for rate in candidates
|
||||
if abs(float(rate) - measured) <= RATE_TOLERANCE * measured]
|
||||
if agreeing:
|
||||
return min(agreeing, key=lambda rate: abs(float(rate) - measured))
|
||||
from_timestamps = Fraction(measured).limit_denominator(1001)
|
||||
if 0 < from_timestamps <= MAX_RATE:
|
||||
return from_timestamps
|
||||
# Nothing to go on but the metadata, and nominal first keeps the rate that
|
||||
# drops no distinct frame. An unusable pair falls through to the refusal
|
||||
# below, which names the rate the file claimed rather than one of these.
|
||||
return nominal if 0 < nominal <= MAX_RATE else average
|
||||
|
||||
|
||||
def probe(path):
|
||||
|
|
@ -178,12 +280,21 @@ def probe(path):
|
|||
of itself disagreed with the container's own contents, so the guard rejected
|
||||
CFR video for being variable.
|
||||
|
||||
THE RATE IS THE NOMINAL ONE. `r_frame_rate` is the rate every timestamp in the
|
||||
stream can be expressed at, which is the rate that keeps every distinct source
|
||||
frame; resampling to the average would drop some. Duration is preserved either
|
||||
way — ffmpeg's CFR conversion is driven by timestamps, so the audio stays in
|
||||
sync at any rate — so this trades a possible duplicated frame against a
|
||||
certainly lost one.
|
||||
THE RATE IS MEASURED AND THE DECLARATIONS ARE VOTED ON, which is the same
|
||||
distrust applied to the one number that still comes from here. This used to
|
||||
take `r_frame_rate` outright — the rate every timestamp in the stream can be
|
||||
expressed at, and so the rate that keeps every distinct source frame. The
|
||||
trouble is that it is not a claim about frames at all: the file above declares
|
||||
120 and holds 30, and resampling it up cost four times the encode, four times
|
||||
the tracing stills and four times the blobs for 970 duplicated frames. So
|
||||
`_measured_rate` reads the timestamps, `_choose_rate` keeps whichever declared
|
||||
rate they bear out, and the nominal rate is believed when it is true rather
|
||||
than because it is nominal.
|
||||
|
||||
Duration is preserved either way — ffmpeg's CFR conversion is driven by
|
||||
timestamps, so the audio stays in sync at any rate — and the median interval
|
||||
keeps the no-distinct-frame-dropped property that taking the nominal rate was
|
||||
reaching for. See `_measured_rate`.
|
||||
"""
|
||||
data = json.loads(_command(["ffprobe", "-v", "error", "-show_streams",
|
||||
"-show_format", "-of", "json", str(path)]))
|
||||
|
|
@ -194,19 +305,25 @@ def probe(path):
|
|||
average = Fraction(video.get("avg_frame_rate") or "0")
|
||||
if nominal <= 0 and average <= 0:
|
||||
raise ValueError("the video's frame rate is unknown")
|
||||
rate = nominal if 0 < nominal <= MAX_RATE else average
|
||||
measured = _measured_rate(path)
|
||||
rate = _choose_rate(nominal, average, measured)
|
||||
if not 0 < rate <= MAX_RATE:
|
||||
raise ValueError(f"the video reports a frame rate of {float(rate):g}, which is "
|
||||
"not a rate footage can be measured at")
|
||||
duration = float(data.get("format", {}).get("duration") or 0)
|
||||
if duration > 0 and duration * float(rate) > 901:
|
||||
raise ValueError("video is longer than the 900-frame footage limit")
|
||||
# if duration > 0 and duration * float(rate) > 901:
|
||||
# raise ValueError("video is longer than the 900-frame footage limit")
|
||||
frames = video.get("nb_frames")
|
||||
return {"fps": float(rate),
|
||||
# The exact rate, for ffmpeg. 30000/1001 is not a float, and handing
|
||||
# `-r` a rounded one is how a long take drifts out of sync.
|
||||
"rate": f"{rate.numerator}/{rate.denominator}",
|
||||
"nominal_fps": float(nominal), "average_fps": float(average),
|
||||
# What the timestamps said, and null when there were too few to ask.
|
||||
# Recorded because it is the input to a decision this file used not to
|
||||
# make, and the one number that explains a chosen rate matching
|
||||
# neither declaration.
|
||||
"measured_fps": measured,
|
||||
"width": int(video["width"]), "height": int(video["height"]),
|
||||
"duration": duration,
|
||||
# KEPT, AND NO LONGER TRUSTED AS A COUNT. See the docstring: this is
|
||||
|
|
@ -326,8 +443,8 @@ def run(key):
|
|||
proxy_facts = probe(proxy_path)
|
||||
_refuse_a_shifted_timeline(proxy_path)
|
||||
frames = count_frames(proxy_path)
|
||||
if not 1 <= frames <= 900:
|
||||
raise ValueError(f"the proxy holds {frames} frames; the limit is 1–900")
|
||||
#if not 1 <= frames <= 900:
|
||||
# raise ValueError(f"the proxy holds {frames} frames; the limit is 1–900")
|
||||
# CHECKED AS A DURATION, not as a frame count. The page's clock is
|
||||
# `frame = floor(audio.currentTime * fps)`, so what must not drift is
|
||||
# how long the picture lasts against how long the audio lasts — and
|
||||
|
|
|
|||
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),
|
||||
]
|
||||
39
clips/migrations/0008_owners_editors_leaf_seq.py
Normal file
39
clips/migrations/0008_owners_editors_leaf_seq.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def orphans(apps, schema_editor):
|
||||
# Every project has an owner, and none of the ones saved before owners did.
|
||||
apps.get_model("clips", "Project").objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("clips", "0007_symbols_not_timelines"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(orphans, migrations.RunPython.noop),
|
||||
migrations.AddField(
|
||||
model_name="leaf",
|
||||
name="seq",
|
||||
field=models.PositiveBigIntegerField(
|
||||
default=0, help_text="the project seq of the write that last changed it"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="editors",
|
||||
field=models.ManyToManyField(blank=True, related_name="shared_projects",
|
||||
to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="owner",
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="projects", to=settings.AUTH_USER_MODEL),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
18
clips/migrations/0009_revision_blocks.py
Normal file
18
clips/migrations/0009_revision_blocks.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2.17 on 2026-09-30 01:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('clips', '0008_owners_editors_leaf_seq'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='revision',
|
||||
name='blocks',
|
||||
field=models.JSONField(default=dict, help_text="each clip's tier-2 block keys, by cid, so a restore can name them"),
|
||||
),
|
||||
]
|
||||
25
clips/migrations/0010_sounds.py
Normal file
25
clips/migrations/0010_sounds.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 5.2.17 on 2026-09-30 07:14
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('clips', '0009_revision_blocks'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Sound',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('filename', models.CharField(max_length=255)),
|
||||
('duration', models.FloatField(help_text='seconds, as ffprobe reports it')),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('blob', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sound_for', to='clips.blob')),
|
||||
],
|
||||
),
|
||||
]
|
||||
13
clips/migrations/0011_occurrence_schema.py
Normal file
13
clips/migrations/0011_occurrence_schema.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("clips", "0010_sounds")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="schema_version",
|
||||
field=models.PositiveIntegerField(default=3),
|
||||
),
|
||||
]
|
||||
18
clips/migrations/0012_sound_label.py
Normal file
18
clips/migrations/0012_sound_label.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2.17 on 2026-10-01 04:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('clips', '0011_occurrence_schema'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sound',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, help_text='what a person called it; the filename when empty. Separate from `filename` because the name on disk is a fact about the upload and renaming must not rewrite it', max_length=200),
|
||||
),
|
||||
]
|
||||
45
clips/migrations/0013_palette_track.py
Normal file
45
clips/migrations/0013_palette_track.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Schema 4: split animated symbol palettes from the authoring palette.
|
||||
|
||||
Before schema 4 a symbol's ``:palette`` leaf value was always a channel. It now
|
||||
names the static palette used when that symbol is the viewed root, while the
|
||||
old channel is retained as ``:palette-channel`` compatibility data. New edits
|
||||
use a real lane symbol referenced by ``:palette-track``.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Leaf = apps.get_model("clips", "Leaf")
|
||||
Project = apps.get_model("clips", "Project")
|
||||
for leaf in Leaf.objects.filter(path__contains="/symbol/"):
|
||||
parts = leaf.path.split("/")
|
||||
if len(parts) != 4 or parts[2] != "symbol":
|
||||
continue
|
||||
value = leaf.value
|
||||
if not (isinstance(value, list) and value[:1] == ["^ "]):
|
||||
continue
|
||||
out = list(value)
|
||||
changed = False
|
||||
for i in range(1, len(out) - 1, 2):
|
||||
if out[i] == "~:palette" and isinstance(out[i + 1], list):
|
||||
out[i] = "~:palette-channel"
|
||||
changed = True
|
||||
if changed:
|
||||
leaf.value = out
|
||||
leaf.version += 1
|
||||
leaf.save(update_fields=["value", "version"])
|
||||
Project.objects.update(schema_version=4)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("clips", "0012_sound_label")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="schema_version",
|
||||
field=models.PositiveIntegerField(default=4),
|
||||
),
|
||||
migrations.RunPython(forwards, migrations.RunPython.noop),
|
||||
]
|
||||
15
clips/migrations/0014_multiple_analyses.py
Normal file
15
clips/migrations/0014_multiple_analyses.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("clips", "0013_palette_track")]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="clip", name="analysis"),
|
||||
migrations.RemoveField(model_name="clip", name="footage"),
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="schema_version",
|
||||
field=models.PositiveIntegerField(default=5),
|
||||
),
|
||||
]
|
||||
48
clips/migrations/0015_tracing_images.py
Normal file
48
clips/migrations/0015_tracing_images.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Schema 6: tracing is a symbol.
|
||||
|
||||
A face's `:head :trace` is gone: its footage is a placement of a `:type :trace`
|
||||
symbol under the head, the trace keys are that placement's `:time :holds`, and the
|
||||
head follows them with `:reads`. Nothing is converted. Every project is marked 6,
|
||||
and one that still carries a `:trace` is refused when it is opened, by name and
|
||||
with what to do about it; the rest open as they did.
|
||||
|
||||
Images are stills to trace over, stored like sounds.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
apps.get_model("clips", "Project").objects.update(schema_version=6)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("clips", "0014_multiple_analyses")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Image",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False,
|
||||
primary_key=True, serialize=False)),
|
||||
("filename", models.CharField(max_length=255)),
|
||||
("label", models.CharField(
|
||||
blank=True, max_length=200,
|
||||
help_text="what a person called it; the filename when empty")),
|
||||
("width", models.PositiveIntegerField(help_text="pixels, as ffprobe reports them")),
|
||||
("height", models.PositiveIntegerField()),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("blob", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="image_for", to="clips.blob")),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="schema_version",
|
||||
field=models.PositiveIntegerField(default=6),
|
||||
),
|
||||
migrations.RunPython(forwards, migrations.RunPython.noop),
|
||||
]
|
||||
42
clips/migrations/0016_an_anchor_is_a_peg.py
Normal file
42
clips/migrations/0016_an_anchor_is_a_peg.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Schema 7: an anchor is a peg.
|
||||
|
||||
`[:xform :anchor]` is gone from the transform. `T(a)·M·T(-a)` is a transform
|
||||
conjugated by a translation — "do M in a frame shifted by a" — and a parent
|
||||
already is a shifted frame, so an anchor was a peg written inline: one that could
|
||||
not be selected, keyed, shared between nodes, or placed above a measured channel.
|
||||
A pivot nobody chose is now derived from what the node draws, per drag, and stored
|
||||
nowhere; a pivot to keep is a peg, an ordinary `:group` parent.
|
||||
|
||||
Nothing is converted, as in schema 6. Every project is marked 7, and one that
|
||||
still carries an anchor is refused when it is opened, by name and with what to do
|
||||
about it — `node/problems` in the frontend.
|
||||
|
||||
Not converted rather than not worth converting. Dropping an anchor is in fact
|
||||
pixel-exact wherever rotation and scale are the identity, since the anchor
|
||||
cancels out of the composition there — and that is everywhere a freeze, a drop or
|
||||
a new drawing wrote one. It is NOT exact on anything a hand has since turned or
|
||||
scaled, where the composed translation is `a + p - M·a`, and it cannot be made
|
||||
exact at all where `pos` is dense, because tier 2 is content-addressed and not
|
||||
rewritable here. A conversion would therefore be silent and right for most nodes
|
||||
and silent and wrong for exactly the ones somebody had hand-placed, which is the
|
||||
worse failure: a refusal names the document and says what to do.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
apps.get_model("clips", "Project").objects.update(schema_version=7)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("clips", "0015_tracing_images")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="schema_version",
|
||||
field=models.PositiveIntegerField(default=7),
|
||||
),
|
||||
migrations.RunPython(forwards, migrations.RunPython.noop),
|
||||
]
|
||||
45
clips/migrations/0017_a_node_has_a_pivot.py
Normal file
45
clips/migrations/0017_a_node_has_a_pivot.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Schema 8: a node has a pivot.
|
||||
|
||||
`[:xform :pivot]` is back in the transform, as the point rotation and scale are
|
||||
composed about: `local = T(pos)·T(piv)·R·K·S·T(-piv)`. Schema 7 deleted it, on
|
||||
the argument that an anchor is a peg — true as algebra, and not true as a feature.
|
||||
A peg is a node, and a turn about a point that is not the turning node's own
|
||||
origin still has to solve for a position to hold that point still; that solution
|
||||
is an arc in the angle while a position channel tweens along the chord, so it is
|
||||
right on the frame it is written and wrong on every frame between two keys. A
|
||||
drawing escaped it, since its origin is the middle of what it draws. A symbol
|
||||
instance could not: its origin is its symbol's, which is the top-left corner of
|
||||
the stage, so one keyed turn of an instance swung its drawing round that corner
|
||||
on an orbit the size of the stage.
|
||||
|
||||
CONVERTED, unlike 6 and 7, because adding this one is exact. A schema-7 node has
|
||||
no pivot; an absent pivot reads as [0 0]; and T(pos)·T(0)·M·T(-0) is T(pos)·M to
|
||||
the last bit of the mantissa. Every stored document therefore composes to exactly
|
||||
the matrices it composed to before, dense tier-2 transforms included, so there is
|
||||
nothing to guess at and no node a conversion could silently move. The version is
|
||||
restamped and nothing else is touched.
|
||||
|
||||
What a converted document does NOT get is a pivot somebody chose: nodes placed
|
||||
before this carry none, so they still turn about their own origin until the first
|
||||
turn or scale writes one — `gesture/with-pivot`, from the middle of what the node
|
||||
draws at that moment — or until the cross is dragged (ctrl/cmd-drag on the stage).
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
apps.get_model("clips", "Project").objects.update(schema_version=8)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("clips", "0016_an_anchor_is_a_peg")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="schema_version",
|
||||
field=models.PositiveIntegerField(default=8),
|
||||
),
|
||||
migrations.RunPython(forwards, migrations.RunPython.noop),
|
||||
]
|
||||
|
|
@ -24,7 +24,9 @@ without parsing its leaves: which footage, which analysis, which blocks.
|
|||
"""
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class Blob(models.Model):
|
||||
|
|
@ -50,6 +52,39 @@ class Source(models.Model):
|
|||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class Sound(models.Model):
|
||||
"""An uploaded sound file — mp3, wav, whatever the browser can decode — kept
|
||||
as uploaded. Not footage: it has no frames and nothing measures it, so it
|
||||
skips extraction and an audio node plays its bytes directly."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
blob = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="sound_for")
|
||||
filename = models.CharField(max_length=255)
|
||||
label = models.CharField(
|
||||
max_length=200, blank=True,
|
||||
help_text="what a person called it; the filename when empty. Separate "
|
||||
"from `filename` because the name on disk is a fact about the "
|
||||
"upload and renaming must not rewrite it",
|
||||
)
|
||||
duration = models.FloatField(help_text="seconds, as ffprobe reports it")
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class Image(models.Model):
|
||||
"""An uploaded still — a drawing, a photo, a model sheet — kept as uploaded, to
|
||||
be traced over. Never part of the picture: a document names its blob as a
|
||||
tracing symbol's `:media`, and the page draws it over the stage."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
blob = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="image_for")
|
||||
filename = models.CharField(max_length=255)
|
||||
label = models.CharField(max_length=200, blank=True,
|
||||
help_text="what a person called it; the filename when empty")
|
||||
width = models.PositiveIntegerField(help_text="pixels, as ffprobe reports them")
|
||||
height = models.PositiveIntegerField()
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class Extraction(models.Model):
|
||||
"""One requested decode of a source into immutable footage."""
|
||||
|
||||
|
|
@ -201,12 +236,21 @@ class Project(models.Model):
|
|||
|
||||
`schema_version` identifies the stored document format. `seq` counts writes
|
||||
to this particular project; it is not a format version. Every write bumps
|
||||
`seq`, and a client that sees `seq > local + 1` refetches once broadcasts exist.
|
||||
`seq`, and a client that sees `seq > local + 1` refetches.
|
||||
|
||||
ANYONE WITH THE LINK CAN VIEW; the owner and the editors can write. Every
|
||||
project has an owner.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="projects",
|
||||
)
|
||||
editors = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects",
|
||||
)
|
||||
name = models.CharField(max_length=200, default="untitled")
|
||||
schema_version = models.PositiveIntegerField(default=1)
|
||||
schema_version = models.PositiveIntegerField(default=8)
|
||||
seq = models.PositiveBigIntegerField(default=0)
|
||||
palette = models.CharField(max_length=64, default="arthur/default")
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
|
@ -219,10 +263,19 @@ class Project(models.Model):
|
|||
return f"{self.name} ({self.id})"
|
||||
|
||||
def bump(self):
|
||||
self.seq += 1
|
||||
self.save(update_fields=["seq", "updated"])
|
||||
"""The next seq, taken with an UPDATE so that inside a transaction it is
|
||||
also the write lock: two concurrent saves cannot both get the same one."""
|
||||
Project.objects.filter(id=self.id).update(
|
||||
seq=models.F("seq") + 1, updated=timezone.now()
|
||||
)
|
||||
self.refresh_from_db(fields=["seq", "updated"])
|
||||
return self.seq
|
||||
|
||||
def can_edit(self, user):
|
||||
return user.is_authenticated and (
|
||||
user.id == self.owner_id or self.editors.filter(id=user.id).exists()
|
||||
)
|
||||
|
||||
|
||||
class Clip(models.Model):
|
||||
"""Tier 1: the unit of work, and the thing leaf paths are scoped by.
|
||||
|
|
@ -235,12 +288,6 @@ class Clip(models.Model):
|
|||
cid = models.SlugField(max_length=64)
|
||||
name = models.CharField(max_length=200, blank=True)
|
||||
order = models.IntegerField(default=0)
|
||||
footage = models.ForeignKey(
|
||||
Footage, null=True, blank=True, on_delete=models.SET_NULL, related_name="clips"
|
||||
)
|
||||
analysis = models.ForeignKey(
|
||||
Analysis, null=True, blank=True, on_delete=models.SET_NULL, related_name="clips"
|
||||
)
|
||||
blocks = models.ManyToManyField(
|
||||
Block, blank=True, related_name="clips",
|
||||
help_text="the tier-2 blocks this clip's channels name",
|
||||
|
|
@ -271,6 +318,9 @@ class Leaf(models.Model):
|
|||
path = models.CharField(max_length=300)
|
||||
value = models.JSONField()
|
||||
version = models.PositiveBigIntegerField(default=1)
|
||||
seq = models.PositiveBigIntegerField(
|
||||
default=0, help_text="the project seq of the write that last changed it",
|
||||
)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
|
|
@ -288,7 +338,9 @@ class Leaf(models.Model):
|
|||
|
||||
|
||||
class Revision(models.Model):
|
||||
"""Tier 1: a snapshot of the authored layer, with a user and a summary.
|
||||
"""Tier 1: a snapshot of the authored layer, with a user and a summary — a
|
||||
named snapshot, which is how a person marks a version now that every edit
|
||||
saves itself.
|
||||
|
||||
ON AN EXPLICIT TRIGGER, not on every save. tl snapshots a small annotation
|
||||
layer; arthur's tier 1 will contain cel polygons, so a snapshot per save bloats
|
||||
|
|
@ -302,6 +354,9 @@ class Revision(models.Model):
|
|||
author = models.CharField(max_length=200, blank=True)
|
||||
summary = models.CharField(max_length=500, blank=True)
|
||||
document = models.JSONField(help_text="every leaf of the project, by path")
|
||||
blocks = models.JSONField(
|
||||
default=dict, help_text="each clip's tier-2 block keys, by cid, so a restore can name them",
|
||||
)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
|
|
|
|||
7
clips/routing.py
Normal file
7
clips/routing.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.urls import path
|
||||
|
||||
from .consumers import ProjectConsumer
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path("ws/projects/<uuid:project_id>", ProjectConsumer.as_asgi()),
|
||||
]
|
||||
|
|
@ -2,11 +2,14 @@
|
|||
{% comment %}
|
||||
The host page, served by Django since port-plan step 9.
|
||||
|
||||
It was `frontend/public/index.html`, served by shadow-cljs's `:dev-http`, and that
|
||||
key is gone. The bundle is unchanged: shadow-cljs writes it into
|
||||
`static/arthur/js` and staticfiles serves it from there, so `manage.py runserver`
|
||||
and `shadow-cljs watch app` are the whole dev loop with nothing copying files
|
||||
between them.
|
||||
It carries no styles of its own any more. They are `static/arthur/app.css`, which
|
||||
staticfiles serves from the same tree as the bundle — the page grew a five-pane
|
||||
application chrome and "the styles" stopped being a thing you read in passing on
|
||||
the way to the markup.
|
||||
|
||||
The bundle is unchanged: shadow-cljs writes it into `static/arthur/js` and
|
||||
staticfiles serves it from there, so `manage.py runserver` and `shadow-cljs watch
|
||||
app` are the whole dev loop with nothing copying files between them.
|
||||
|
||||
The CSRF token is rendered so that Django sets its cookie, which is what
|
||||
`arthur.fx.http` reads to write the `X-CSRFToken` header. Saves are ordinary POSTs
|
||||
|
|
@ -17,72 +20,12 @@ and PUTs with ordinary CSRF protection — no endpoint in this app is exempt.
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>arthur</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg: #12141c; --fg: #c9c3b4; }
|
||||
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); }
|
||||
body { font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
main { padding: 24px; }
|
||||
/* The preview is nearest-neighbour everywhere. A browser that smooths the
|
||||
upscale would misrepresent the look the tool exists to judge. */
|
||||
canvas { image-rendering: pixelated; }
|
||||
h1 { font-size: 14px; font-weight: normal; opacity: .5; margin: 0 0 12px; }
|
||||
.stage { display: block; background: #12141c; }
|
||||
.stage-wrap { position: relative; width: fit-content; }
|
||||
.paint-overlay { position: absolute; inset: 0; touch-action: none; }
|
||||
.paint-overlay circle { cursor: grab; }
|
||||
.paint-tools { width: 640px; margin-top: 9px; font-size: 12px; }
|
||||
.paint-tools .row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 4px 0; }
|
||||
.paint-tools select { color: var(--fg); background: #1c1f2b; border: 1px solid #2b3040; font: inherit; }
|
||||
.paint-tools .hint { color: #d0ba86; opacity: .8; }
|
||||
audio { display: none; }
|
||||
.transport { margin-top: 12px; width: 640px; }
|
||||
.transport .row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.transport .gap { flex: 1; }
|
||||
button {
|
||||
font: inherit; color: var(--fg); background: #1c1f2b;
|
||||
border: 1px solid #2b3040; padding: 3px 10px; cursor: pointer;
|
||||
}
|
||||
button:hover { background: #242836; }
|
||||
button:disabled { opacity: .45; cursor: wait; }
|
||||
button.on { background: #3a4258; border-color: #556080; }
|
||||
.scrub { width: 100%; margin: 10px 0 6px; }
|
||||
.readout { display: flex; gap: 18px; opacity: .55; font-size: 12px; }
|
||||
.readout .warn { color: #d98f5a; opacity: 1; }
|
||||
.picture-rate { display: flex; align-items: center; gap: 6px; margin-top: 7px;
|
||||
font-size: 12px; }
|
||||
.source-path { display: block; margin-top: 8px; font-size: 12px; opacity: .7; }
|
||||
.source-path select { margin: 0 8px; padding: 3px 5px;
|
||||
color: var(--fg); background: #1c1f2b; border: 1px solid #2b3040;
|
||||
font: inherit; max-width: 360px; }
|
||||
.load-status { margin-top: 6px; font-size: 12px; opacity: .75; }
|
||||
.export { width: 640px; margin-top: 14px; padding-top: 12px;
|
||||
border-top: 1px solid #2b3040; font-size: 12px; }
|
||||
.export .row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.export .gap { flex: 1; }
|
||||
.export select { margin-left: 6px; padding: 3px 5px; color: var(--fg);
|
||||
background: #1c1f2b; border: 1px solid #2b3040; font: inherit; }
|
||||
.export .readout { margin-top: 7px; }
|
||||
.export .note { margin: 7px 0 0; }
|
||||
.controls { width: 640px; margin-top: 18px; padding-top: 12px;
|
||||
border-top: 1px solid #2b3040; font-size: 12px; }
|
||||
.controls select { margin-left: 8px; padding: 3px 5px; color: var(--fg);
|
||||
background: #1c1f2b; border: 1px solid #2b3040; font: inherit; }
|
||||
.shared-note { margin-top: 6px; color: #d0ba86; }
|
||||
.control-list { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 16px;
|
||||
margin-top: 10px; }
|
||||
.control-row { display: grid; grid-template-columns: 115px 1fr 42px;
|
||||
align-items: center; gap: 6px; }
|
||||
.control-row input { width: 100%; }
|
||||
.control-row output { text-align: right; }
|
||||
.regeneration-debug { padding: 8px; margin-top: 10px; background: #1c1f2b;
|
||||
white-space: pre-wrap; color: #d0ba86; }
|
||||
.note { opacity: .35; font-size: 12px; max-width: 640px; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="{% static 'arthur/app.css' %}?v={{ css_version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% csrf_token %}
|
||||
<div id="app"></div>
|
||||
<script src="{% static 'mediapipe/vision_bundle.js' %}"></script>
|
||||
<script src="{% static 'arthur/js/main.js' %}"></script>
|
||||
<script src="{% static 'arthur/js/main.js' %}?v={{ js_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ from django.core.management import call_command
|
|||
from django.test import TestCase, override_settings
|
||||
|
||||
from clips import blobs, extraction
|
||||
from clips.models import Analysis, Block, Blob, Clip, Footage, Leaf, Project, Revision, Source
|
||||
from clips.models import Analysis, Block, Blob, Clip, Footage, Image, Leaf, Project, Revision, Sound, Source
|
||||
|
||||
BLOB_DIR = tempfile.mkdtemp(prefix="arthur-test-blobs-")
|
||||
|
||||
|
|
@ -361,7 +361,10 @@ class DocumentTests(TestCase):
|
|||
"""Tier 1: load, save, and the conditional write."""
|
||||
|
||||
def setUp(self):
|
||||
self.project = Project.objects.create(name="a project")
|
||||
from django.contrib.auth import get_user_model
|
||||
owner = get_user_model().objects.create_user("owner", password="password1")
|
||||
self.client.force_login(owner)
|
||||
self.project = Project.objects.create(name="a project", owner=owner)
|
||||
descriptor = analysis_descriptor()
|
||||
self.analysis = key_for(descriptor)
|
||||
self.client.post("/api/analyses", data=json.dumps(
|
||||
|
|
@ -382,37 +385,73 @@ 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],
|
||||
],
|
||||
}
|
||||
|
||||
def save(self, leaves=None, blocks=None):
|
||||
def save(self, leaves=None, blocks=None, analyses=None):
|
||||
return self.put(f"/api/projects/{self.project.id}", {
|
||||
"name": "a project",
|
||||
"clips": [{"cid": "c1", "name": "take", "analysis": self.analysis,
|
||||
"clips": [{"cid": "c1", "name": "take",
|
||||
"analyses": [self.analysis] if analyses is None else analyses,
|
||||
"leaves": leaves if leaves is not None else self.leaves(),
|
||||
"blocks": blocks if blocks is not None else [self.block]}],
|
||||
})
|
||||
|
||||
def test_a_clip_declares_the_registered_analyses_its_blocks_name(self):
|
||||
undeclared = self.save(analyses=[])
|
||||
self.assertEqual(409, undeclared.status_code)
|
||||
self.assertIn("every block", undeclared.json()["error"])
|
||||
|
||||
unknown = "sha256:" + "f" * 64
|
||||
missing = self.save(analyses=[self.analysis, unknown])
|
||||
self.assertEqual(409, missing.status_code)
|
||||
self.assertEqual([unknown], missing.json()["missing"])
|
||||
|
||||
def test_every_saved_symbol_is_listed_across_projects(self):
|
||||
leaves = self.leaves()
|
||||
leaves["clip/c1/symbol/sym~face"] = ["^ ", "~:name", "face", "~:frames", 12]
|
||||
leaves["clip/c1/symbol/sym~face/node/mark"] = ["^ ", "~:id", "~:mark", "~:z", "a1"]
|
||||
self.assertEqual(200, self.save(leaves).status_code)
|
||||
rows = self.client.get("/api/symbols").json()["symbols"]
|
||||
self.assertEqual(
|
||||
[("face", "sym~face", 12), ("main", "main", 48)],
|
||||
[(r["name"], r["symbol"], r["frames"]) for r in rows])
|
||||
self.assertEqual({str(self.project.id)}, {r["project"] for r in rows})
|
||||
self.assertEqual({"c1"}, {r["cid"] for r in rows})
|
||||
|
||||
def test_saved_palettes_are_listed_as_assets(self):
|
||||
leaves = self.leaves()
|
||||
leaves["clip/c1/palette/night"] = [
|
||||
"^ ", "~:id", "~:night", "~:name", "Moonlit",
|
||||
"~:slots", ["~#list", [["^ ", "~:hex", "#001122"]]],
|
||||
]
|
||||
self.assertEqual(200, self.save(leaves).status_code)
|
||||
rows = self.client.get("/api/symbols").json()["palettes"]
|
||||
self.assertEqual(
|
||||
[("Moonlit", "night", "c1")],
|
||||
[(r["name"], r["palette"], r["cid"]) for r in rows],
|
||||
)
|
||||
|
||||
def test_a_document_comes_back_exactly(self):
|
||||
response = self.save()
|
||||
self.assertEqual(200, response.status_code, response.content)
|
||||
self.assertEqual(5, len(response.json()["written"]))
|
||||
|
||||
loaded = self.client.get(f"/api/projects/{self.project.id}").json()
|
||||
self.assertEqual(1, loaded["schema_version"])
|
||||
self.assertEqual(7, loaded["schema_version"])
|
||||
self.assertEqual(1, len(loaded["clips"]))
|
||||
clip = loaded["clips"][0]
|
||||
self.assertEqual("c1", clip["cid"])
|
||||
self.assertEqual([self.block], clip["blocks"])
|
||||
self.assertEqual(self.analysis, clip["analysis"])
|
||||
self.assertNotIn("analysis", clip)
|
||||
# The whole point: byte-identical values, including the integer frame keys
|
||||
# transit writes as "~i0". A JSON round trip that stringified them would
|
||||
# come back "0" and the part would hold its first pose forever.
|
||||
|
|
@ -424,22 +463,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 +511,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 +527,7 @@ class DocumentTests(TestCase):
|
|||
# take-theirs. A PUT that replaced unconditionally is the bug where the
|
||||
# loser's work disappears silently.
|
||||
self.save()
|
||||
url = f"/api/projects/{self.project.id}/leaves/clip/c1/timeline/main/node/mouth"
|
||||
url = f"/api/projects/{self.project.id}/leaves/clip/c1/symbol/main/node/mouth"
|
||||
self.put(url, {"value": ["^ ", "~:z", "a2"]}, HTTP_IF_MATCH='"1"')
|
||||
stale = self.put(url, {"value": ["^ ", "~:z", "a3"]}, HTTP_IF_MATCH='"1"')
|
||||
self.assertEqual(409, stale.status_code)
|
||||
|
|
@ -511,6 +550,61 @@ class DocumentTests(TestCase):
|
|||
|
||||
# --- revisions ---------------------------------------------------------
|
||||
|
||||
def patch(self, base, leaves, removed=()):
|
||||
return self.put(f"/api/projects/{self.project.id}", {
|
||||
"base": base,
|
||||
"clips": [{"cid": "c1", "analyses": [self.analysis], "leaves": leaves,
|
||||
"removed": list(removed), "blocks": [self.block]}],
|
||||
})
|
||||
|
||||
def test_a_patch_leaves_what_it_does_not_name_alone(self):
|
||||
seq = self.save().json()["seq"]
|
||||
response = self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 24]},
|
||||
removed=["clip/c1/symbol/main/node/mouth"])
|
||||
self.assertEqual(200, response.status_code, response.content)
|
||||
self.assertEqual(["clip/c1/timing"], response.json()["written"])
|
||||
self.assertEqual(4, Leaf.objects.count())
|
||||
|
||||
def test_two_people_on_different_leaves_both_land(self):
|
||||
seq = self.save().json()["seq"]
|
||||
self.assertEqual(200, self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 24]}).status_code)
|
||||
# The second saver has not caught up, and touched a different leaf.
|
||||
response = self.patch(seq, {"clip/c1/symbol/main": ["^ ", "~:frames", 12]})
|
||||
self.assertEqual(200, response.status_code, response.content)
|
||||
leaves = self.client.get(f"/api/projects/{self.project.id}").json()["clips"][0]["leaves"]
|
||||
self.assertEqual(["^ ", "~:fps", 24], leaves["clip/c1/timing"])
|
||||
self.assertEqual(["^ ", "~:frames", 12], leaves["clip/c1/symbol/main"])
|
||||
|
||||
def test_two_people_on_one_leaf_is_a_conflict_that_writes_nothing(self):
|
||||
seq = self.save().json()["seq"]
|
||||
self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 24]})
|
||||
response = self.patch(seq, {"clip/c1/timing": ["^ ", "~:fps", 12],
|
||||
"clip/c1/symbol/main": ["^ ", "~:frames", 12]})
|
||||
self.assertEqual(409, response.status_code)
|
||||
self.assertEqual({"clip/c1/timing": ["^ ", "~:fps", 24]}, response.json()["conflicts"])
|
||||
self.assertEqual(seq + 1, Project.objects.get(id=self.project.id).seq)
|
||||
self.assertEqual(["^ ", "~:frames", 48],
|
||||
Leaf.objects.get(path="clip/c1/symbol/main").value)
|
||||
# Caught up to their seq, the same write is ordinary.
|
||||
self.assertEqual(200, self.patch(seq + 1, {"clip/c1/timing": ["^ ", "~:fps", 12]}).status_code)
|
||||
|
||||
def test_a_named_snapshot_restores_as_an_ordinary_write(self):
|
||||
self.save()
|
||||
snap = self.client.post(f"/api/projects/{self.project.id}/revisions",
|
||||
data=json.dumps({"summary": "before the big change"}),
|
||||
content_type="application/json").json()
|
||||
moved = self.leaves()
|
||||
moved["clip/c1/timing"] = ["^ ", "~:fps", 12]
|
||||
del moved["clip/c1/symbol/main/node/mouth"]
|
||||
self.save(moved)
|
||||
listed = self.client.get(f"/api/projects/{self.project.id}/revisions").json()["revisions"]
|
||||
self.assertEqual(["before the big change"], [r["summary"] for r in listed])
|
||||
restored = self.client.post(
|
||||
f"/api/projects/{self.project.id}/revisions/{snap['id']}/restore").json()
|
||||
self.assertEqual(2, restored["changed"])
|
||||
leaves = self.client.get(f"/api/projects/{self.project.id}").json()["clips"][0]["leaves"]
|
||||
self.assertEqual(self.leaves(), leaves)
|
||||
|
||||
def test_a_revision_snapshots_the_authored_layer(self):
|
||||
self.save()
|
||||
response = self.client.post(
|
||||
|
|
@ -587,6 +681,64 @@ class FootageTests(TestCase):
|
|||
with self.assertRaisesMessage(CommandError, "refusing an inaccurate footage"):
|
||||
call_command("ingest_bundle", str(root), stdout=StringIO())
|
||||
|
||||
def test_footage_can_be_renamed_and_falls_back_when_cleared(self):
|
||||
"""A LABEL IS THE ONE FIELD A CLIENT MAY WRITE ON FOOTAGE. The rest is a
|
||||
description of bytes that are content-addressed and immutable, so a
|
||||
rename that could reach `frames` or `digest` would let the pool's name
|
||||
for a clip contradict the clip."""
|
||||
footage = self.ingest(self.bundle())
|
||||
self.assertEqual("IMG_8608.MOV", self.client.get(
|
||||
f"/api/footage/{footage.id}").json()["label"])
|
||||
|
||||
renamed = self.client.patch(f"/api/footage/{footage.id}",
|
||||
json.dumps({"label": " the long take "}),
|
||||
content_type="application/json")
|
||||
self.assertEqual(200, renamed.status_code, renamed.content)
|
||||
self.assertEqual("the long take", renamed.json()["label"])
|
||||
# On the row, so every project listing this footage sees the new name.
|
||||
footage.refresh_from_db()
|
||||
self.assertEqual("the long take", footage.label)
|
||||
self.assertEqual("the long take",
|
||||
self.client.get("/api/footage").json()["footage"][0]["label"])
|
||||
|
||||
# Cleared gives back the name it was ingested under rather than nothing.
|
||||
cleared = self.client.patch(f"/api/footage/{footage.id}",
|
||||
json.dumps({"label": ""}),
|
||||
content_type="application/json")
|
||||
self.assertEqual("IMG_8608.MOV", cleared.json()["label"])
|
||||
self.assertEqual(3, Footage.objects.get().frames)
|
||||
|
||||
def test_a_rename_that_names_no_label_is_refused(self):
|
||||
footage = self.ingest(self.bundle())
|
||||
refused = self.client.patch(f"/api/footage/{footage.id}",
|
||||
json.dumps({"frames": 900}),
|
||||
content_type="application/json")
|
||||
self.assertEqual(400, refused.status_code)
|
||||
self.assertEqual("a rename needs a label", refused.json()["error"])
|
||||
self.assertEqual(3, Footage.objects.get().frames)
|
||||
|
||||
def test_a_sound_is_renamed_without_losing_the_name_it_arrived_as(self):
|
||||
"""`filename` is a fact about the upload and `label` is what a person
|
||||
called it, which is why renaming does not write over the first one."""
|
||||
digest, size = blobs.write_stream([b"RIFF....WAVEfmt "])
|
||||
blob = Blob.objects.create(digest=digest, size=size, media_type="audio/wav")
|
||||
sound = Sound.objects.create(blob=blob, filename="rec0012.wav", duration=2.5)
|
||||
|
||||
renamed = self.client.patch(f"/api/sounds/{sound.id}",
|
||||
json.dumps({"label": "arthur, line 4"}),
|
||||
content_type="application/json")
|
||||
self.assertEqual(200, renamed.status_code, renamed.content)
|
||||
self.assertEqual("arthur, line 4", renamed.json()["label"])
|
||||
self.assertEqual("rec0012.wav", renamed.json()["filename"])
|
||||
sound.refresh_from_db()
|
||||
self.assertEqual("rec0012.wav", sound.filename)
|
||||
self.assertEqual("arthur, line 4", sound.label)
|
||||
|
||||
self.client.patch(f"/api/sounds/{sound.id}", json.dumps({"label": " "}),
|
||||
content_type="application/json")
|
||||
self.assertEqual("rec0012.wav",
|
||||
self.client.get(f"/api/sounds/{sound.id}").json()["label"])
|
||||
|
||||
def test_the_footage_list_does_not_carry_every_url(self):
|
||||
# A list of takes should not be a list of six hundred URLs each.
|
||||
self.ingest(self.bundle())
|
||||
|
|
@ -651,6 +803,62 @@ class UploadTests(TestCase):
|
|||
self.assertEqual(27, job.progress)
|
||||
job.save.assert_called_once_with(update_fields=["progress", "updated"])
|
||||
|
||||
def test_an_uploaded_mp3_is_a_sound_and_not_footage(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "tone.mp3"
|
||||
subprocess.run([
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=1.5", str(path),
|
||||
], check=True, capture_output=True)
|
||||
payload = path.read_bytes()
|
||||
|
||||
uploaded = self.client.post("/api/sounds", {
|
||||
"file": SimpleUploadedFile("tone.mp3", payload, content_type="audio/mpeg")})
|
||||
self.assertEqual(201, uploaded.status_code, uploaded.content)
|
||||
sound = uploaded.json()
|
||||
self.assertEqual("tone.mp3", sound["label"])
|
||||
self.assertAlmostEqual(1.5, sound["duration"], delta=0.1)
|
||||
self.assertEqual(payload, b"".join(self.client.get(sound["audio"]).streaming_content))
|
||||
self.assertEqual(sound, self.client.get(f"/api/sounds/{sound['id']}").json())
|
||||
self.assertEqual([sound], self.client.get("/api/sounds").json()["sounds"])
|
||||
self.assertEqual(0, Source.objects.count())
|
||||
|
||||
again = self.client.post("/api/sounds", {
|
||||
"file": SimpleUploadedFile("again.mp3", payload, content_type="audio/mpeg")})
|
||||
self.assertEqual(200, again.status_code)
|
||||
self.assertEqual(1, Sound.objects.count())
|
||||
|
||||
def test_an_uploaded_still_is_an_image_named_by_its_bytes(self):
|
||||
payload = png(17, 5)
|
||||
uploaded = self.client.post("/api/images", {
|
||||
"file": SimpleUploadedFile("sheet.png", payload, content_type="image/png")})
|
||||
self.assertEqual(201, uploaded.status_code, uploaded.content)
|
||||
image = uploaded.json()
|
||||
self.assertEqual(("sheet.png", 17, 5), (image["label"], image["width"], image["height"]))
|
||||
self.assertEqual(f"/blob/{image['digest']}", image["url"])
|
||||
self.assertEqual(payload, b"".join(self.client.get(image["url"]).streaming_content))
|
||||
self.assertEqual([image], self.client.get("/api/images").json()["images"])
|
||||
|
||||
again = self.client.post("/api/images", {
|
||||
"file": SimpleUploadedFile("again.png", payload, content_type="image/png")})
|
||||
self.assertEqual(200, again.status_code)
|
||||
self.assertEqual(1, Image.objects.count())
|
||||
|
||||
renamed = self.client.patch(f"/api/images/{image['id']}",
|
||||
json.dumps({"label": "model sheet"}),
|
||||
content_type="application/json")
|
||||
self.assertEqual("model sheet", renamed.json()["label"])
|
||||
|
||||
def test_a_file_that_is_not_a_picture_is_not_an_image(self):
|
||||
refused = self.client.post("/api/images", {
|
||||
"file": SimpleUploadedFile("notes.txt", b"not a picture", content_type="text/plain")})
|
||||
self.assertEqual(400, refused.status_code)
|
||||
|
||||
def test_a_file_without_audio_is_not_a_sound(self):
|
||||
refused = self.client.post("/api/sounds", {
|
||||
"file": SimpleUploadedFile("notes.txt", b"not audio", content_type="text/plain")})
|
||||
self.assertEqual(400, refused.status_code)
|
||||
|
||||
def test_uploaded_video_extracts_to_reopenable_footage(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "four-frames.mp4"
|
||||
|
|
@ -700,6 +908,37 @@ class UploadTests(TestCase):
|
|||
self.assertEqual("image/jpeg", still["Content-Type"])
|
||||
self.assertEqual(200, self.client.get(footage["audio"]).status_code)
|
||||
|
||||
def test_re_uploading_a_source_re_reads_its_facts(self):
|
||||
# A SOURCE ROW HOLDS A READING, NOT A DECISION. The facts are a pure
|
||||
# function of bytes that are themselves this row's identity, so the row
|
||||
# cannot be the place a reading goes to be preserved: `probe` got better
|
||||
# at phone footage — it stopped believing a declared 120 over timestamps
|
||||
# 1/30s apart — and a stored reading that nothing can replace would have
|
||||
# left every already-uploaded source resampling to four times the frames
|
||||
# with no way to correct it short of deleting the row.
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "four-frames.mp4"
|
||||
subprocess.run([
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-f", "lavfi", "-i", "color=c=red:s=64x48:r=4:d=1",
|
||||
"-c:v", "mpeg4", str(path),
|
||||
], check=True, capture_output=True)
|
||||
payload = path.read_bytes()
|
||||
|
||||
first = self.client.post("/api/sources", {
|
||||
"file": SimpleUploadedFile("four-frames.mp4", payload, content_type="video/mp4")})
|
||||
self.assertEqual(201, first.status_code, first.content)
|
||||
self.assertEqual(4.0, first.json()["probe"]["fps"])
|
||||
|
||||
better = dict(first.json()["probe"], fps=12.0, rate="12/1", measured_fps=12.0)
|
||||
with patch("clips.extraction.probe", return_value=better):
|
||||
again = self.client.post("/api/sources", {
|
||||
"file": SimpleUploadedFile("same.mp4", payload, content_type="video/mp4")})
|
||||
self.assertEqual(200, again.status_code, again.content)
|
||||
self.assertFalse(again.json()["created"], "the same bytes are the same source")
|
||||
self.assertEqual("12/1", again.json()["probe"]["rate"])
|
||||
self.assertEqual(12.0, Source.objects.get(id=first.json()["id"]).probe["fps"])
|
||||
|
||||
def test_the_proxy_is_re_encoded_rather_than_the_upload_re_served(self):
|
||||
# The footage's identity is the proxy's digest, and the proxy is produced
|
||||
# by one ffmpeg invocation whatever the upload was. If the upload were
|
||||
|
|
@ -747,6 +986,73 @@ class UploadTests(TestCase):
|
|||
self.assertTrue(facts["vfr"], "the disagreement is still recorded, just not fatal")
|
||||
self.assertTrue(facts["has_audio"])
|
||||
|
||||
def test_a_declared_rate_the_timestamps_do_not_bear_out_is_not_resampled_to(self):
|
||||
# THE FOUR-TIMES. An iPhone container declares `r_frame_rate` 120 over a
|
||||
# stream whose frames are 1/30s apart, and taking the declaration at its
|
||||
# word turned an 11-second clip into 1293 proxy frames instead of 323:
|
||||
# four times the encode, four times the tracing stills, four times the
|
||||
# blobs and the rows, for 970 frames that are copies of their neighbours.
|
||||
# The timestamps are the evidence and they say 30.
|
||||
streams = json.dumps({"streams": [
|
||||
{"codec_type": "video", "r_frame_rate": "120/1",
|
||||
"avg_frame_rate": "96900/3233", "nb_frames": "323",
|
||||
"width": 1920, "height": 1440},
|
||||
{"codec_type": "audio"}],
|
||||
"format": {"duration": "10.775"}})
|
||||
# IN DECODE ORDER, which is how an HEVC stream really arrives — the first
|
||||
# packets of the fixture this was found on come out 0, 0.133, 0.067,
|
||||
# 0.033. Differencing that order unsorted measures the reordering delay
|
||||
# and not the rate, so the fixture keeps the hazard in it.
|
||||
shuffled = [0, 4, 2, 1, 3, 8, 6, 5, 7, 12, 10, 9, 11]
|
||||
packets = json.dumps({"packets": [{"pts_time": f"{i / 30:.6f}"} for i in shuffled]})
|
||||
with patch("clips.extraction._command", side_effect=[streams, packets]):
|
||||
facts = extraction.probe(Path("phone.mov"))
|
||||
self.assertEqual("96900/3233", facts["rate"], "resampled to the declared 120")
|
||||
self.assertAlmostEqual(30.0, facts["measured_fps"], places=2)
|
||||
|
||||
def test_a_genuine_high_rate_capture_is_still_taken_at_its_own_rate(self):
|
||||
# The other half of the same decision, and the one that would be easy to
|
||||
# break: a real 120fps capture must not be dragged down to anything. Its
|
||||
# declaration and its timestamps agree, so the declaration — the exact
|
||||
# rational the stream was authored at — is what is used.
|
||||
streams = json.dumps({"streams": [
|
||||
{"codec_type": "video", "r_frame_rate": "120/1", "avg_frame_rate": "120/1",
|
||||
"width": 640, "height": 480}],
|
||||
"format": {"duration": "2"}})
|
||||
packets = json.dumps({"packets": [{"pts_time": f"{i / 120:.6f}"} for i in range(13)]})
|
||||
with patch("clips.extraction._command", side_effect=[streams, packets]):
|
||||
facts = extraction.probe(Path("slowmo.mov"))
|
||||
self.assertEqual("120/1", facts["rate"])
|
||||
|
||||
def test_too_few_timestamps_to_measure_leaves_the_declaration_standing(self):
|
||||
# A clip with nine-ish frames cannot outvote one odd timestamp, so the
|
||||
# measurement declines to have an opinion and the nominal rate — the one
|
||||
# that drops no distinct frame — is used exactly as it was before.
|
||||
streams = json.dumps({"streams": [
|
||||
{"codec_type": "video", "r_frame_rate": "30/1", "avg_frame_rate": "24/1",
|
||||
"width": 640, "height": 480}],
|
||||
"format": {"duration": "0.1"}})
|
||||
packets = json.dumps({"packets": [{"pts_time": f"{i / 30:.6f}"} for i in range(3)]})
|
||||
with patch("clips.extraction._command", side_effect=[streams, packets]):
|
||||
facts = extraction.probe(Path("tiny.mov"))
|
||||
self.assertEqual("30/1", facts["rate"])
|
||||
self.assertIsNone(facts["measured_fps"])
|
||||
|
||||
def test_a_rate_measurement_that_fails_outright_cannot_refuse_an_upload(self):
|
||||
# The measurement is an optimisation. If ffprobe cannot read the packets
|
||||
# of a file whose streams it just read happily, the upload still has to be
|
||||
# accepted on its metadata — an optimisation that can reject work is worse
|
||||
# than no optimisation.
|
||||
streams = json.dumps({"streams": [
|
||||
{"codec_type": "video", "r_frame_rate": "25/1", "avg_frame_rate": "25/1",
|
||||
"width": 640, "height": 480}],
|
||||
"format": {"duration": "4"}})
|
||||
with patch("clips.extraction._command",
|
||||
side_effect=[streams, ValueError("ffprobe fell over")]):
|
||||
facts = extraction.probe(Path("awkward.mov"))
|
||||
self.assertEqual("25/1", facts["rate"])
|
||||
self.assertIsNone(facts["measured_fps"])
|
||||
|
||||
def test_the_proxy_rate_is_exact_rather_than_a_rounded_float(self):
|
||||
# 30000/1001 is not a float. Handing ffmpeg's -r a rounded one is how a
|
||||
# long take drifts out of sync with its own audio.
|
||||
|
|
@ -814,3 +1120,99 @@ class UploadTests(TestCase):
|
|||
digest="e" * 64, fps=12, frames=3, width=8, height=6, audio=blob)
|
||||
manifest = self.client.get(f"/api/footage/{footage.id}").json()
|
||||
self.assertIsNone(manifest["video"])
|
||||
|
||||
|
||||
@override_settings(BLOB_ROOT=BLOB_DIR)
|
||||
class OwnershipTests(TestCase):
|
||||
"""Anyone with the link reads; the owner and the editors write."""
|
||||
|
||||
def setUp(self):
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
self.ann = User.objects.create_user("ann", password="password1")
|
||||
self.bob = User.objects.create_user("bob", password="password1")
|
||||
self.project = Project.objects.create(name="ann's", owner=self.ann)
|
||||
|
||||
def write(self):
|
||||
return self.client.put(f"/api/projects/{self.project.id}",
|
||||
data=json.dumps({"name": "renamed", "clips": []}),
|
||||
content_type="application/json")
|
||||
|
||||
def test_anyone_with_the_link_can_read_and_nobody_else_can_write(self):
|
||||
loaded = self.client.get(f"/api/projects/{self.project.id}").json()
|
||||
self.assertEqual(("ann", False), (loaded["owner"], loaded["can_edit"]))
|
||||
self.assertEqual(403, self.write().status_code)
|
||||
self.client.login(username="bob", password="password1")
|
||||
self.assertEqual(403, self.write().status_code)
|
||||
|
||||
def test_the_owner_names_an_editor_who_can_then_write(self):
|
||||
self.client.login(username="bob", password="password1")
|
||||
self.assertEqual(403, self.client.post(
|
||||
f"/api/projects/{self.project.id}/editors", data=json.dumps({"username": "bob"}),
|
||||
content_type="application/json").status_code)
|
||||
self.client.login(username="ann", password="password1")
|
||||
self.assertEqual(200, self.write().status_code)
|
||||
self.assertEqual(["bob"], self.client.post(
|
||||
f"/api/projects/{self.project.id}/editors", data=json.dumps({"username": "bob"}),
|
||||
content_type="application/json").json()["editors"])
|
||||
self.client.login(username="bob", password="password1")
|
||||
self.assertTrue(self.client.get(f"/api/projects/{self.project.id}").json()["can_edit"])
|
||||
self.assertEqual(200, self.write().status_code)
|
||||
self.client.login(username="ann", password="password1")
|
||||
self.client.delete(f"/api/projects/{self.project.id}/editors/bob")
|
||||
self.client.login(username="bob", password="password1")
|
||||
self.assertEqual(403, self.write().status_code)
|
||||
|
||||
def test_a_project_is_made_by_somebody_signed_in_and_is_theirs(self):
|
||||
self.assertEqual(403, self.client.post("/api/projects", data=json.dumps({"name": "x"}),
|
||||
content_type="application/json").status_code)
|
||||
self.client.post("/api/signup", data=json.dumps(
|
||||
{"username": "cat", "password": "password1"}), content_type="application/json")
|
||||
self.assertEqual("cat", self.client.get("/api/me").json()["username"])
|
||||
mine = self.client.post("/api/projects", data=json.dumps({"name": "y"}),
|
||||
content_type="application/json").json()
|
||||
self.assertEqual(("cat", True), (mine["owner"], mine["can_edit"]))
|
||||
listed = {p["name"] for p in self.client.get("/api/projects").json()["projects"]}
|
||||
self.assertEqual({"y"}, listed)
|
||||
self.client.logout()
|
||||
self.assertEqual([], self.client.get("/api/projects").json()["projects"])
|
||||
|
||||
def test_a_project_has_an_address(self):
|
||||
response = self.client.get(f"/p/{self.project.id}")
|
||||
self.assertEqual(200, response.status_code)
|
||||
# The slug is the name, for people; the id is what finds it.
|
||||
self.assertEqual(200, self.client.get(f"/p/{self.project.id}/anything-at-all").status_code)
|
||||
self.assertContains(response, 'id="app"')
|
||||
|
||||
|
||||
class SocketTests(TestCase):
|
||||
"""A committed write reaches everyone in the room; presence is stamped."""
|
||||
|
||||
def test_a_save_is_broadcast_to_the_room(self):
|
||||
from asgiref.sync import async_to_sync, sync_to_async
|
||||
from channels.testing import WebsocketCommunicator
|
||||
from clips.consumers import broadcast
|
||||
from server.asgi import application
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
project = Project.objects.create(
|
||||
name="shared", owner=get_user_model().objects.create_user("host"))
|
||||
|
||||
async def scenario():
|
||||
peer = WebsocketCommunicator(application, f"/ws/projects/{project.id}",
|
||||
headers=[(b"origin", b"http://localhost")])
|
||||
connected, _ = await peer.connect()
|
||||
self.assertTrue(connected)
|
||||
self.assertEqual("welcome", (await peer.receive_json_from())["kind"])
|
||||
self.assertEqual([], (await peer.receive_json_from())["peers"])
|
||||
self.assertEqual("join", (await peer.receive_json_from())["kind"])
|
||||
# The server stamps who sent it; a claimed name is overwritten.
|
||||
await peer.send_json_to({"kind": "state", "frame": 12, "user": "forged"})
|
||||
state = await peer.receive_json_from()
|
||||
self.assertEqual((12, None), (state["frame"], state["user"]))
|
||||
await sync_to_async(broadcast)(project.id, {"seq": 1, "clips": []})
|
||||
delta = await peer.receive_json_from()
|
||||
self.assertEqual(("delta", 1), (delta["kind"], delta["seq"]))
|
||||
await peer.disconnect()
|
||||
|
||||
async_to_sync(scenario)()
|
||||
|
|
|
|||
|
|
@ -16,16 +16,28 @@ from django.urls import path
|
|||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("me", views.me),
|
||||
path("login", views.login),
|
||||
path("signup", views.signup),
|
||||
path("logout", views.logout),
|
||||
path("detector", views.detector),
|
||||
path("sources", views.sources),
|
||||
path("sounds", views.sounds),
|
||||
path("sounds/<uuid:sound_id>", views.sound_detail),
|
||||
path("images", views.images),
|
||||
path("images/<uuid:image_id>", views.image_detail),
|
||||
path("extractions", views.extractions),
|
||||
path("extractions/<str:key>", views.extraction_detail),
|
||||
path("footage", views.footage_list),
|
||||
path("footage/<uuid:footage_id>", views.footage_detail),
|
||||
path("projects", views.projects),
|
||||
path("symbols", views.symbols),
|
||||
path("projects/<uuid:project_id>", views.project_detail),
|
||||
path("projects/<uuid:project_id>/leaves/<path:leaf_path>", views.leaf_detail),
|
||||
path("projects/<uuid:project_id>/revisions", views.revisions),
|
||||
path("projects/<uuid:project_id>/revisions/<int:revision_id>/restore", views.restore),
|
||||
path("projects/<uuid:project_id>/editors", views.editors),
|
||||
path("projects/<uuid:project_id>/editors/<str:username>", views.editors),
|
||||
path("analyses", views.analyses),
|
||||
path("analyses/<str:key>", views.analysis_detail),
|
||||
path("blocks", views.blocks),
|
||||
|
|
|
|||
516
clips/views.py
516
clips/views.py
|
|
@ -32,14 +32,18 @@ from pathlib import Path
|
|||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate, get_user_model
|
||||
from django.contrib.auth import login as auth_login, logout as auth_logout
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.http import FileResponse, HttpResponse, JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from . import blobs, extraction
|
||||
from .models import Analysis, Block, Blob, Clip, Extraction, Footage, Leaf, Project, Revision, Source
|
||||
from .consumers import broadcast
|
||||
from .models import Analysis, Block, Blob, Clip, Extraction, Footage, Image, Leaf, Project, Revision, Sound, Source
|
||||
|
||||
KEY_LENGTH = 71 # "sha256:" + 64 hex
|
||||
|
||||
|
|
@ -119,10 +123,27 @@ def _crop_blob(chunks):
|
|||
# the page
|
||||
|
||||
|
||||
def page(request):
|
||||
def _asset_version(relative):
|
||||
"""A static file's modification time, for its URL.
|
||||
|
||||
The stylesheet and the bundle are served with `Last-Modified` and nothing
|
||||
else, so a browser is free to keep a stale copy on heuristic freshness — and
|
||||
new JavaScript over an old stylesheet renders a pane the stylesheet has never
|
||||
heard of as bare elements. A version in the URL makes each edit a new URL."""
|
||||
for root in settings.STATICFILES_DIRS:
|
||||
path = Path(root) / relative
|
||||
if path.exists():
|
||||
return str(int(path.stat().st_mtime))
|
||||
return "0"
|
||||
|
||||
|
||||
def page(request, project_id=None, slug=None):
|
||||
"""The host page. This replaced `frontend/public/index.html` at step 9, and
|
||||
`:dev-http` in shadow-cljs.edn went away with it."""
|
||||
return render(request, "clips/index.html")
|
||||
return render(request, "clips/index.html", {
|
||||
"css_version": _asset_version("arthur/app.css"),
|
||||
"js_version": _asset_version("arthur/js/main.js"),
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -197,6 +218,17 @@ def sources(request):
|
|||
"media_type": upload.content_type or "video/mp4"})
|
||||
row, created = Source.objects.get_or_create(
|
||||
blob=blob, defaults={"filename": Path(upload.name).name[:255], "probe": facts})
|
||||
if not created and row.probe != facts:
|
||||
# THE FACTS ARE RE-READ, NOT REMEMBERED. They are a pure function of
|
||||
# the bytes, and the bytes are this row's identity — so a
|
||||
# disagreement means the server reads the file differently now from
|
||||
# whenever it first saw it, and the fresh reading is the one to keep.
|
||||
# Storing the first reading forever pins a source to a rate the code
|
||||
# no longer believes in, and makes it unfixable without deleting the
|
||||
# row: `extraction.probe` got better at phone footage and every
|
||||
# already-uploaded source would have gone on being wrong.
|
||||
row.probe = facts
|
||||
row.save(update_fields=["probe"])
|
||||
return JsonResponse({"id": str(row.id), "digest": digest,
|
||||
"filename": row.filename, "probe": row.probe,
|
||||
"created": created}, status=201 if created else 200)
|
||||
|
|
@ -204,6 +236,112 @@ def sources(request):
|
|||
return JsonResponse({"error": str(exc)}, status=400)
|
||||
|
||||
|
||||
def _sound_json(row):
|
||||
return {"id": str(row.id), "label": row.label or row.filename,
|
||||
"filename": row.filename, "duration": row.duration,
|
||||
"audio": f"/blob/{row.blob_id}"}
|
||||
|
||||
|
||||
def _relabel(request, row):
|
||||
"""PATCH one asset's display name.
|
||||
|
||||
A LABEL IS THE ONLY FIELD EITHER ROW LETS A CLIENT WRITE, and the body is
|
||||
read for that key alone. Footage is content-addressed and its frame count,
|
||||
rate and digest are facts about the bytes; an endpoint that merged whatever
|
||||
it was sent would let a rename quietly contradict them. Blank clears it,
|
||||
which puts the row back to the name it was uploaded under rather than
|
||||
leaving it nameless.
|
||||
"""
|
||||
data = _body(request)
|
||||
if "label" not in data:
|
||||
raise Bad("a rename needs a label")
|
||||
label = str(data["label"] or "").strip()[:200]
|
||||
if label != row.label:
|
||||
row.label = label
|
||||
row.save(update_fields=["label"])
|
||||
return row
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def sounds(request):
|
||||
if request.method == "GET":
|
||||
return JsonResponse({"sounds": [_sound_json(row)
|
||||
for row in Sound.objects.order_by("-created")]})
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return JsonResponse({"error": "upload a sound as the file field"}, status=400)
|
||||
try:
|
||||
digest, size = blobs.write_stream(upload.chunks())
|
||||
duration = extraction.probe_audio(blobs.path_for(digest))
|
||||
blob, _ = Blob.objects.get_or_create(
|
||||
digest=digest, defaults={"size": size,
|
||||
"media_type": upload.content_type or "audio/mpeg"})
|
||||
row, created = Sound.objects.get_or_create(
|
||||
blob=blob, defaults={"filename": Path(upload.name).name[:255],
|
||||
"duration": duration})
|
||||
return JsonResponse(_sound_json(row), status=201 if created else 200)
|
||||
except (ValueError, OSError) as exc:
|
||||
return JsonResponse({"error": str(exc)}, status=400)
|
||||
|
||||
|
||||
@require_http_methods(["GET", "PATCH"])
|
||||
def sound_detail(request, sound_id):
|
||||
try:
|
||||
row = Sound.objects.get(id=sound_id)
|
||||
except Sound.DoesNotExist:
|
||||
return JsonResponse({"error": "no such sound"}, status=404)
|
||||
try:
|
||||
if request.method == "PATCH":
|
||||
row = _relabel(request, row)
|
||||
except Bad as exc:
|
||||
return _error(exc)
|
||||
return JsonResponse(_sound_json(row))
|
||||
|
||||
|
||||
def _image_json(row):
|
||||
return {"id": str(row.id), "label": row.label or row.filename,
|
||||
"filename": row.filename, "width": row.width, "height": row.height,
|
||||
"digest": row.blob_id, "url": f"/blob/{row.blob_id}"}
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def images(request):
|
||||
"""Stills to trace over. The document names one by its blob digest, so an
|
||||
image is the same picture in every project that uses it."""
|
||||
if request.method == "GET":
|
||||
return JsonResponse({"images": [_image_json(row)
|
||||
for row in Image.objects.order_by("-created")]})
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return JsonResponse({"error": "upload an image as the file field"}, status=400)
|
||||
try:
|
||||
digest, size = blobs.write_stream(upload.chunks())
|
||||
width, height = extraction.probe_image(blobs.path_for(digest))
|
||||
blob, _ = Blob.objects.get_or_create(
|
||||
digest=digest, defaults={"size": size,
|
||||
"media_type": upload.content_type or "image/png"})
|
||||
row, created = Image.objects.get_or_create(
|
||||
blob=blob, defaults={"filename": Path(upload.name).name[:255],
|
||||
"width": width, "height": height})
|
||||
return JsonResponse(_image_json(row), status=201 if created else 200)
|
||||
except (ValueError, OSError) as exc:
|
||||
return JsonResponse({"error": str(exc)}, status=400)
|
||||
|
||||
|
||||
@require_http_methods(["GET", "PATCH"])
|
||||
def image_detail(request, image_id):
|
||||
try:
|
||||
row = Image.objects.get(id=image_id)
|
||||
except Image.DoesNotExist:
|
||||
return JsonResponse({"error": "no such image"}, status=404)
|
||||
try:
|
||||
if request.method == "PATCH":
|
||||
row = _relabel(request, row)
|
||||
except Bad as exc:
|
||||
return _error(exc)
|
||||
return JsonResponse(_image_json(row))
|
||||
|
||||
|
||||
def _extraction_json(row):
|
||||
return {"key": row.key, "source": str(row.source_id), "state": row.state,
|
||||
"progress": row.progress, "error": row.error,
|
||||
|
|
@ -273,12 +411,70 @@ def footage_list(request):
|
|||
)
|
||||
|
||||
|
||||
_SYMBOL_LEAF = re.compile(r"^clip/([^/]+)/symbol/([^/]+)$")
|
||||
_PALETTE_LEAF = re.compile(r"^clip/([^/]+)/palette/([^/]+)$")
|
||||
|
||||
|
||||
def _transit_fields(value, *keys):
|
||||
"""Top-level fields of a transit map leaf, by keyword name. A leaf's own
|
||||
facts are a small flat map, so no key repeats and transit's cache never
|
||||
stands in for one; anything else reads as absent."""
|
||||
if not (isinstance(value, list) and value[:1] == ["^ "]):
|
||||
return {}
|
||||
pairs = dict(zip(value[1::2], value[2::2]))
|
||||
return {k: pairs.get(f"~:{k}") for k in keys}
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def symbols(request):
|
||||
"""Every symbol in every saved project, for the pool's all-assets folder.
|
||||
|
||||
Read off the leaf PATHS rather than by loading documents: a symbol's own leaf
|
||||
is `clip/<cid>/symbol/<sid>`, so listing them is one query and no decoding
|
||||
beyond the name and length its value carries."""
|
||||
rows = []
|
||||
for leaf in Leaf.objects.filter(path__contains="/symbol/").select_related("project"):
|
||||
m = _SYMBOL_LEAF.match(leaf.path)
|
||||
if not m:
|
||||
continue
|
||||
fields = _transit_fields(leaf.value, "name", "frames")
|
||||
rows.append({
|
||||
"project": str(leaf.project_id),
|
||||
"project_name": leaf.project.name,
|
||||
"cid": m.group(1),
|
||||
"symbol": m.group(2),
|
||||
"name": fields.get("name") or m.group(2).replace("~", "/"),
|
||||
"frames": fields.get("frames"),
|
||||
})
|
||||
rows.sort(key=lambda r: (r["project_name"], r["project"], r["name"]))
|
||||
palettes = []
|
||||
for leaf in Leaf.objects.filter(path__contains="/palette/").select_related("project"):
|
||||
m = _PALETTE_LEAF.match(leaf.path)
|
||||
if not m:
|
||||
continue
|
||||
fields = _transit_fields(leaf.value, "name")
|
||||
palettes.append({
|
||||
"project": str(leaf.project_id),
|
||||
"project_name": leaf.project.name,
|
||||
"cid": m.group(1),
|
||||
"palette": m.group(2),
|
||||
"name": fields.get("name") or m.group(2).replace("~", "/"),
|
||||
})
|
||||
palettes.sort(key=lambda r: (r["project_name"], r["project"], r["name"]))
|
||||
return JsonResponse({"symbols": rows, "palettes": palettes})
|
||||
|
||||
|
||||
@require_http_methods(["GET", "PATCH"])
|
||||
def footage_detail(request, footage_id):
|
||||
try:
|
||||
footage = Footage.objects.select_related("audio", "video", "stream").get(id=footage_id)
|
||||
except Footage.DoesNotExist:
|
||||
return JsonResponse({"error": "no such footage"}, status=404)
|
||||
try:
|
||||
if request.method == "PATCH":
|
||||
footage = _relabel(request, footage)
|
||||
except Bad as exc:
|
||||
return _error(exc)
|
||||
return JsonResponse(_footage_json(footage))
|
||||
|
||||
|
||||
|
|
@ -564,7 +760,11 @@ def block_detail(request, key):
|
|||
# tier 1: projects, clips, leaves
|
||||
|
||||
|
||||
def _project_json(project: Project):
|
||||
def _who(user):
|
||||
return {"username": user.get_username() if user.is_authenticated else None}
|
||||
|
||||
|
||||
def _project_json(project: Project, user):
|
||||
leaves = list(project.leaves.all())
|
||||
clips = []
|
||||
for clip in project.clips.all():
|
||||
|
|
@ -573,8 +773,6 @@ def _project_json(project: Project):
|
|||
{
|
||||
"cid": clip.cid,
|
||||
"name": clip.name,
|
||||
"footage": str(clip.footage_id) if clip.footage_id else None,
|
||||
"analysis": clip.analysis_id,
|
||||
"blocks": sorted(clip.blocks.values_list("key", flat=True)),
|
||||
"leaves": {leaf.path: leaf.value for leaf in leaves if leaf.path.startswith(prefix)},
|
||||
}
|
||||
|
|
@ -585,27 +783,103 @@ def _project_json(project: Project):
|
|||
"schema_version": project.schema_version,
|
||||
"seq": project.seq,
|
||||
"palette": project.palette,
|
||||
"owner": project.owner.get_username(),
|
||||
"editors": sorted(project.editors.values_list("username", flat=True)),
|
||||
"can_edit": project.can_edit(user),
|
||||
"clips": clips,
|
||||
}
|
||||
|
||||
|
||||
def _project(project_id):
|
||||
try:
|
||||
return Project.objects.select_related("owner").get(id=project_id)
|
||||
except Project.DoesNotExist:
|
||||
raise Bad("no such project", status=404)
|
||||
|
||||
|
||||
def _writable(request, project_id):
|
||||
project = _project(project_id)
|
||||
if not project.can_edit(request.user):
|
||||
raise Bad("only the owner and the editors can change this project; "
|
||||
"save a copy instead", status=403)
|
||||
return project
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# who you are
|
||||
#
|
||||
# Django's session cookie, and the page's CSRF cookie on every write. Nothing
|
||||
# here that a signed-in admin does not already have; the API gains a way in that
|
||||
# is not the admin's login page.
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def me(request):
|
||||
return JsonResponse(_who(request.user))
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
def login(request):
|
||||
data = json.loads(request.body or b"{}")
|
||||
user = authenticate(request, username=data.get("username"), password=data.get("password"))
|
||||
if user is None:
|
||||
return JsonResponse({"error": "wrong username or password"}, status=400)
|
||||
auth_login(request, user)
|
||||
return JsonResponse(_who(user))
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
def signup(request):
|
||||
data = json.loads(request.body or b"{}")
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password") or ""
|
||||
if not username or len(password) < 8:
|
||||
return JsonResponse({"error": "a username, and a password of 8 or more"}, status=400)
|
||||
User = get_user_model()
|
||||
if User.objects.filter(username__iexact=username).exists():
|
||||
return JsonResponse({"error": "that username is taken"}, status=409)
|
||||
user = User.objects.create_user(username=username, password=password)
|
||||
auth_login(request, user)
|
||||
return JsonResponse(_who(user), status=201)
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
def logout(request):
|
||||
auth_logout(request)
|
||||
return JsonResponse(_who(request.user))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tier 1: projects, clips, leaves
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def projects(request):
|
||||
"""GET lists what you own and are an editor of — nothing, signed out; POST
|
||||
makes one, owned by you. Every project has an owner, so making one needs you
|
||||
signed in."""
|
||||
if request.method == "GET":
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({"projects": []})
|
||||
visible = Q(owner=request.user) | Q(editors=request.user)
|
||||
return JsonResponse(
|
||||
{
|
||||
"projects": [
|
||||
{"id": str(p.id), "name": p.name,
|
||||
"schema_version": p.schema_version, "seq": p.seq,
|
||||
"owner": p.owner.get_username(),
|
||||
"updated": p.updated.isoformat()}
|
||||
for p in Project.objects.all()[:100]
|
||||
for p in Project.objects.filter(visible).distinct()
|
||||
.select_related("owner")[:100]
|
||||
]
|
||||
}
|
||||
)
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({"error": "sign in to make a project"}, status=403)
|
||||
try:
|
||||
data = _body(request)
|
||||
project = Project.objects.create(name=data.get("name") or "untitled")
|
||||
return JsonResponse(_project_json(project), status=201)
|
||||
project = Project.objects.create(name=data.get("name") or "untitled", owner=request.user)
|
||||
return JsonResponse(_project_json(project, request.user), status=201)
|
||||
except Bad as exc:
|
||||
return _error(exc)
|
||||
|
||||
|
|
@ -613,43 +887,74 @@ def projects(request):
|
|||
@require_http_methods(["GET", "PUT"])
|
||||
def project_detail(request, project_id):
|
||||
try:
|
||||
project = Project.objects.get(id=project_id)
|
||||
except Project.DoesNotExist:
|
||||
return JsonResponse({"error": "no such project"}, status=404)
|
||||
if request.method == "GET":
|
||||
return JsonResponse(_project_json(project))
|
||||
return JsonResponse(_project_json(_project(project_id), request.user))
|
||||
project = _writable(request, project_id)
|
||||
return _save(project, _body(request), request.user)
|
||||
except Bad as exc:
|
||||
return _error(exc)
|
||||
|
||||
|
||||
@require_http_methods(["POST", "DELETE"])
|
||||
def editors(request, project_id, username=None):
|
||||
"""The owner names who else can write. POST {username} adds; DELETE
|
||||
`editors/<username>` removes."""
|
||||
try:
|
||||
return _save(project, _body(request))
|
||||
project = _project(project_id)
|
||||
if not (request.user.is_authenticated and request.user.id == project.owner_id):
|
||||
raise Bad("only the owner can change who edits", status=403)
|
||||
if request.method == "POST":
|
||||
username = _body(request).get("username")
|
||||
user = get_user_model().objects.filter(username__iexact=username or "").first()
|
||||
if user is None:
|
||||
raise Bad(f"nobody is called {username!r}", status=404)
|
||||
if request.method == "POST":
|
||||
project.editors.add(user)
|
||||
else:
|
||||
project.editors.remove(user)
|
||||
broadcast(project.id, {}, kind="access")
|
||||
return JsonResponse({"editors": sorted(project.editors.values_list("username", flat=True))})
|
||||
except Bad as exc:
|
||||
return _error(exc)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _save(project: Project, data):
|
||||
"""A whole-document save: one clip's leaves replace that clip's leaves.
|
||||
def _save(project: Project, data, user):
|
||||
"""A save: one clip's leaves, written.
|
||||
|
||||
SCOPED BY CLIP, not by project. A payload that carries clip `a` does not
|
||||
disturb clip `b`'s leaves, because a save is not the only way the document
|
||||
changes — a single-leaf conditional write is — and a save that cleared
|
||||
everything it did not mention would be a save that undoes a collaborator.
|
||||
disturb clip `b`'s leaves.
|
||||
|
||||
Two shapes. Without `base`, a clip's leaves REPLACE that clip's leaves — the
|
||||
whole-document save. With `base`, the seq the client last caught up to, the
|
||||
save is a PATCH: `leaves` are the ones it changed, `removed` the ones it
|
||||
deleted, and nothing it did not mention is touched. A leaf it names that
|
||||
somebody else changed after `base`, to something else, is a conflict, and the
|
||||
whole save answers 409 with their values — last-writer-wins per leaf, with the
|
||||
loser told rather than silently clobbered. docs/architecture.md, "Make the
|
||||
merge unit small instead of clever".
|
||||
|
||||
A leaf whose value is unchanged keeps its VERSION. That is what makes the
|
||||
entity tag mean something: a save of a document where one channel moved
|
||||
invalidates one leaf's etag, not all four hundred.
|
||||
"""
|
||||
base = data.get("base")
|
||||
seq = project.bump()
|
||||
if data.get("name"):
|
||||
project.name = data["name"]
|
||||
if data.get("palette"):
|
||||
project.palette = data["palette"]
|
||||
project.save(update_fields=["name", "palette"])
|
||||
|
||||
written, removed, unchanged = [], [], []
|
||||
written, removed, unchanged, conflicts, deltas = [], [], [], {}, []
|
||||
for spec in data.get("clips") or []:
|
||||
cid = spec.get("cid")
|
||||
if not cid:
|
||||
raise Bad("every clip in a save names its cid")
|
||||
leaves = spec.get("leaves") or {}
|
||||
gone = spec.get("removed") or [] if base is not None else []
|
||||
prefix = f"clip/{cid}/"
|
||||
for path in leaves:
|
||||
for path in [*leaves, *gone]:
|
||||
if not path.startswith(prefix):
|
||||
raise Bad(
|
||||
f"leaf {path!r} is not addressed to clip {cid!r}",
|
||||
|
|
@ -657,7 +962,19 @@ def _save(project: Project, data):
|
|||
)
|
||||
|
||||
keys = spec.get("blocks") or []
|
||||
have = set(Block.objects.filter(key__in=keys).values_list("key", flat=True))
|
||||
analyses = spec.get("analyses") or []
|
||||
if (not isinstance(analyses, list)
|
||||
or not all(isinstance(key, str) for key in analyses)
|
||||
or len(analyses) != len(set(analyses))):
|
||||
raise Bad("a clip's analyses must be a list of distinct analysis ids")
|
||||
registered = set(Analysis.objects.filter(key__in=analyses)
|
||||
.values_list("key", flat=True))
|
||||
if unknown := [key for key in analyses if key not in registered]:
|
||||
raise Bad("this clip names analyses the server does not know; register them first",
|
||||
status=409, missing=unknown)
|
||||
|
||||
block_rows = list(Block.objects.filter(key__in=keys))
|
||||
have = {block.key for block in block_rows}
|
||||
if missing := [k for k in keys if k not in have]:
|
||||
# Referential integrity across the tiers, enforced where it can be:
|
||||
# a document that names blocks the server does not hold would load
|
||||
|
|
@ -667,39 +984,60 @@ def _save(project: Project, data):
|
|||
"before saving the document that points at them",
|
||||
status=409, missing=missing,
|
||||
)
|
||||
if undeclared := sorted({block.analysis_id for block in block_rows} - registered):
|
||||
raise Bad("every block in a clip must name one of that clip's analyses",
|
||||
status=409, missing=undeclared)
|
||||
|
||||
existing = {leaf.path: leaf for leaf in project.leaves.filter(path__startswith=prefix)}
|
||||
if base is not None:
|
||||
for path in [*leaves, *gone]:
|
||||
theirs = existing.get(path)
|
||||
if theirs and theirs.seq > base and (
|
||||
path not in leaves or theirs.value != leaves[path]):
|
||||
conflicts[path] = theirs.value
|
||||
if conflicts:
|
||||
continue
|
||||
|
||||
analysis = Analysis.objects.filter(key=spec.get("analysis")).first()
|
||||
footage = None
|
||||
if spec.get("footage"):
|
||||
footage = Footage.objects.filter(id=spec["footage"]).first()
|
||||
clip, _ = Clip.objects.update_or_create(
|
||||
project=project,
|
||||
cid=cid,
|
||||
defaults={"name": spec.get("name") or "", "analysis": analysis, "footage": footage},
|
||||
defaults={"name": spec.get("name") or ""},
|
||||
)
|
||||
clip.blocks.set(Block.objects.filter(key__in=keys))
|
||||
blocks = block_rows
|
||||
if base is None:
|
||||
clip.blocks.set(blocks)
|
||||
gone = [path for path in existing if path not in leaves]
|
||||
else:
|
||||
clip.blocks.add(*blocks)
|
||||
|
||||
existing = {leaf.path: leaf for leaf in project.leaves.filter(path__startswith=prefix)}
|
||||
changed = {}
|
||||
for path, value in leaves.items():
|
||||
leaf = existing.get(path)
|
||||
if leaf is None:
|
||||
Leaf.objects.create(project=project, path=path, value=value)
|
||||
written.append(path)
|
||||
Leaf.objects.create(project=project, path=path, value=value, seq=seq)
|
||||
elif leaf.value != value:
|
||||
leaf.value = value
|
||||
leaf.value, leaf.seq = value, seq
|
||||
leaf.version += 1
|
||||
leaf.save(update_fields=["value", "version", "updated"])
|
||||
written.append(path)
|
||||
leaf.save(update_fields=["value", "version", "seq", "updated"])
|
||||
else:
|
||||
unchanged.append(path)
|
||||
for path, leaf in existing.items():
|
||||
if path not in leaves:
|
||||
leaf.delete()
|
||||
removed.append(path)
|
||||
continue
|
||||
changed[path] = value
|
||||
dropped = [path for path in gone if path in existing]
|
||||
project.leaves.filter(path__in=dropped).delete()
|
||||
written += changed
|
||||
removed += dropped
|
||||
deltas.append({"cid": cid, "leaves": changed, "removed": dropped, "blocks": keys})
|
||||
|
||||
seq = project.seq + 1
|
||||
project.seq = seq
|
||||
project.save()
|
||||
if conflicts:
|
||||
raise Bad(
|
||||
"somebody else changed these since you last caught up",
|
||||
status=409, seq=seq - 1, conflicts=conflicts,
|
||||
)
|
||||
by = user.get_username() if user.is_authenticated else None
|
||||
transaction.on_commit(lambda: broadcast(project.id, {
|
||||
"seq": seq, "by": by, "name": project.name, "clips": deltas,
|
||||
}))
|
||||
return JsonResponse(
|
||||
{
|
||||
"id": str(project.id),
|
||||
|
|
@ -722,9 +1060,9 @@ def leaf_detail(request, project_id, leaf_path):
|
|||
for a painted cel that is the class of bug that ends trust in a tool.
|
||||
"""
|
||||
try:
|
||||
project = Project.objects.get(id=project_id)
|
||||
except Project.DoesNotExist:
|
||||
return JsonResponse({"error": "no such project"}, status=404)
|
||||
project = _project(project_id) if request.method == "GET" else _writable(request, project_id)
|
||||
except Bad as exc:
|
||||
return _error(exc)
|
||||
|
||||
leaf = project.leaves.filter(path=leaf_path).first()
|
||||
if request.method == "GET":
|
||||
|
|
@ -742,19 +1080,24 @@ def leaf_detail(request, project_id, leaf_path):
|
|||
return _error(Bad("a leaf write carries a value"))
|
||||
|
||||
match = request.headers.get("If-Match")
|
||||
with transaction.atomic():
|
||||
seq = project.bump()
|
||||
leaf = project.leaves.filter(path=leaf_path).first()
|
||||
if leaf is None:
|
||||
# ANY `If-Match` on a leaf that does not exist is a failed precondition,
|
||||
# `*` included: RFC 7232 gives `*` the meaning "the resource must already
|
||||
# exist", which is exactly the write a client makes when it believes it is
|
||||
# editing something. Creating it instead would turn "somebody deleted this
|
||||
# node" into a silent resurrection.
|
||||
# exist", which is exactly the write a client makes when it believes it
|
||||
# is editing something. Creating it instead would turn "somebody deleted
|
||||
# this node" into a silent resurrection.
|
||||
if match:
|
||||
transaction.set_rollback(True)
|
||||
return JsonResponse(
|
||||
{"error": "no such leaf", "path": leaf_path}, status=409
|
||||
)
|
||||
leaf = Leaf.objects.create(project=project, path=leaf_path, value=data["value"])
|
||||
leaf = Leaf.objects.create(project=project, path=leaf_path, value=data["value"], seq=seq)
|
||||
else:
|
||||
if match and match not in ("*", leaf.etag):
|
||||
transaction.set_rollback(True)
|
||||
response = JsonResponse(
|
||||
{
|
||||
"error": "stale write",
|
||||
|
|
@ -766,11 +1109,16 @@ def leaf_detail(request, project_id, leaf_path):
|
|||
)
|
||||
response["ETag"] = leaf.etag
|
||||
return response
|
||||
leaf.value = data["value"]
|
||||
leaf.value, leaf.seq = data["value"], seq
|
||||
leaf.version += 1
|
||||
leaf.save(update_fields=["value", "version", "updated"])
|
||||
leaf.save(update_fields=["value", "version", "seq", "updated"])
|
||||
cid = leaf_path.split("/")[1] if leaf_path.startswith("clip/") else None
|
||||
by = request.user.get_username() if request.user.is_authenticated else None
|
||||
transaction.on_commit(lambda: broadcast(project.id, {
|
||||
"seq": seq, "by": by, "name": project.name,
|
||||
"clips": [{"cid": cid, "leaves": {leaf.path: leaf.value}, "removed": [], "blocks": []}],
|
||||
}))
|
||||
|
||||
seq = project.bump()
|
||||
response = JsonResponse({"path": leaf.path, "version": leaf.version, "seq": seq})
|
||||
response["ETag"] = leaf.etag
|
||||
return response
|
||||
|
|
@ -778,16 +1126,17 @@ def leaf_detail(request, project_id, leaf_path):
|
|||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def revisions(request, project_id):
|
||||
"""Mark a version: one snapshot of the authored layer, with a summary."""
|
||||
"""Named snapshots: GET lists them, POST {summary} takes one of the document
|
||||
as it is now."""
|
||||
try:
|
||||
project = Project.objects.get(id=project_id)
|
||||
except Project.DoesNotExist:
|
||||
return JsonResponse({"error": "no such project"}, status=404)
|
||||
project = _project(project_id) if request.method == "GET" else _writable(request, project_id)
|
||||
except Bad as exc:
|
||||
return _error(exc)
|
||||
if request.method == "GET":
|
||||
return JsonResponse(
|
||||
{
|
||||
"revisions": [
|
||||
{"seq": r.seq, "author": r.author, "summary": r.summary,
|
||||
{"id": r.id, "seq": r.seq, "author": r.author, "summary": r.summary,
|
||||
"created": r.created.isoformat(), "leaves": len(r.document)}
|
||||
for r in project.revisions.all()[:100]
|
||||
]
|
||||
|
|
@ -797,8 +1146,57 @@ def revisions(request, project_id):
|
|||
revision = Revision.objects.create(
|
||||
project=project,
|
||||
seq=project.seq,
|
||||
author=data.get("author") or "",
|
||||
summary=data.get("summary") or "",
|
||||
author=request.user.get_username(),
|
||||
summary=(data.get("summary") or "").strip()[:500],
|
||||
document={leaf.path: leaf.value for leaf in project.leaves.all()},
|
||||
blocks={clip.cid: sorted(clip.blocks.values_list("key", flat=True))
|
||||
for clip in project.clips.all()},
|
||||
)
|
||||
return JsonResponse({"seq": revision.seq, "leaves": len(revision.document)}, status=201)
|
||||
return JsonResponse({"id": revision.id, "seq": revision.seq,
|
||||
"leaves": len(revision.document)}, status=201)
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
def restore(request, project_id, revision_id):
|
||||
"""Put a snapshot back: an ordinary write of every leaf that differs, so
|
||||
everybody in the room receives it the way they receive any other."""
|
||||
try:
|
||||
project = _writable(request, project_id)
|
||||
revision = project.revisions.filter(id=revision_id).first()
|
||||
if revision is None:
|
||||
raise Bad("no such snapshot", status=404)
|
||||
except Bad as exc:
|
||||
return _error(exc)
|
||||
with transaction.atomic():
|
||||
seq = project.bump()
|
||||
existing = {leaf.path: leaf for leaf in project.leaves.all()}
|
||||
deltas = {}
|
||||
def delta(path):
|
||||
cid = path.split("/")[1]
|
||||
return deltas.setdefault(cid, {"cid": cid, "leaves": {}, "removed": [],
|
||||
"blocks": revision.blocks.get(cid, [])})
|
||||
for path, value in revision.document.items():
|
||||
leaf = existing.get(path)
|
||||
if leaf is None:
|
||||
Leaf.objects.create(project=project, path=path, value=value, seq=seq)
|
||||
elif leaf.value != value:
|
||||
leaf.value, leaf.seq = value, seq
|
||||
leaf.version += 1
|
||||
leaf.save(update_fields=["value", "version", "seq", "updated"])
|
||||
else:
|
||||
continue
|
||||
delta(path)["leaves"][path] = value
|
||||
gone = [path for path in existing if path not in revision.document]
|
||||
project.leaves.filter(path__in=gone).delete()
|
||||
for path in gone:
|
||||
delta(path)["removed"].append(path)
|
||||
for cid, keys in revision.blocks.items():
|
||||
clip = project.clips.filter(cid=cid).first()
|
||||
if clip:
|
||||
clip.blocks.add(*Block.objects.filter(key__in=keys))
|
||||
by = request.user.get_username()
|
||||
transaction.on_commit(lambda: broadcast(project.id, {
|
||||
"seq": seq, "by": by, "name": project.name, "clips": list(deltas.values()),
|
||||
}))
|
||||
return JsonResponse({"seq": seq, "changed": sum(len(d["leaves"]) + len(d["removed"])
|
||||
for d in deltas.values())})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
# arthur — the animation model
|
||||
|
||||
The revised target for lanes, occurrences, source playback, shared editing, and
|
||||
multi-view UX is [The Lane Model](lane-model.md). It supersedes conflicting
|
||||
proposals below. Backward compatibility is not required; this document still
|
||||
contains descriptions of earlier shapes and planned features.
|
||||
|
||||
The data that describes a moving picture: what the primitives are, how they
|
||||
nest, how they change over time, and how rotoscoped and hand-authored work end
|
||||
up being the same thing with one flag between them.
|
||||
|
|
@ -79,6 +84,20 @@ this way.
|
|||
over which the node exists at all. Distinct from a `[:vis]` channel, which
|
||||
blinks an existing node on and off.
|
||||
|
||||
**Every node has the same two maps into its parent**, whatever kind it is:
|
||||
|
||||
- **space** — the matrix its transform channels compose to, times a `:pinv` if
|
||||
it has been moved in from elsewhere;
|
||||
- **time** — `local = rate · (parent − at)`, from `:time :at` and `:rate`,
|
||||
identity when absent. `:span` and every key are in the node's **own** frames.
|
||||
|
||||
A move keeps a node's world maps and re-expresses them under its new parent:
|
||||
the matrix becomes a `:pinv`, the time becomes a new `:at` and `:rate`, and its
|
||||
channels, keys and span are not touched. Both maps are affine, so any depth of
|
||||
nesting is one map and every move is one inverse. `node/time-of`,
|
||||
`node/then-time` and `node/placed-span` are the time half; `clip/move-node` and
|
||||
`clip/group` are the move.
|
||||
|
||||
### Subjects and tracked features
|
||||
|
||||
Scene nodes describe drawings, not tracking identity. A scene may also carry a
|
||||
|
|
@ -153,7 +172,6 @@ Every animatable property is a channel, and channels are addressed **by path**:
|
|||
[:xform :rot] {:animated? false :value 0.0}
|
||||
[:xform :scale] {:animated? false :value [1.0 1.0]}
|
||||
[:xform :skew] {:animated? false :value [0.0 0.0]}
|
||||
[:xform :anchor]{:animated? false :value [0.0 0.0]}
|
||||
[:geom :pts] {:animated? true :interp :hold :dense {...} :generated {...}}
|
||||
[:style :color] {:animated? false :value :skin-dark}
|
||||
[:vis] {:animated? true :interp :hold :keys {0 true, 37 false}}}
|
||||
|
|
@ -216,10 +234,30 @@ combines:
|
|||
```clojure
|
||||
{:animated? true :interp :hold
|
||||
:dense {...} :generated {...}
|
||||
:over [{:blend :offset :keys {88 [2 0], 96 [0 0]}}
|
||||
{:blend :replace :keys {104 [[3 7] [4 7] …]}}]}
|
||||
:over [{:id :nudge :support [88 98] :op :offset
|
||||
:values {:animated? true :interp :linear :keys {88 [2 0], 96 [0 0]}}}
|
||||
{:id :redraw :support [104 105] :op :replace
|
||||
:values {:animated? false :value [[3 7] [4 7] …]}}]}
|
||||
```
|
||||
|
||||
A LAYER'S VALUES ARE A CHANNEL, which is what keeps a constant adjustment, a
|
||||
ramp and a return motion from being three mechanisms: a framed one says the same
|
||||
thing on every frame it covers, a keyed one moves. They read through `value-at`
|
||||
and `cursor` like any channel, one reading head each, so the specification and
|
||||
the playback path share their blending and differ only in how they read — and a
|
||||
layer's values may not carry layers of their own, which the stack already
|
||||
orders.
|
||||
|
||||
`:support` is half-open and explicit, `[in out)`. Outside it a layer is inactive
|
||||
and the base evaluates exactly as it did before, which is the difference between
|
||||
a bounded correction and inserting boundary keys — the latter alters the
|
||||
neighbouring segments. And a layer has NO TIME SPACE of its own: its support and
|
||||
its values' keys are in the frames the base channel's keys are in, the node's
|
||||
own. A correction on a lane is therefore in lane frames and reaches across the
|
||||
drawings exposed under it; one on a single occurrence is in that occurrence's
|
||||
frames and travels with it when the exposure moves. Ownership had already
|
||||
answered the question, so there is no field to disagree with.
|
||||
|
||||
- **`:offset`** adds a delta to the base. "Nudge the mouth two pixels right for
|
||||
ten frames" survives a re-freeze at different parameters, because it was never
|
||||
a position — it was a correction.
|
||||
|
|
@ -229,6 +267,22 @@ This is what `docs/design.md` means by an override layer, and it is why
|
|||
re-freezing is safe: the base is regenerated, the layers are untouched. It is
|
||||
Blender's NLA blending and AE's effect stack at one property.
|
||||
|
||||
WHEN THE BASE OUTGROWS A CORRECTION it is a CONFLICT, which is neither a dropped
|
||||
layer nor an applied one. Turning `:verts` gives the mouth a different number of
|
||||
points, and an `:offset` is a row of components that has to match: so the
|
||||
regeneration records `:conflict` on the layer, the layer stays in the document,
|
||||
the picture is the base meanwhile, and `clip/conflicts` is the list a view
|
||||
offers to resolve. Deliberately not `problems` — the document loads and saves
|
||||
fine, it just contains a decision nobody has made yet. A later regeneration
|
||||
that restores the shape clears the mark. Only `:offset` can conflict; `:replace`
|
||||
states a whole value and has nothing to agree with.
|
||||
|
||||
A correction is NOT a hand placement. `regenerate-head` leaves the head's
|
||||
authored channels alone once somebody has placed it by hand, and it compares the
|
||||
channels WITHOUT their layers to decide: otherwise the first correction anyone
|
||||
made would stop the head following re-measurement forever, which is the opposite
|
||||
of what a layer is for.
|
||||
|
||||
Layers are what "set it by hand" means for anything measured, and the measured
|
||||
channel does not need to know. A hand-set gaze is an `:over` on
|
||||
`[:xform :pos]` of the iris; a hand-set mouth shape is an `:over` on
|
||||
|
|
@ -269,7 +323,7 @@ to change to allow it.
|
|||
## Transform: decomposed, never a matrix
|
||||
|
||||
```clojure
|
||||
{:pos [x y] :rot θ :scale [sx sy] :skew [kx ky] :anchor [ax ay]}
|
||||
{:pos [x y] :rot θ :scale [sx sy] :skew [kx ky]}
|
||||
```
|
||||
|
||||
Stored decomposed for two reasons. Each component has to be independently
|
||||
|
|
@ -279,18 +333,141 @@ entries is meaningless — a rotation tweened through its matrix shears on the w
|
|||
Composition, per node:
|
||||
|
||||
```
|
||||
local = T(pos) · T(anchor) · R(rot) · K(skew) · S(scale) · T(-anchor)
|
||||
local = T(pos) · T(piv) · R(rot) · K(skew) · S(scale) · T(-piv)
|
||||
world = world(parent) · pinv · local
|
||||
```
|
||||
|
||||
`:anchor` is Flash's registration point and Blender's origin: rotation and scale
|
||||
happen about it, and getting it wrong is why hand-placed parts swing rather than
|
||||
turn.
|
||||
|
||||
`:pinv` is Blender's `parent_inverse`, captured at the moment of parenting so the
|
||||
child does not jump when it acquires a parent. Small, and its absence is the kind
|
||||
of thing that makes a parenting feature feel broken.
|
||||
|
||||
### A node has a `:pivot`, and a peg is still a peg
|
||||
|
||||
Rotation and scale happen about the node's **pivot**, `[:xform :pivot]`, a point
|
||||
in its own coordinates:
|
||||
|
||||
```
|
||||
local = T(pos) · T(piv) · R(rot) · K(skew) · S(scale) · T(-piv)
|
||||
= T(pos + piv - M·piv) · M
|
||||
```
|
||||
|
||||
Toon Boom gives every layer and every peg a pivot, Flash gives every instance a
|
||||
transformation point, After Effects calls it the anchor point. All three store
|
||||
it, and the reason is one sentence: **a turn has to be a turn on every frame**,
|
||||
and the only way to keep a point still through an interpolated angle is for the
|
||||
angle to be composed about that point.
|
||||
|
||||
This was deleted in schema 7 and restored in schema 8, and the argument for
|
||||
deleting it was *not wrong*, which is why it is worth writing down. It was:
|
||||
|
||||
```
|
||||
T(pos) · T(a) · R·K·S · T(-a) ≡ peg at pos+a carrying R·K·S, child at -a
|
||||
```
|
||||
|
||||
to the last bit of the mantissa — `node-test` asserts it, still. `T(a)·M·T(-a)`
|
||||
is `M` conjugated by a translation, which is "do `M` in a frame shifted by `a`",
|
||||
and a **parent already is a shifted frame**. So an anchor was a peg written
|
||||
inline, and a peg can be selected, keyed, shared between nodes and put above a
|
||||
measured channel. Same expressive content, strictly more reach.
|
||||
|
||||
**What that identity does not say is what a node turns about when nobody has
|
||||
made a peg.** It is an equivalence between a pivot and a peg *that already
|
||||
exists*; it is silent on the default, and the default is what a person meets.
|
||||
With no pivot in the composition, a turn about any point that is not the node's
|
||||
own origin has to be paid for by writing `pos` as well — `gesture/about` solves
|
||||
for it:
|
||||
|
||||
```
|
||||
q = M⁻¹(c − t) the material point under c
|
||||
p' = c − M'·q
|
||||
```
|
||||
|
||||
and that solution is an **arc** in the angle while `pos` interpolates along the
|
||||
**chord**:
|
||||
|
||||
| | pivot = origin | pivot ≠ origin |
|
||||
| --- | --- | --- |
|
||||
| one drag | right | right |
|
||||
| between two keys | right | **wrong**, by the sagitta of the arc |
|
||||
|
||||
A 360° turn is where that is unmissable: 0° and 360° are the only two frames
|
||||
where a wrong pivot cannot be seen at all, so the keys look right and every
|
||||
frame between them is wrong.
|
||||
|
||||
**A drawing escaped it. A symbol instance could not.** `paint/centred` puts a
|
||||
shape's origin on the middle of what it draws the moment it is drawn, so for a
|
||||
drawing the pivot *is* the origin, `about` has nothing to do, and a keyed turn is
|
||||
right between its keys. An instance's origin is its **symbol's**, and a symbol is
|
||||
drawn on the stage, so its origin is the stage's top-left corner. Measured from
|
||||
the document this was reported on: a symbol holding six drawn shapes had its
|
||||
content centred at (99, 127), 161 px from its own origin, on a 320×200 stage. One
|
||||
instance of it, keyed `rot` 0 → 60 and dragged round by hand, put the drawing at
|
||||
(115, 116) on frame 0 and (241, 104) on frame 60 — both where they were put — and
|
||||
at (−88, 121) on frame 30, a stage and a half from either. The answer on offer
|
||||
was "make a peg first", for wanting to spin a drawing.
|
||||
|
||||
So the pivot is back, with the default and the escape hatch spelled out, because
|
||||
a stored pivot without either is the field that was deleted:
|
||||
|
||||
| | what | where |
|
||||
| --- | --- | --- |
|
||||
| **the default, for a node nobody has pivoted** | the middle of what it draws — `pick/bounds-of`, the same call the selection box comes from, so the cross starts out on the middle of the box | `gesture/pivot` |
|
||||
| **choosing it, invisibly** | the first turn or scale writes that middle down, in the same edit, with the `pos` that holds the picture still | `gesture/with-pivot` |
|
||||
| **choosing it, by hand** | ⌃/⌘-drag the cross on the stage: the pivot goes under the pointer and nothing moves | `gesture/repivot`, `::ui/repivot` |
|
||||
| **a placement** | `clip/place-symbol` stores the middle of what the symbol draws as the instance's pivot, so an instance turns about its drawing from the moment it is dropped | `clip/place-symbol` |
|
||||
| **putting it back** | ⌖ beside the pivot row in the inspector: back to the middle of what the node draws *now*, moving nothing | `gesture/centred`, `::ui/centre-pivot` |
|
||||
|
||||
**A pivot is a choice, and does not follow the drawing.** Once it is the node's
|
||||
own, the derived middle is never consulted for it again. This is the half the
|
||||
old stored anchor got right and the derived pivot got wrong: adding a shape
|
||||
inside a symbol must not re-aim every keyed spin of every instance of it, and a
|
||||
pivot that tracked the content did exactly that, silently, with nothing changing
|
||||
on screen at the moment it happened. The cross is visible and draggable and ⌖
|
||||
puts it back, which is what the anchor was missing — it was never the storing
|
||||
that was wrong.
|
||||
|
||||
**A peg is an ordinary `:group` parent, `nest/peg`, with `:pinv` captured so
|
||||
nothing moves when it appears.** It is no longer the answer to "this turns about
|
||||
the wrong point", and it is still the answer to three things a node's own pivot
|
||||
is not:
|
||||
|
||||
| want | why the node's own pivot is not it | what the peg does |
|
||||
| --- | --- | --- |
|
||||
| a pivot **shared** between nodes — an arm and a forearm about one shoulder | two pivots that have to agree frame for frame are not one pivot | one transform, two children hanging off it |
|
||||
| a **second** transform on one node — a drawing spinning about its middle while the limb swings about the shoulder | a node has one `rot` | stack them, as Harmony does |
|
||||
| a hand transform over a **measured** one | `gesture/refusal` turns a drag on a measured channel away, because the next regenerate would discard it | the peg's channels are its own, so the hand transform composes outside the measurement, which stays regenerable |
|
||||
|
||||
The pivot of a measured node is *not* in that table: `[:xform :pivot]` is
|
||||
authored on every node alike, never dense and never regenerated, so a traced
|
||||
mouth can be told to turn about its own middle without a peg and with nothing a
|
||||
regenerate will throw away. That is the row that used to be impossible — writing
|
||||
an anchor under a measured `M` moved the thing it was meant to leave alone,
|
||||
because the old composition was `T(pos)·M·T(-a)` and `pos` was the measurement's.
|
||||
The conjugated form has no such problem: `T(a)·M·T(-a)` is the identity at `a`
|
||||
whatever `M` is.
|
||||
|
||||
`demo/stage` places its seven faces on pegs, and that is now one way of writing
|
||||
something a pivot says directly: the faces' `:scale` is **keyed** — they pulse —
|
||||
and the source's middle has to stay on its authored centre throughout, which a
|
||||
static `pos` cannot do since `T(pos)·S(k(f))` moves that point whenever `k`
|
||||
changes. `T(center)·S(k(f))·T(-origin)` does, for every `k`, and so does one
|
||||
instance with its pivot on the middle. The demo is left as it is, pegs and all:
|
||||
it is a hand-authored scene that renders correctly and `instance-test` asserts
|
||||
its structure, and a peg carrying a keyed scale is a perfectly good thing to
|
||||
have written.
|
||||
|
||||
`gesture/about` survives for the one gesture whose pivot belongs to no node: a
|
||||
**multi-selection** scaling about the middle of its shared box, where every
|
||||
member has to move to keep the arrangement. Nobody keys that.
|
||||
|
||||
Schema 8 is the first version that **converts** rather than refusing. A schema-7
|
||||
node has no pivot, an absent pivot reads as `[0 0]`, and `T(pos)·T(0)·M·T(-0)` is
|
||||
`T(pos)·M` to the bit — so every stored document composes to exactly the matrices
|
||||
it did, dense tier-2 transforms included, and the migration only restamps the
|
||||
version. What a converted document does not get is a pivot anybody chose; its
|
||||
nodes still turn about their origins until the first turn writes one or the cross
|
||||
is dragged.
|
||||
|
||||
**The similarity fit already produces a decomposition.** `fitSimilarity` returns
|
||||
`{s θ tx ty}`, which drops straight into `[:xform :scale]`, `[:xform :rot]` and
|
||||
`[:xform :pos]` with no conversion. The analysis output and the animation model
|
||||
|
|
@ -379,7 +556,7 @@ selected head placement, so it aligns with the vectors drawn over it.
|
|||
:mouth :mouth-in :teeth :lid-r :lid-l :brow-r :brow-l …
|
||||
```
|
||||
|
||||
Changing anchor keys edits `:head` and never touches `:face`, so it cannot move
|
||||
Changing anchor keys edits `:head` and never touches `:place`, so it cannot move
|
||||
something that was placed by hand. A group node is free, and keeping the authored
|
||||
and the measured transform apart is the whole reason the transform is decomposed
|
||||
in the first place.
|
||||
|
|
@ -415,9 +592,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 +606,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 +654,9 @@ 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 `:source {:symbol :sym/blink}` places one, and
|
||||
its `:playback` says how time runs inside it — which drawing is used and how it
|
||||
is played are separate facts, per [the lane model](lane-model.md). 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.
|
||||
|
|
@ -545,28 +727,26 @@ dense geometry. A topology setting cannot be treated as a per-frame gain curve.
|
|||
The op list is the boundary with stage 7 in `docs/architecture.md`: the
|
||||
rasteriser takes ops and knows nothing about nodes, channels or time.
|
||||
|
||||
**A photographic underlay is not an op.** The registered source frame that an
|
||||
animator traces over is a reference, not output, and it may not enter the indexed
|
||||
buffer — the same rule `docs/architecture.md` already sets for handles and
|
||||
vertex boxes. It is a `drawImage` at an affine on a separate canvas, which clips
|
||||
at the canvas edge for free, and the only thing it needs from the model is the
|
||||
world transform of the node it rides:
|
||||
**A tracing layer is an op that never reaches the raster.** Footage or a still
|
||||
to draw over is a symbol with `:type :trace` and a `:media`, placed by an ordinary
|
||||
instance — so it is moved, scaled, trimmed, held and put in a lane like anything
|
||||
else — and it resolves to one `:trace` op: `{:kind :trace :node :layer :media
|
||||
:frame :size :m}`. The raster refuses that kind, the player hands it to a
|
||||
`drawImage` on a separate canvas over the picture, and `clip/resolver` makes one
|
||||
only when asked with `:tracing?`, which only the stage does. An export, a
|
||||
symbol's centre and a thumbnail never ask, so a reference cannot reach the
|
||||
picture by any path that forgets to filter it. See `docs/tracing-symbol-plan.md`.
|
||||
|
||||
```clojure
|
||||
(world-of resolver :head) ;; -> Float64Array[6]
|
||||
```
|
||||
A face's footage is one of these, placed as `:plate` under `:head` with the
|
||||
anchor fit itself as its measured transform — the inverse of the head's, over
|
||||
image height. Its world is `head · fit · 1/H`, so on a frame where the head and
|
||||
the plate read the same measured frame the two cancel and the photo sits where
|
||||
the face was filmed; on any other frame it rides the head. Registration is the
|
||||
ordinary walk, not a matrix built beside it.
|
||||
|
||||
Composed with image-pixels-to-local — **both axes divided by `imgH`**, never by
|
||||
their own dimension — the photo is registered with the shapes by construction,
|
||||
and an unregistered underlay is merely decorative. The tracing editor chooses
|
||||
which source frame to show under a cel. That reference choice is independent of
|
||||
the finished picture fps and does not change the dense analysis track. A cel can
|
||||
therefore use any useful source frame as its drawing reference, even when that
|
||||
frame is not one of the displayed picture poses.
|
||||
|
||||
A photo that has to sit *between* two drawn layers is the case that would make it
|
||||
a `:bitmap` node with an op of its own. Nothing wants that yet: a reference is
|
||||
either under everything or over everything at low alpha.
|
||||
Which frame it shows is the placement's: `:time {:holds [...]}` holds it on
|
||||
chosen frames, and a head with `:reads {:holds-of :plate}` jumps to the same
|
||||
ones. Whether it is showing at all is the editor's, `[:ui :tracing]`.
|
||||
|
||||
### Making it fast in CLJS
|
||||
|
||||
|
|
@ -619,9 +799,9 @@ Proof that it covers what exists, not just what is wanted:
|
|||
| square pupil | node `:pupil-r`, `:kind :rect`, parent `:iris-r`, stencil `:iris-r` |
|
||||
| brow ring + quantised raise | node `:brow-r`, `[:geom :pts]` dense (the traced ring with height removed), `[:xform :pos]` dense (the quantised raise). **The decomposition design.md insists on is two channels.** |
|
||||
| head plate, kept frames | node `:head`, `:symbol` per instance, keys on `[:symbol]` at kept frames |
|
||||
| `makeXform` face-oval crop | **gone.** Placement is `[:xform :*]` on `:face`; the stage clips |
|
||||
| `stabilize` transforms | dense `[:xform :*]` on `:head`, read through its optional `:anchors` map |
|
||||
| registered underlay | not data — a UI layer riding `(world-of resolver :head)` |
|
||||
| `makeXform` face-oval crop | **gone.** Placement is `[:xform :*]` on the face's own `:place`; the stage clips |
|
||||
| `stabilize` transforms | dense `[:xform :*]` on `:head` (the inverse fit) and on its `:plate` (the fit), read where `:reads` and `:time :holds` say |
|
||||
| registered underlay | the face's `:plate`, an instance of the footage's tracing symbol under `:head`; a `:trace` op the raster never sees |
|
||||
| painted background cel | node per layer, `[:geom :pts]` **framed**, `[:style :color]` framed |
|
||||
| `mouth lead` | `:time {:offset k}` on performance nodes only |
|
||||
| `exposure` | `:time {:expose n}` on the clip root, inherited |
|
||||
|
|
@ -658,28 +838,33 @@ scope does not define resolves to the loud magenta, like any other missing index
|
|||
|
||||
### The scope rule
|
||||
|
||||
`:palette` on a timeline is a channel like any other:
|
||||
`:palette-track` points to an ordinary lane symbol. Its clips are instances of
|
||||
restricted palette symbols: a palette symbol owns no nodes and points at exactly
|
||||
one project palette.
|
||||
|
||||
```clojure
|
||||
{:frames 91
|
||||
:palette {:animated? true :interp :hold :keys {0 :day, 48 :dusk, 72 :night}}
|
||||
:nodes {...}}
|
||||
{:id :shot :frames 91 :palette :day :palette-track :shot-palettes :nodes {...}}
|
||||
|
||||
{:id :shot-palettes :type :palette-track :display :lane :frames 91
|
||||
:nodes {:day-clip {:kind :instance :source {:symbol :day-palette} ...}
|
||||
:dusk-clip {:kind :instance :source {:symbol :dusk-palette} ...}}}
|
||||
|
||||
{:id :day-palette :type :palette :palette-ref :day :frames 1 :nodes {}}
|
||||
```
|
||||
|
||||
**Absent means inherit** from the instancing context. **Present means this
|
||||
timeline's content is read in that ramp, and it travels with the timeline** — a
|
||||
symbol authored against `:night` stays night wherever it is placed. That is
|
||||
lexical scope, and deliberately: a character with their own palette is a
|
||||
character, not a decoration of whichever scene they were dropped into.
|
||||
`:palette` is the symbol's authoring/preview palette. It seeds evaluation only
|
||||
when that symbol is the viewed root; nested symbols do not replace the root's
|
||||
choice merely because they were authored under another ramp. When absent, the
|
||||
project default seeds evaluation.
|
||||
|
||||
Composition is the same walk as `:time` — down the instance chain, **innermost
|
||||
set palette wins**. An enclosing timeline's palette therefore applies to
|
||||
everything inside it that does not set its own, which is adjustment-layer
|
||||
behaviour with no adjustment layer in it. It is just scope.
|
||||
|
||||
And because it is an ordinary channel, a project switches palette over time with
|
||||
keys on the root timeline, a child timeline switches on its own, and neither
|
||||
knows about the other.
|
||||
Covered clips of the viewed root's palette track override that seed. An
|
||||
uncovered lane interval is a genuine gap, restoring the authoring palette or
|
||||
project default. Palette clips use the same trim, roll, slide, claim-time and
|
||||
undo commands as visual clips; palette code does not duplicate those edits.
|
||||
Thus palette-track coverage, authoring preview, and project fallback are
|
||||
separate facts rather than three accidental meanings of one field. There is no
|
||||
second keyed palette control on symbols or instances: time-varying palette
|
||||
changes are authored only as clips in the palette lane.
|
||||
|
||||
### One index space, partitioned by palette
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ rotoscoping rather than a sketch bolted to the side.
|
|||
`docs/animation-model.md` specifies the data both of them are about — nodes,
|
||||
channels, symbols and time maps — and supersedes this document wherever the two
|
||||
describe the same type.
|
||||
The newer [Lane Model](lane-model.md) takes precedence for occurrence ownership,
|
||||
playback semantics, shared editing operations, and multi-view UX. It explicitly
|
||||
allows replacing the current format without backward compatibility.
|
||||
Nothing here revises an aesthetic decision; several things here split a
|
||||
decision that is currently made in two places at once.
|
||||
|
||||
|
|
@ -49,10 +52,10 @@ overrides and kept-frame sets are all in clip-frame space, so a clip slides on
|
|||
the timeline without a single stored number changing. Exposure and lead are
|
||||
transforms *within* clip space:
|
||||
|
||||
Detection retains every source frame. A chosen picture fps samples the frozen
|
||||
roto in clip time; it changes neither source-frame count nor the audio clock.
|
||||
The set of source frames an artist uses as cel tracing references is another
|
||||
selection, independent of the picture fps.
|
||||
Detection retains every source frame. Symbols carry their native fps; project
|
||||
fps selects the output grid without changing source data or audio speed.
|
||||
See [Time selection](time.md) for boundary sampling and frame units.
|
||||
Tracing references remain an independent selection.
|
||||
|
||||
```clojure
|
||||
(defn pose-frame [clip cf]
|
||||
|
|
@ -87,8 +90,8 @@ it a name is most of the work: `state` in `app.js` is a clip with its analysis
|
|||
inlined and its palette global.
|
||||
|
||||
Cel keys select where drawings begin and how long they hold. The source frames
|
||||
shown beneath a cel while tracing are chosen independently, and picture fps
|
||||
only controls which analyzed pose the finished roto displays at a given time.
|
||||
shown beneath a cel while tracing are chosen independently. Project fps controls
|
||||
which native frames can appear on the output grid.
|
||||
|
||||
### Two things called "track"
|
||||
|
||||
|
|
@ -639,10 +642,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
|
||||
|
|
@ -675,6 +678,45 @@ and `~` is then refused inside a name. That is the whole of the escaping.
|
|||
That maps onto the tiers exactly — the server stores tier 1 and snapshots tier 1,
|
||||
with tiers 2 and 3 as content-addressed blobs beside it.
|
||||
|
||||
### As built
|
||||
|
||||
- **Addresses.** `/` is the index of the projects you own or edit. A project
|
||||
is only ever at `/p/<uuid>/<slug>`: the id finds it, the slug is its name and
|
||||
follows a rename without a history entry. "new" makes the project on the
|
||||
server first and opens it; a built-in example opened in the editor is saved
|
||||
at once as a project of its own. There is no bare project. The address, the
|
||||
title and the socket follow `[:project :id]` through one global interceptor
|
||||
(`events/collab`).
|
||||
- **Ownership.** Every project has an owner (`Project.owner`, not nullable) and
|
||||
`editors`. Anyone with the link reads; the owner and editors write. Making a
|
||||
project needs you signed in. A reader can make a copy of their own.
|
||||
`/api/{me,login,signup,logout}`, `/api/projects/<id>/editors[/<username>]`.
|
||||
- **Every edit saves.** No save button. The same interceptor sees
|
||||
`:paint/revision` move and saves; one request is in flight at a time, and an
|
||||
edit made meanwhile goes when it lands — so a drag reaches the room as fast
|
||||
as the round trip allows. A save sends only the leaves that differ from what
|
||||
was last synced, and a clean document sends nothing. Analyses and blocks
|
||||
already put on the server are not asked about again.
|
||||
- **Saves are patches.** With `base` (the seq last caught up to) a save names
|
||||
only its changed and removed leaves. A named leaf somebody else changed after
|
||||
`base`, to something else, fails the whole save with 409.
|
||||
- **The first write wins.** A remote write to a leaf with a local change not
|
||||
yet sent waits in the entry's `:behind`; the next save, or a 409, puts theirs
|
||||
on screen over ours and says so. Ours stays in the undo list.
|
||||
- **The socket** (`/ws/projects/<uuid>`, channels + daphne) carries presence,
|
||||
the delta each committed write broadcasts, and `access` when the editor list
|
||||
changes. A gap in `seq`, a welcome, or a 409 refetches the document.
|
||||
- **Undo** is per person, recorded in `events/edit` as leaf befores and afters
|
||||
(`domain/history`), and applied as an ordinary edit. A step undoes only if
|
||||
every leaf it touched still holds what it left there: somebody else's edit
|
||||
since refuses it rather than being undone with it. Edits to the same leaves
|
||||
within a second, each starting where the last left off, are one step.
|
||||
- **Snapshots** are named revisions (`/api/projects/<id>/revisions`), with each
|
||||
clip's block keys. Restoring one is an ordinary write, broadcast like any.
|
||||
|
||||
Not yet: follow mode, frame/selection in presence, the advisory `:editing`
|
||||
lease, the durable outbox.
|
||||
|
||||
### Why this model, and not a CRDT
|
||||
|
||||
The usual reason to reach for Yjs or Automerge is automatic convergence without
|
||||
|
|
@ -780,17 +822,17 @@ collaborator's keying. The fix is addressing, not an algorithm:
|
|||
|
||||
```
|
||||
palette
|
||||
sequence/:sid
|
||||
lane/:sid
|
||||
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
|
||||
|
|
|
|||
135
docs/clipboard-plan.md
Normal file
135
docs/clipboard-plan.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Selection and clipboard
|
||||
|
||||
This is the implementation contract for multi-selection, copy, cut, paste,
|
||||
duplicate, and duplicate unique. It deliberately replaces any incidental older
|
||||
behavior. The document model is the authority; the stage and timeline are two
|
||||
views of the same editor state.
|
||||
|
||||
## One selection
|
||||
|
||||
`[:ui :selections]` is the ordered selection set. Its last member is the primary
|
||||
selection in `[:ui :selection]`, used by the inspector and single-subject tools.
|
||||
Every member is an occurrence address:
|
||||
|
||||
```clojure
|
||||
[:node owner-symbol-id node-id row-path]
|
||||
```
|
||||
|
||||
The row path distinguishes two occurrences of shared content. Commands that
|
||||
write the document canonicalize those addresses before acting:
|
||||
|
||||
- invalid and non-node addresses are ignored;
|
||||
- the same owned node, `[owner-symbol-id node-id]`, is acted on once;
|
||||
- when one selected row path is below another selected row path, only the
|
||||
ancestor is a clipboard root. Its ordinary parent-pointer subtree comes with
|
||||
it, and an instance already displays the symbol it references, so also
|
||||
materializing the visibly nested selection would duplicate it twice.
|
||||
|
||||
Plain click replaces the selection. Shift-click toggles membership, on both the
|
||||
stage and timeline. A stage marquee replaces, or with Shift adds to, the same
|
||||
set. Timeline rows, bars, and cel blocks render membership from that same set;
|
||||
the primary member gets the inspector/focus treatment.
|
||||
|
||||
Creation targeting is derived rather than stored. The primary (last-selected)
|
||||
occurrence is the preferred row; the playhead validates it and, when necessary,
|
||||
walks outward to the nearest valid containing occurrence. A target is singular
|
||||
even when selection is plural. See `docs/creating-in.md` for the resolver
|
||||
contract.
|
||||
|
||||
## Clipboard value
|
||||
|
||||
The clipboard is editor state, not document state and not history. Copy records
|
||||
a detached snapshot of each canonical root and its complete parent-pointer
|
||||
subtree. It records source occurrence paths and authored node data, but normal
|
||||
copy deliberately keeps referenced symbol identities. Therefore a pasted
|
||||
instance is another use of the same symbol. The clipboard survives cutting its
|
||||
nodes because it contains the node snapshot, not merely their addresses.
|
||||
|
||||
This first implementation is the application's clipboard, not the operating
|
||||
system clipboard. It is consequently project-local and has no serialization or
|
||||
cross-project identity collision policy hidden inside it.
|
||||
|
||||
## Paste
|
||||
|
||||
Paste resolves one destination from the primary selection and playhead. An
|
||||
ordinary occurrence is usable only while the playhead maps through every
|
||||
enclosing occurrence and lies within its extent. Otherwise resolution walks
|
||||
outward, with the open symbol as the total fallback. A selected lane is an
|
||||
insertion surface only while all occurrences enclosing its parent symbol are
|
||||
valid. Multi-selection supplies one ordered payload, not several destinations;
|
||||
only its primary member anchors this resolution.
|
||||
|
||||
The earliest finite start among the copied roots is aligned with the playhead
|
||||
in the destination. All other root starts retain their offset from it. Roots
|
||||
without a finite span remain timeless; paste does not invent a span for a shape
|
||||
that was authored for the whole symbol. Parent/child timing, transforms,
|
||||
channels, corrections, playback, stencil links, and relative root order are
|
||||
otherwise copied exactly. Every node receives a new identity, and all internal
|
||||
parent and stencil references are remapped.
|
||||
|
||||
In an ordinary composition overlap is valid. In a lane the pasted finite spans
|
||||
claim their intervals using the lane's existing overwrite rule: covered cels
|
||||
are removed, crossing cels are trimmed or split, and the pasted roots do not
|
||||
overlap one another. Pasting a timeless root into a lane is refused. Validation
|
||||
is all-or-nothing.
|
||||
|
||||
After paste, the new roots are the selection, in clipboard order, and the last
|
||||
one is primary.
|
||||
|
||||
## Cut
|
||||
|
||||
Cut first takes exactly the same snapshot as copy, then deletes every canonical
|
||||
root and its parent-pointer subtree. The clipboard write is editor state; the
|
||||
whole document deletion is one history transaction. The selection is cleared.
|
||||
Undo restores the deleted document nodes. It does not roll back the clipboard,
|
||||
which matches ordinary editor behavior.
|
||||
|
||||
## Duplicate and Duplicate Unique
|
||||
|
||||
Duplicate does not read the insertion target or playhead. It is a local
|
||||
operation beside the selected material:
|
||||
|
||||
- in an ordinary composition, copies keep the originals' parent, transform,
|
||||
timing, and span, and are stacked immediately in front;
|
||||
- for direct children of a lane, the selected temporal envelope is repeated
|
||||
immediately after itself. Relative timing and gaps inside the selected set are
|
||||
preserved, and later cels ripple forward by the envelope duration. This is
|
||||
the lane's useful "duplicate forward" behavior; it is not a second command.
|
||||
|
||||
Selections in several owners are handled per owner in one command. Thus two
|
||||
lane selections repeat in their respective lanes, while a selected composition
|
||||
node duplicates in place, all as one history step.
|
||||
|
||||
Normal Duplicate preserves symbol references, just like normal copy/paste.
|
||||
Duplicate Unique performs the same placement but deep-copies the complete graph
|
||||
of every referenced symbol. One shared remap table is used for the whole batch,
|
||||
so two duplicated instances that shared a nested part still share one new copy
|
||||
with each other, while sharing nothing mutable with the originals. Immutable
|
||||
media/store blocks may remain shared.
|
||||
|
||||
After either duplicate command, the new roots replace the selection.
|
||||
|
||||
## History, refusal, and stale state
|
||||
|
||||
Cut, paste, duplicate, and duplicate unique each call one domain command and
|
||||
commit through one `edit/transaction`; each is exactly one undo/redo step no
|
||||
matter how many nodes or symbols it touches. Copy and selection do not
|
||||
touch history. A refusal changes no document leaves and creates no history step.
|
||||
|
||||
Undo/redo filters the complete selection set against the restored document and
|
||||
repairs the primary selection. Clipboard payloads remain snapshots. Paste
|
||||
validates the resolved destination and all remapped references at commit time,
|
||||
so a stale selection or a newly impossible symbol cycle refuses rather than
|
||||
partially editing.
|
||||
|
||||
## Required tests
|
||||
|
||||
Domain tests cover canonical ancestor/descendant selection, subtree ID remaps,
|
||||
normal shared references, deep unique graph remaps, multi-root relative timing,
|
||||
composition overlap, lane overwrite, lane forward duplication and ripple,
|
||||
mixed-owner duplication, cycle refusal, stale targets, and all-or-nothing
|
||||
failure. Event tests cover copy without history, atomic cut/paste/duplicates,
|
||||
resulting multi-selection, target fallback at the playhead,
|
||||
and one undo plus redo of each mutation. Browser tests cover mirrored stage and
|
||||
timeline selection, Shift-toggle on labels/bars/cels, singular derived creation
|
||||
targeting, and the keyboard commands.
|
||||
234
docs/correction-authoring-plan.md
Normal file
234
docs/correction-authoring-plan.md
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
# Correction authoring implementation plan
|
||||
|
||||
Written against `2f1c9b9` (2026-09-30), following the lane handoff in
|
||||
`7a54bfc`. Implemented on `codex/correction-authoring`; this now records the
|
||||
scope and acceptance criteria of that implementation.
|
||||
The cel-sheet targeting work described below was removed with the cel-sheet UI
|
||||
on 2026-10-01; it remains here only as history of that implementation.
|
||||
Read [lane-handoff.md](lane-handoff.md) and the correction section of
|
||||
[lane-model.md](lane-model.md) first. Their ownership and document rules remain
|
||||
the foundation. The choices below settle the first implementation's scope.
|
||||
|
||||
## Outcome
|
||||
|
||||
A person can select a lane or cel, specify a range, and apply Constant
|
||||
adjustment, Ramp, or Return motion to rotation or position. The result is one
|
||||
correction layer and one undo step. It works from either timing view. A lane
|
||||
correction crosses drawing boundaries; a cel correction travels with its cel.
|
||||
Regeneration preserves the hand work and presents incompatible layers for an
|
||||
explicit decision. Frames outside the support evaluate exactly as before.
|
||||
|
||||
Finish this vertical slice before adding more property types or gestures.
|
||||
Numeric range fields and an Apply button are sufficient for this pass. Dragging
|
||||
a range or manipulating a peak on the stage can later issue the same command.
|
||||
|
||||
## 1. Fix sheet targeting first
|
||||
|
||||
In `ui/timeline.cljs`, `cel-sheet` currently drops each lane row's `:select`.
|
||||
An occupied cell selects its cel; a gap only seeks, leaving the previous target
|
||||
selected. Thus clicking lane B's gap after selecting lane A can send an insert
|
||||
or overwrite to A.
|
||||
|
||||
Carry the row's complete selection address into its column and gap cells.
|
||||
An occupied cell selects its cel; a gap selects its lane. Make the column header
|
||||
select the lane too: this is the explicit way to author across drawings.
|
||||
Preserve full paths, not just `(peek path)`, as view identity. Keep seek and
|
||||
selection dispatch order deterministic.
|
||||
|
||||
Add a two-lane browser case: select A, click a gap in B, overwrite, assert that
|
||||
only B changes, and undo once. Add a header-selection assertion. Retain the
|
||||
existing occupied-cell hold test. Do not redesign sheet rendering in this step.
|
||||
|
||||
## 2. Make stack compatibility consistent
|
||||
|
||||
There is a concrete discrepancy at this HEAD:
|
||||
|
||||
- `channel/problems` uses `stack-conflict`, accounting for prior replacements.
|
||||
- `channel/conflicts` and `flow/regenerate.cljs`'s `rebased` use
|
||||
`conflict-with`, comparing an offset directly with the base.
|
||||
|
||||
A two-component base, a covering three-component replacement, then a
|
||||
three-component offset is valid and evaluates correctly, but the latter paths
|
||||
can report or mark that offset incompatible. Conversely a replacement can make
|
||||
an offset incompatible even when it fits the original base.
|
||||
|
||||
Extract one ordered-stack compatibility operation and use it for validation,
|
||||
conflict discovery, regeneration, and resolution. Keep `conflict-with` if useful
|
||||
for the narrower question its name/docstring describe. Do not use it alone to
|
||||
decide whether a stacked layer is applicable.
|
||||
|
||||
Compute compatibility against the values that can actually reach a layer over
|
||||
its support. Partition at overlapping support boundaries if needed: two adjacent
|
||||
replacements can jointly cover an offset even though neither covers it alone.
|
||||
An empty replacement channel does not supply a value and must not erase the
|
||||
possible input shape. Preserve the evaluator's absence behavior. Explicitly
|
||||
marked conflicts are skipped, so later layers must be checked against the stack
|
||||
that actually runs. During regeneration, recompute compatibility in order, using
|
||||
each preceding layer's resulting active/conflicted state. Preserve IDs, values,
|
||||
support, and order; update compatibility reasons without dropping hand work.
|
||||
|
||||
Use structural shape reasoning for dense data rather than requiring every block
|
||||
to be sampled. If unknown shape or missing samples limit what can be proven,
|
||||
retain the current absence contract and document that limit; do not claim an
|
||||
unconditional proof of runtime safety from incomplete metadata.
|
||||
|
||||
Tests: covering replacement of a different shape; partial coverage; adjacent
|
||||
covering replacements; empty replacement; inactive conflicted replacement;
|
||||
regeneration changing base shape; and the same cases through cursor evaluation.
|
||||
Assert that an accepted compatible stack is not listed as a conflict, and that
|
||||
an incompatible regenerated layer remains persisted but is skipped.
|
||||
|
||||
## 3. Pure correction commands
|
||||
|
||||
Add `frontend/src/arthur/domain/correction.cljs`. It owns authoring and resolving
|
||||
corrections; `channel.cljs` continues to own evaluation and compatibility.
|
||||
Suggested API (names may follow repository conventions):
|
||||
|
||||
```clojure
|
||||
(add clip sid node-id channel-path
|
||||
{:id layer-id :support [a b] :motion :return
|
||||
:start 0 :peak angle :peak-frame p})
|
||||
(remove-layer clip sid node-id channel-path layer-id)
|
||||
(retry-layer clip sid node-id channel-path layer-id)
|
||||
```
|
||||
|
||||
Return `{:clip updated :selection node-id}` or `{:refused reason}`. IDs come
|
||||
from the event caller (`random-uuid`), never from the pure command. Reject a nil
|
||||
ID or one already used within that channel stack. Address layers by the full
|
||||
symbol/node/channel/layer tuple; no global layer registry is needed.
|
||||
|
||||
Resolve the base via `node/channels`, which supplies defaults. A lane with no
|
||||
explicit rotation channel already has a zero rotation; materialize that channel
|
||||
with its new `:over`. Preserve every existing base field, generated provenance,
|
||||
and previous layer. Never route this through a setter that bakes the correction
|
||||
into base keys. Append to the ordered stack and validate the resulting document.
|
||||
Do not run correction edits through `lane/finish`, whose extent policy belongs
|
||||
to cel arrangement. Use `clip/problems` for the candidate document instead.
|
||||
|
||||
First authoring properties: `[:xform :rot]` (scalar radians) and `[:xform :pos]`
|
||||
(two numeric components). First blend operation: `:offset`. UI labels must say
|
||||
offset/delta, since a target offset of 20 degrees does not mean an absolute
|
||||
rotation of 20 degrees. Keep existing `:replace` evaluation and loaded stacks;
|
||||
there is no new replacement-authoring UI in this slice.
|
||||
|
||||
All command support endpoints are finite integer OWNER frames, `[a b)`, with
|
||||
`a < b`. Do not ban negative owner frames merely because displayed shot frames
|
||||
start at zero. Validate all supplied values for finite numbers and exact shape.
|
||||
Refuse unknown targets, unsupported properties/motions, malformed ranges, and
|
||||
incompatible stacks with useful messages. Refusal must not mutate store/history.
|
||||
|
||||
Motion construction uses existing channels only:
|
||||
|
||||
| Command | Values | Minimum samples |
|
||||
| --- | --- | --- |
|
||||
| Constant adjustment | `(ch/framed delta)` | 1 |
|
||||
| Ramp | `(ch/keyed {a start, (dec b) end} :linear)` | 2 |
|
||||
| Return motion | `(ch/keyed {a start, p peak, (dec b) start} :linear)` | 3 |
|
||||
|
||||
For Return, require integer `a < p < b-1`. Default the UI peak to
|
||||
`a + floor((b-a-1)/2)`; on an even-length range the earlier middle sample wins.
|
||||
Expose the peak frame so this is visible and adjustable. Never place an endpoint
|
||||
at `b`: it is outside the selected samples. `[10 13)` with start 0 and peak 0.5
|
||||
must yield offsets `0, 0.5, 0` at 10, 11, 12. Support controls the boundary;
|
||||
there is no need to insert zero keys into the base before/after it.
|
||||
|
||||
## 4. Owner and range UI
|
||||
|
||||
Add a Corrections section to the right pane (`ui/params.cljs`), extracting a
|
||||
`ui/corrections.cljs` component if that keeps the pane readable. Provide an
|
||||
explicit target readout (symbol, lane or cel), Rotation/Position, motion choice,
|
||||
From/Through fields, relevant value fields, peak frame for Return, and Apply.
|
||||
Offer a selected cel's owning lane as an explicit target choice. Do not silently
|
||||
promote a cel edit to a lane edit. Shared drawing content is outside this first
|
||||
UI; it has different sharing consequences.
|
||||
|
||||
For this first pass the range fields explicitly read **owner frames**, with
|
||||
inclusive From/Through converted to `[from, through+1)`. This is a deliberate
|
||||
UI scope choice, not a claim that a displayed shot range and owner range are
|
||||
interchangeable. It lets nested and retimed owners be addressed without an
|
||||
unproven range conversion. Match the existing zero-based numbering and show the
|
||||
owner beside the range. The handoff must record that displayed-range dragging
|
||||
is still outstanding.
|
||||
|
||||
Use an explicit three-sample initial draft in owner coordinates; for a cel,
|
||||
prefer its span start where it is integral. Show the range, allow adjustment,
|
||||
and do not extend a cel or shot to make the correction visible. Reset the draft
|
||||
when the target changes. Rotation is shown in degrees and converted to radians
|
||||
at the event boundary, following the existing inspector convention. Position
|
||||
uses x/y inputs in the owner's transform coordinates.
|
||||
|
||||
Draft inputs must not write document state, start history groups, or invoke the
|
||||
existing inspector `number-input`'s hold/settle behavior. Apply dispatches one
|
||||
event; success uses `edit/transaction` once and preserves the selected node's
|
||||
full address. Do not use a layer ID as node selection. Validate again at Apply,
|
||||
since the target/document may have changed since the draft was opened.
|
||||
|
||||
If adding a “use playhead” convenience, prove its mapping separately. The clock
|
||||
of a cel's transform is its own node clock, not the drawing source clock selected
|
||||
by `:playback`. `nest/inside` on the complete cel path enters the source and is
|
||||
therefore the wrong shortcut. The existing `selection-frame` resolves only the
|
||||
owning symbol; node/ancestor time conversion remains necessary. Floors, loops,
|
||||
and nonintegral mappings must never silently snap an authored range. Omit this
|
||||
convenience rather than expanding the first pass into a new timing system.
|
||||
|
||||
## 5. Conflict actions and regeneration proof
|
||||
|
||||
Show a document-wide list from `clip/conflicts` in the pane, including symbol,
|
||||
node, property, layer ID, and reason. Keep it accessible even when a different
|
||||
node is selected. Also list the selected target's layers in stack order with
|
||||
support, motion values, and status; do not require a new persisted motion label.
|
||||
|
||||
Provide Remove correction and Retry compatibility. Remove is explicit and
|
||||
undoable; Retry rechecks the complete candidate stack and clears a conflict only
|
||||
when it is valid. If retry would invalidate a downstream offset, refuse and say
|
||||
why. Similarly, removing a replacement that makes a later offset invalid must
|
||||
refuse, rather than commit an invalid document or silently remove more layers.
|
||||
A retry that changes nothing must not manufacture an undo step.
|
||||
|
||||
These are minimal resolution actions, not topology remapping. Automatic geometry
|
||||
remapping, reordering layers, editing arbitrary stored vector values, and resolving
|
||||
removed targets are separate work. Preserve all existing generated-data behavior.
|
||||
|
||||
Exercise the actual `flow/regenerate.cljs` entry points in integration tests:
|
||||
author a correction, regenerate compatible base data, and confirm the correction
|
||||
survives with its ID/support/values intact and affects the new base. Then change
|
||||
topology on a geometry fixture and assert persisted actionable conflicts. The
|
||||
geometry fixture can use an existing layer directly; geometry authoring is not
|
||||
required to expose and resolve a conflict already present in a document.
|
||||
|
||||
## 6. Verification and completion
|
||||
|
||||
Use focused tests that establish observable promises:
|
||||
|
||||
- Domain: each motion's sample values; refusal on short ranges/nonfinite values;
|
||||
default channel materialization; unchanged base and prior layers; duplicate ID.
|
||||
- Evaluation: compare before/after on every frame outside support, including
|
||||
neighboring interpolated frames. Cursor/spec agreement in nonmonotonic order.
|
||||
- Ownership: a lane Return crosses a drawing boundary; a cel correction moves
|
||||
with the cel and survives split/trim. Neither alters another use of its drawing.
|
||||
- Sampling: picture-rate/pose selection changes the generated base frame while
|
||||
the authored correction still reads owner time. Keep HEAD's regression tests.
|
||||
- Events: one Apply is one undo step; undo/redo restores complete layer data;
|
||||
refusal leaves clip/history unchanged; stale target refuses; selection survives.
|
||||
- Persistence: leaf and Transit round trips retain layers, order, IDs, conflicts.
|
||||
- Browser: both views can select a target and apply the same correction using
|
||||
actual controls. Assert evaluated results and history, not only a layer count.
|
||||
Include the two-lane gap-targeting case from step 1.
|
||||
- Regeneration and conflict actions: use the real flow and test undoable removal,
|
||||
valid retry, invalid retry, and removal that would break a downstream layer.
|
||||
|
||||
Run the suites documented in `lane-handoff.md`: CLJS tests, lane and take browser
|
||||
flows, Django tests, and optimized frontend build. Restore the dev app bundle
|
||||
after the release build. Note that `take.mjs` writes a local project. Report
|
||||
actual results and any unrun checks; do not copy previous test counts as evidence.
|
||||
|
||||
Suggested commit sequence: sheet targeting; consistent stack compatibility;
|
||||
pure correction commands; pane/events plus browser proof; updated handoff.
|
||||
Keep each commit coherent and tested. No schema version bump should be needed:
|
||||
the layers already have a persisted representation.
|
||||
|
||||
Update `lane-handoff.md` and `lane-model.md` with what shipped, the owner-frame
|
||||
range UI limitation, conflict actions available, and verified test counts. Done
|
||||
means a person can author and undo the correction, regenerate its base, and see
|
||||
either their preserved edit or a useful conflict. A constructor without reachable
|
||||
controls, or controls without that regeneration proof, does not finish this work.
|
||||
155
docs/creating-in.md
Normal file
155
docs/creating-in.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# Creating in
|
||||
|
||||
Revised 2026-10-02.
|
||||
|
||||
Arthur's creation model is a layer list plus a playhead. The primary active row
|
||||
is the preferred place for a new thing. The playhead decides whether that row is
|
||||
present in the current occurrence and supplies the frame at which the thing is
|
||||
created.
|
||||
|
||||
This is editor behavior, not document structure. A saved clip has symbols,
|
||||
instances and spans; it does not have timeline rows or a creation target.
|
||||
|
||||
## Selection and the active row
|
||||
|
||||
The selection set names the objects affected by copy, delete and transform. Its
|
||||
primary member is also the active row, analogous to the active layer in a paint
|
||||
program. There is no second targeting gesture for a user to maintain.
|
||||
|
||||
- Clicking a row label, its timeline body, or the same occurrence on the stage
|
||||
makes that occurrence primary.
|
||||
- Shift/marquee selection may retain several objects, but only the primary row
|
||||
anchors creation.
|
||||
- Clicking blank stage or timeline space returns the active row to the symbol in
|
||||
the current tab.
|
||||
- Row disclosure is independent. Selecting or creating never implicitly expands
|
||||
a row.
|
||||
|
||||
The selection does not change merely because the playhead moves. An off-frame
|
||||
object remains available for copying, deletion and inspection.
|
||||
|
||||
## Resolving the active row at the playhead
|
||||
|
||||
Resolution starts with the structural destination implied by the primary row:
|
||||
|
||||
- A lane row means the lane symbol itself.
|
||||
- An ordinary symbol-instance row means the symbol placed by that occurrence.
|
||||
- A non-instance row means the symbol containing that node.
|
||||
- No row means the symbol in the current tab.
|
||||
|
||||
For an ordinary symbol occurrence, the playhead must be within that occurrence's
|
||||
extent in the current nested context. Extents are tested after walking all parent
|
||||
time maps and use the half-open interval `[in, out)`. If the preferred occurrence
|
||||
is not present, resolution walks outward to the nearest parent whose occurrence
|
||||
is present. A containing lane is therefore the natural fallback from an inactive
|
||||
cel. If no nested occurrence is present, the current tab is the destination.
|
||||
|
||||
The preferred row is retained during fallback. Scrubbing back into its extent
|
||||
makes it the effective destination again.
|
||||
|
||||
A lane differs only in what its row means. It is an insertion surface across its
|
||||
containing timeline and does not require an existing cel under the playhead. A
|
||||
cel within the lane is still an ordinary symbol occurrence with an extent.
|
||||
|
||||
For `A -> B -> lane C -> cel D`:
|
||||
|
||||
- over D, with D primary, creation happens inside D;
|
||||
- past D but while C is available, creation inserts a new cel in C;
|
||||
- outside C's containing occurrence but inside B, creation happens inside B;
|
||||
- outside every nested occurrence, creation happens in A, the current tab.
|
||||
|
||||
## What creation does
|
||||
|
||||
Once resolved, every creation command follows the destination kind.
|
||||
|
||||
### Lane destination
|
||||
|
||||
- A new or dropped symbol is instantiated directly in the lane at the playhead.
|
||||
- Its interval claims that time. Existing cels under the interval are removed or
|
||||
trimmed by the lane's ordinary claim-time command.
|
||||
- Beginning a drawing creates a new one-frame drawing symbol in the lane and the
|
||||
finished shape is a child of that drawing.
|
||||
- Selecting an existing cel changes the destination from the lane to the symbol
|
||||
placed by that cel; subsequent symbols and shapes become children there.
|
||||
|
||||
Double-clicking a lane creates an empty cel at the playhead; beginning a drawing
|
||||
creates a drawing cel there. These are the same lane-creation operation with
|
||||
different payloads. The pointer chooses the lane, never a second creation time.
|
||||
The resulting cel is selected, so it immediately becomes the preferred target:
|
||||
drawing again enters that cel's symbol instead of replacing it.
|
||||
|
||||
Thus no separate "new cel" versus "add inside" mode is needed. Selecting the
|
||||
lane header says new cel; selecting a cel says add inside.
|
||||
|
||||
### Ordinary symbol destination
|
||||
|
||||
- A new symbol is instantiated as a child at the mapped playhead frame.
|
||||
- A new shape is authored directly in the symbol at that frame.
|
||||
- Stage coordinates are transformed through the occurrence into the destination
|
||||
symbol's local coordinates.
|
||||
|
||||
### Explicit timeline drop
|
||||
|
||||
A timeline drop uses the row and frame under the pointer, not the stored active
|
||||
row and playhead. Dropping onto a lane therefore always instantiates in that lane
|
||||
and claims the pointer's interval. A stage drop uses the resolved active row and
|
||||
the playhead.
|
||||
|
||||
Paste, imported symbols and converted footage obey the same resolver as direct
|
||||
creation. They must not each reconstruct nesting or extent fallback separately.
|
||||
|
||||
## Multi-selection and paste
|
||||
|
||||
Multi-selection does not create multiple insertion targets. Copy and cut take
|
||||
the canonical forest of selected roots as one ordered payload; the primary
|
||||
(last-selected) occurrence alone supplies the preferred row for a later paste.
|
||||
At paste time that row and the current playhead resolve one effective
|
||||
destination, and every root in the payload is inserted there in one transaction.
|
||||
|
||||
The earliest finite root start is aligned to the destination frame. Other roots
|
||||
keep their timing offsets, hierarchy, and clipboard order. In an ordinary symbol
|
||||
the roots may overlap. In a lane every root must have a finite span and the
|
||||
payload's root spans must not overlap one another; valid spans claim their times
|
||||
and trim or remove existing cels as a batch. An invalid member refuses the whole
|
||||
paste rather than inserting a partial payload.
|
||||
|
||||
After paste, all new roots form the selection and the final root is primary, so
|
||||
it becomes the preferred row for the next creation. Pasting one payload into
|
||||
several selected destinations is intentionally not implicit: that would be a
|
||||
separate distribute command. Duplicate is also distinct from paste—it stays
|
||||
beside each source in its original owner and does not consult the playhead or
|
||||
creation target.
|
||||
|
||||
## Palette lanes
|
||||
|
||||
Palette lanes use the same row-and-playhead resolution. Their content filter and
|
||||
transition command remain palette-specific: only palettes and palette
|
||||
transitions can be inserted there. This is a type restriction, not a second
|
||||
targeting model.
|
||||
|
||||
## Implementation boundary
|
||||
|
||||
One pure resolver returns the effective destination:
|
||||
|
||||
```clojure
|
||||
{:kind :lane | :symbol
|
||||
:sid destination-symbol
|
||||
:path effective-occurrence-path
|
||||
:frame destination-local-frame
|
||||
:matrix destination-to-current-tab-transform}
|
||||
```
|
||||
|
||||
Callers may add the unchanged document as `:clip` or rename `:frame` to `:at`,
|
||||
but they must not reinterpret the active row. Polygon creation, symbol creation,
|
||||
stage drops, paste, import and footage conversion all consume this answer.
|
||||
Explicit timeline drops use the same structural row rule with the row and frame
|
||||
under the pointer; unlike playhead resolution, an invalid pointer destination is
|
||||
refused rather than allowed to fall outward.
|
||||
|
||||
The invariants are:
|
||||
|
||||
1. The primary row is the preferred structural destination.
|
||||
2. The playhead validates occurrences and supplies creation time.
|
||||
3. Inactive targets fall outward; selection does not follow them.
|
||||
4. A lane row inserts a cel, while a cel row enters its symbol.
|
||||
5. Stage creation uses the playhead; timeline drops use pointer time.
|
||||
510
docs/frame-selection.md
Normal file
510
docs/frame-selection.md
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
# Frame selection
|
||||
|
||||
> Since `docs/tracing-symbol-plan.md`: `domain/trace` is gone. Trace keys are
|
||||
> the face's `:plate` placement's `:time :holds`, the origin is the head's
|
||||
> `:reads`, and the photo's registration is the plate's own measured channels.
|
||||
> Where this document names `trace/measured-local` or `trace/prepare`, read the
|
||||
> plate's or the head's measured channels and `node/hold`.
|
||||
|
||||
Two mechanisms. One vocabulary. An earlier draft of this document claimed they
|
||||
were one component used twice — because `suggestPlateFrames` in the old
|
||||
`js/pipeline.js` and the never-built "performance poses" of
|
||||
[timing-handoff](timing-handoff.md) looked like the same function — and that claim
|
||||
is wrong. They share how a selection is *read* and how the hand overrides one.
|
||||
They do not share how frames get chosen, because the two are answering questions
|
||||
of different shapes.
|
||||
|
||||
[Time selection](time.md) is the floor both stand on: an output frame reads the
|
||||
latest native frame at or before its time, and nothing rewrites the dense
|
||||
measurements. That is a *cadence*: an answer with no opinion about content. It
|
||||
cannot know that the one frame where the eye is fully closed is worth more than
|
||||
its neighbours, so at 12fps out of 30 it drops that frame two times in three.
|
||||
This document is how the picture gets an opinion.
|
||||
|
||||
## The one idea
|
||||
|
||||
**A selection is a set of frames chosen out of a dense measurement, and read by
|
||||
holding the latest one at or before now.**
|
||||
|
||||
The holding half already exists and is already shared: `pose/held-frame` is called
|
||||
by `node/hold` (a placement's `:time :holds`) and by `pose/source-frame`, which is the two sites agreeing
|
||||
about reading. The hand half is shared too — see *Three layers* below. Choosing is
|
||||
what differs.
|
||||
|
||||
| | Plate drawings (tracing) | Performance poses |
|
||||
| --- | --- | --- |
|
||||
| the question | which frames does an artist have to draw a head on? | which frames does the picture change a shape on? |
|
||||
| the cost being managed | a person drawing | a pose looking wrong |
|
||||
| signal | the measured head's motion | — none; a stored cut |
|
||||
| the baseline it improves on | drawing on 2s | the cadence, or the exposure grid |
|
||||
| the shape of the answer | a non-uniform set out of dense | the same grid, nudged |
|
||||
| lives on | the face's `:plate` `:time :holds` | the instance's `:playback :tracks` |
|
||||
| hand edit today | `::project/toggle-hold` | `pose/put-cut` / `pose/remove-cut` |
|
||||
| UI today | `params/layer-section` | **none** |
|
||||
| proposes today | **nothing** | **nothing** |
|
||||
|
||||
## Why they are not one function
|
||||
|
||||
A plate selection has to be **non-uniform**, and that is the whole reason it
|
||||
exists. A head still for sixty frames and then whipping across in ten wants two
|
||||
drawings for the first stretch and eight for the second. Drawing on 2s gives
|
||||
thirty-five drawings, most of them identical, and no amount of nudging a uniform
|
||||
grid will produce the distribution that is wanted — the spacing itself is the
|
||||
answer. That is what the prototype's walk was for, and it is why a cost knob
|
||||
(`:tolerance`) belongs on this side: the artist is buying drawings.
|
||||
|
||||
A performance selection is **not choosing sparseness at all**. The output rate or
|
||||
the exposure setting has already chosen it. The question left over is only *which*
|
||||
native frame each already-decided slot reads, and the failure it fixes is narrow:
|
||||
a slot landing one or two frames off the closure. Nudging the grid is the right
|
||||
size of answer, and there is nothing for a tolerance to mean.
|
||||
|
||||
There is a second, harder reason, and it is the one that settles it:
|
||||
|
||||
**A selection cannot put a frame on screen that the output grid never samples.**
|
||||
At 12fps out of 30, output frame 5 reads native 12 and output frame 6 reads native
|
||||
15. A closure at native 13 is *between* them. Protecting frame 13 in a set of
|
||||
kept frames makes it available and makes it the frame held across 13 and 14 in
|
||||
native space — and at a 12fps output it still never appears, exactly as
|
||||
[time.md](time.md) says: an event between output frames cannot create an extra
|
||||
frame in a 12fps output. Only moving what output frame 6 reads can show it. So
|
||||
the performance side has to act on the grid, not on a set beside it.
|
||||
|
||||
## Three layers, and the middle one is derived
|
||||
|
||||
The trap this is designed around is stated in
|
||||
[timing-handoff](timing-handoff.md) and is worth repeating because it is the
|
||||
only hard rule here:
|
||||
|
||||
> Store manual edits separately from generated proposals so changing the rate or
|
||||
> tolerance retains hand decisions.
|
||||
|
||||
So a selection is:
|
||||
|
||||
```clojure
|
||||
{:policy {:tolerance 0.02} ; what the proposer was asked for
|
||||
:keep #{47} ; frames the hand insists on
|
||||
:drop #{30}} ; frames the hand refuses
|
||||
```
|
||||
|
||||
and the effective set is `(proposed ∪ keep) \ drop`, always containing frame 0.
|
||||
|
||||
`:keep` and `:drop` are the document. The proposal is not: it is recomputed from
|
||||
`:policy` and the dense signal whenever either changes. **Re-suggesting at a new
|
||||
tolerance must never cost somebody their pinned blink**, and that is the entire
|
||||
reason the hand decisions are stored as their own two sets rather than as the
|
||||
resulting frame list.
|
||||
|
||||
This layering is the part that really is shared. On the performance side there is
|
||||
no `:policy` worth storing — the grid is the policy — but `:keep` and `:drop` mean
|
||||
exactly what they mean on the plate side, and `select/effective` is the one
|
||||
implementation for both. A preserve mark *is* a keep.
|
||||
|
||||
### Materialise the result, do not derive it on the render path
|
||||
|
||||
The effective set is written back to where each site already reads it —
|
||||
the plate's `:time :holds`, or the pose track — so that every existing reader is untouched
|
||||
and nothing on the per-frame path has to open a dense block. Proposing is a
|
||||
command, not a subscription. `ch/value-at` allocates per call and says so; that
|
||||
is fine for a button press over a few hundred frames and would not be fine at
|
||||
30fps.
|
||||
|
||||
This means the stored frame list is redundant with `policy + keep + drop`. That
|
||||
is deliberate and it is the cheap direction of the trade: a stale list is
|
||||
recoverable by pressing Suggest again, and a dense read per node per frame is
|
||||
not recoverable at all.
|
||||
|
||||
## The plate selection: a non-uniform chooser
|
||||
|
||||
`arthur.domain.select`, built — see *What is revertible* for the one part of it
|
||||
that is not yet wanted. Pure, no store access, no clip access: the site hands it a
|
||||
signal it has already read.
|
||||
|
||||
```clojure
|
||||
(defn propose
|
||||
"Frames worth keeping out of `n`, given `signal`."
|
||||
[n signal {:keys [tolerance protect]}])
|
||||
|
||||
(defn effective
|
||||
"`proposed` with the hand's decisions applied. Always contains 0."
|
||||
[proposed keep drop])
|
||||
```
|
||||
|
||||
`propose` is the prototype's walk, generalised off landmarks:
|
||||
|
||||
1. keep frame 0, make it the anchor;
|
||||
2. settle the protected frames from `:protect` *before* walking;
|
||||
3. for each later frame, keep it when it is protected, or when
|
||||
`distance(anchor, f) > tolerance`. Either way it becomes the anchor.
|
||||
|
||||
`distance` is the max absolute difference over components, so a signal of
|
||||
landmark pairs and a signal of one number both work without the caller saying
|
||||
which it handed over. Every sample in a signal is the same width, and a signal of
|
||||
two widths is refused: comparing the prefix two samples happen to share would let
|
||||
a reader that drops a component read as no movement at all.
|
||||
|
||||
**A protected frame anchors the walk like any other kept frame**, which is why
|
||||
protection is settled first and is not unioned onto the walk's result. The anchor
|
||||
is what is on screen; once a protected frame is kept the viewer is looking at it,
|
||||
so measuring the next frame's drift from a frame no longer displayed is wrong.
|
||||
|
||||
**An absent measurement is `nil`, and converting to that is the reader's job.** A
|
||||
frame where the face was not found says nothing about the signal: it cannot move
|
||||
the anchor and it is not a frame worth keeping. `trace/measured-local` already
|
||||
returns nil there. A protected frame with no measurement is still kept — a plate
|
||||
frame is a frame somebody draws on whether or not the detector found a face.
|
||||
|
||||
**A non-finite tolerance falls back to nought.** A cleared slider reads as NaN and
|
||||
every comparison against NaN is false, which taken literally proposes frame 0
|
||||
alone and collapses the whole take to one drawing. Nought proposes every frame
|
||||
that changes, which is merely the baseline back again: wrong in a way somebody can
|
||||
see and undo.
|
||||
|
||||
### The signal
|
||||
|
||||
The head's measured transform, applied to a fixed reference quad, giving
|
||||
displacement in stage units — so a tolerance means "the head has moved this far"
|
||||
and is a number a person can reason about. `trace/measured-local` already builds
|
||||
that matrix per frame and is private; make it public rather than writing a second
|
||||
one. Map it over the frames and transform four corners through `node/apply-pt!`.
|
||||
|
||||
The quad's size is a real parameter hiding in the word "fixed": it sets how much
|
||||
rotation registers against translation. Give it a name and a comment rather than
|
||||
an inline literal.
|
||||
|
||||
Do not reach for raw landmarks. The prototype used them because it had them lying
|
||||
around; the transform is what the drawing actually follows, it is already on the
|
||||
node, and it is three channels instead of a block.
|
||||
|
||||
### The upgrade path, and do not start here
|
||||
|
||||
The greedy walk is order-dependent and slightly suboptimal. The optimal version
|
||||
is a dynamic program — choose `k` frames minimising held-reconstruction error,
|
||||
which is textbook segmented least squares and is O(n²k), nothing at n≈300 — and
|
||||
it keeps extrema *for free*, because an extremum is exactly where a zero-order
|
||||
hold is most wrong.
|
||||
|
||||
Build the greedy one first anyway. It is proven, it shipped in the prototype, and
|
||||
having two implementations to compare is how the DP gets tested. Swap it behind
|
||||
`propose` afterwards, where the signature already permits it.
|
||||
|
||||
Most of `select_test` pins the greedy walk's exact output, deliberately, for that
|
||||
comparison — so expect to rewrite those expectations when the DP lands, and keep
|
||||
them as greedy-specific tests rather than deleting them. The assertion that is a
|
||||
*spec* rather than a pinned vector, and should be written on this side before the
|
||||
swap, is:
|
||||
|
||||
> reading the signal through the selection, held, never differs from the dense
|
||||
> measurement by more than `tolerance`
|
||||
|
||||
That is what makes the tolerance number mean something to a person. It is true of
|
||||
the greedy walk by construction and it is what the DP optimises, so it survives
|
||||
the swap untouched.
|
||||
|
||||
## The performance selection: a preserve-snap on the grid
|
||||
|
||||
Not built. The rule is ten lines; getting the two halves of it into the same place
|
||||
is the work. An earlier draft of this section said "about thirty lines" and that
|
||||
was understated — see *Where it goes* below.
|
||||
|
||||
A grid slot already picks a native frame — `cadence/frame` for the output rate, or
|
||||
the exposure fold for a deliberate hold at full rate. Write `d(k)` for the native
|
||||
frame slot `k` defaults to. Slot `k` is the first slot to cover everything in
|
||||
`(d(k-1), d(k)]`, and the frames strictly inside that interval are the ones the
|
||||
grid shows to nobody. So:
|
||||
|
||||
> **Slot `k` reads the latest preserved frame in `(d(k-1), d(k)]`, and `d(k)` when
|
||||
> there is none.**
|
||||
|
||||
That is the whole mechanism. It recovers a dropped frame out of the slot's own gap,
|
||||
it can never read a frame another slot already showed, and it cannot reach past
|
||||
`d(k)`.
|
||||
|
||||
**Snap backward only, never forward**, and note which direction that actually is,
|
||||
because it is easy to get backwards. The closure at native 13 in a 12-from-30
|
||||
output is recovered by slot **6** — whose default is 15 — reading 13. It is *not*
|
||||
recovered by slot 5, whose default is 12, reaching forward to 13: slot 5's instant
|
||||
is 5/12s = 0.4167s and native 13's is 13/30s = 0.4333s, so that would show the
|
||||
closure 17ms before the mouth shut. `cadence/frame`'s contract is the latest native
|
||||
frame at or before the slot's time and `cadence_test` asserts
|
||||
`selected <= f*native/grid` over every grid and native pair, so reaching forward
|
||||
breaks a tested invariant as well as the no-lead rule. Reading 13 at slot 6 shows
|
||||
the closure two native frames late, which is the same lateness every hold already
|
||||
has.
|
||||
|
||||
**The marks come from a cut that is already stored.** `flow/freeze` computes both
|
||||
closures and keys them as `[:vis]`, with the thresholding and hysteresis already
|
||||
decided:
|
||||
|
||||
- the mouth, from the aperture relative to the take's peak — `[:vis]` on
|
||||
`:mouth-in`, provenance `:roto/mouth-aperture` (`freeze.cljs:473`);
|
||||
- the eyes, from `condition/resolve-blink` with its cut, dwell and hold — `[:vis]`
|
||||
keyed per eye part, provenance `:roto/blink` (`freeze.cljs:533`).
|
||||
|
||||
So "preserve the frames the cut says shut" reads what the document already holds.
|
||||
No new signal, no new dense track, no threshold decided twice. Brows have no
|
||||
closure and get no marks, which is correct: there is no extreme brow position
|
||||
worth protecting.
|
||||
|
||||
**Precompute the mark set when the resolver is built**, the way `pose/prepare` and
|
||||
`trace/prepare` already do (`symbol.cljs:420`, `:470`, `:536`). A `[:vis]` channel
|
||||
is keys, not dense, so reading it per node per frame would be cheap — but the snap
|
||||
also needs the marks sorted for a backward lookup, and building that per frame is
|
||||
the one thing [animation-model.md](animation-model.md) and the render-path note
|
||||
above both forbid.
|
||||
|
||||
### Where it goes, and why it is not a one-liner
|
||||
|
||||
The rule needs two things that currently live at opposite ends of the resolver:
|
||||
|
||||
- **the slot interval** `(d(k-1), d(k)]`, which needs the output slot index and the
|
||||
fps ratio. Both exist at `clip.cljs:261`, the single place the grid becomes a
|
||||
native frame: `(cadence/frame f (or (:grid-fps opts) (:fps clip)) (fps clip sid))`.
|
||||
- **the marks**, which are per pose group, and so belong where group identity
|
||||
exists: `symbol/base-channel-frame` (`symbol.cljs:399`), whose `:pose-sampled?`
|
||||
branch already calls `pose/source-frame` with `(js/Math.floor lf)` as the default
|
||||
pose. **That default is the snap's seat.** Replacing it leaves an explicit hand
|
||||
cut winning over a snap, which is correct — manual precedence is absolute — and
|
||||
costs no new plumbing on the pose side, because per-group choices are already
|
||||
threaded and already prepared.
|
||||
|
||||
By the time control reaches `base-channel-frame` there is only `lf`, a native local
|
||||
frame that placement and retime have already been through, so the slot interval
|
||||
cannot be recovered there. It has to be threaded down from `clip.cljs:261`
|
||||
alongside the frame. That is the actual work of this step: two namespaces' internal
|
||||
signatures, not a drop-in.
|
||||
|
||||
**Do not take the shortcut of snapping at `clip.cljs:261` itself.** It is right
|
||||
there, it needs no threading, and it is wrong: one native frame per output frame
|
||||
means the *whole picture* reads 13 instead of 15, so the head goes two frames stale
|
||||
for one output frame to fix the mouth. At 12fps that is a 67ms hitch on a moving
|
||||
head, and it fights the trace selection, which has its own opinion about which head
|
||||
frame to show. The snap is per group because the thing being recovered is one
|
||||
group's closure.
|
||||
|
||||
### Why not an aperture signal
|
||||
|
||||
An earlier draft had this side read the group's aperture as a single-component
|
||||
signal and hand it to `propose` with `:protect :extrema`. It cannot. The aperture
|
||||
is the separation of landmarks 13 and 14, at positions 5 and 15 of the 20-slot
|
||||
`LIPS-INNER` ring, and `freeze/rings->flat` subsamples the ring to the `verts`
|
||||
budget: those two positions survive only when `verts` is a multiple of four. At
|
||||
`verts` 6, 10, 14 and 18 — all legal, all even — they are not in the stored data
|
||||
at all. `ring/subsample-slots` used to claim otherwise and has been corrected.
|
||||
|
||||
`flow/measure/mouth` does compute the exact scalar and `freeze` does throw it away
|
||||
after thresholding, so storing it was an option. Reading the cut is strictly less
|
||||
work and decides nothing twice.
|
||||
|
||||
## How the two interact
|
||||
|
||||
They are keyed in **the same frame space**: `clip/resolver` hands an instance's
|
||||
`:playback :tracks` down into the child it places, and `symbol/base-channel-frame`
|
||||
reads both the trace and the pose choices at `lf`, the node's local frame inside
|
||||
that face. A trace frame and a pose cut are the same kind of number.
|
||||
|
||||
What differs is the owner, and that asymmetry is load-bearing:
|
||||
|
||||
- the **trace** is the face's, on its `:head` — every instance of that face shares
|
||||
it, because it says how the drawings were made;
|
||||
- the **pose tracks** are the instance's — two placements of one face can be
|
||||
timed differently.
|
||||
|
||||
### The hazard, which is already written down
|
||||
|
||||
[animation-model.md](animation-model.md) states it for exposure and it is the
|
||||
same hazard here:
|
||||
|
||||
> a head cutting on odd frames against a mouth cutting on even ones reads as two
|
||||
> performances
|
||||
|
||||
**It is not a correctness problem.** The mouth is a child of `:head` and its
|
||||
geometry is stored head-local, so a mouth from frame 17 composed onto a head held
|
||||
at frame 12 is exactly lip-sync on a held drawing — the decomposition already
|
||||
decoupled them and nothing is geometrically wrong. The problem is perceptual, and
|
||||
perceptual problems want a constraint rather than a repair.
|
||||
|
||||
### Nest them, do not couple them
|
||||
|
||||
The two are not peers. One is coarse and expensive — the prototype's own comment
|
||||
says it: *"The cost being managed is an artist drawing a head, which is why the
|
||||
signal is head pose and not the mouth — the mouth is traced and free."* The other
|
||||
is fine and cheap.
|
||||
|
||||
So the rule is a subset, in one direction only:
|
||||
|
||||
**Every kept plate frame is a preserved frame of the performance selection.**
|
||||
|
||||
When the drawing changes, the performance changes with it, so the two can never
|
||||
cut against each other on neighbouring frames. The mouth stays free to change on
|
||||
frames where the head does not, which is what shooting a held drawing with a live
|
||||
mouth *is*.
|
||||
|
||||
This is why the preserve set is a set and not a closure predicate: plate frames
|
||||
and shut-mouth frames go into the same pile, and the snap does not care which is
|
||||
which. It needs no new mechanism on either side.
|
||||
|
||||
The reverse is a suggestion and never automatic. A mouth closure is a reasonable
|
||||
place to want a new drawing, but proposing one spends somebody's afternoon. Offer
|
||||
it; do not take it.
|
||||
|
||||
It also composes with `:origin` for free. A head on `:continuous` has opted out
|
||||
of its own selection, so there are no plate frames to preserve and the constraint
|
||||
is vacuous — which is correct, because a continuously moving head cannot cut
|
||||
against anything.
|
||||
|
||||
### Between the kept frames is a third shared field
|
||||
|
||||
The head's `:reads` (once `:trace :origin`) is not a tracing setting. It is the answer to *what happens
|
||||
between kept frames*, and the plate selection has to answer it:
|
||||
|
||||
- `:continuous` — ignore the selection for this purpose and read the frame you
|
||||
are on,
|
||||
- `:keys` — jump to each kept frame and hold it, a hold and not a tween,
|
||||
- `:start` — hold the first forever.
|
||||
|
||||
The performance side does not need the field: a snap picks which frame a slot
|
||||
reads and the grid does the holding, so `:keys` is the only behaviour there is.
|
||||
Do not rename `:origin` to match anything: for a head it genuinely means where the
|
||||
face's origin goes, and a saved field in a shipped UI is not worth churning for a
|
||||
vocabulary tidy.
|
||||
|
||||
## The modes
|
||||
|
||||
One setting, two states, and **"manual" is not a third state.**
|
||||
|
||||
- **off** — the cadence alone, which is what ships today. The output frame
|
||||
reads the latest native frame at or before it, and nothing has an opinion.
|
||||
- **smart frame picking** — on the plate side the proposal is live, and `:policy`
|
||||
holds the tolerance; on the performance side the snap is active.
|
||||
|
||||
Hand keeps and drops apply in **both** states, which is why they are not a mode:
|
||||
turning smart picking off must not throw away the frames somebody pinned, and
|
||||
pinning a frame with smart picking off is a perfectly reasonable thing to want.
|
||||
Manual precedence is absolute — a drop beats a proposal, always.
|
||||
|
||||
The name on the toggle should be the same word in both sections. "Smart frame
|
||||
picking" is fine. What it must not be is two different names for the one idea,
|
||||
which is how these became two features the first time. That the two sections are
|
||||
now backed by different code is an implementation fact and must not reach the UI.
|
||||
|
||||
## The UI
|
||||
|
||||
One component rendered twice, in `ui/params.cljs`:
|
||||
|
||||
```
|
||||
smart frame picking [ off | on ]
|
||||
tolerance [ ----•------- ] 0.02 (plate section only)
|
||||
[ Suggest ]
|
||||
frames 0 12 30 47* 61 (* = kept by hand, strikethrough = dropped)
|
||||
```
|
||||
|
||||
The frame strip already exists in miniature — `params/trace-keys` draws the trace
|
||||
keys as seek buttons (`params.cljs:386`). Lift it into a shared component and
|
||||
give it three affordances: click to seek, a modifier to pin, a modifier to drop.
|
||||
A pinned frame and a proposed frame must be visually distinct, because "will this
|
||||
survive me moving the slider?" is the question the strip exists to answer.
|
||||
|
||||
Render it in two sections:
|
||||
|
||||
- **`tracing · <face>`** (`params.cljs:426`), beside the existing origin row,
|
||||
with the tolerance slider and Suggest.
|
||||
- **`performance · <group>`** — new, on an instance's inspector, one per pose
|
||||
group the placed symbol has. No tolerance: the strip shows the preserved frames
|
||||
and the grid slots that snapped to them.
|
||||
|
||||
## Order to build it
|
||||
|
||||
1. **`domain/select` with the greedy walk, and its tests.** **Done**, in commit
|
||||
`02069e8`. Pure, no store, no clip. Note that the extrema part of it is not
|
||||
wanted by anything below — see *What is revertible*.
|
||||
2. **The preserve-snap**, pulled forward ahead of the plate side because it is the
|
||||
half with no UI and nothing proposing today, and because it needs nothing from
|
||||
the plate side except a fold that can land last. Three pieces: the mark set from
|
||||
the stored `[:vis]` cuts, built in a prepare step beside `pose/prepare`; the
|
||||
slot interval threaded from `clip.cljs:261`; the rule itself, seated in
|
||||
`base-channel-frame`'s default pose. The test that matters is the same synthetic
|
||||
case `select_test` uses — a one-frame closure at native 13 that a 12-from-30
|
||||
grid drops — asserted end to end this time: the resolver shows a shut mouth on
|
||||
exactly one output frame, and the head's frame does not move while it happens.
|
||||
Write it first.
|
||||
3. **The plate signal reader.** `trace/head-signal`: make `trace/measured-local`
|
||||
public, map it over the frames, four corners through `node/apply-pt!`. Test
|
||||
that it returns a vector of the right length, that a motionless take proposes
|
||||
`[0]`, and that a stretch where the face was not found becomes `nil` rather
|
||||
than a pose, a zero or a gap in the vector. Add the held-reconstruction
|
||||
invariant from *The upgrade path* here, since this is the first place a real
|
||||
signal exists to assert it over.
|
||||
4. **Storage for the plate selection.** `:policy`/`:keep`/`:drop` beside
|
||||
the plate's `:time :holds`. Extend `leaf/leaves` and the key whitelists in the same
|
||||
commit — a field without a leaf saves silently and comes back missing, which
|
||||
is the one bug persistence must not be able to have. Round-trip test.
|
||||
5. **Re-suggest preserves hand decisions.** Propose at one tolerance, pin a frame,
|
||||
drop a frame, propose at another, assert both survive. Settle here whether the
|
||||
hand's `:keep` is also fed to `propose` as `:protect`: under the layering as
|
||||
written it is not, so a pinned frame does not re-anchor the walk even though it
|
||||
is on screen, which contradicts the anchor rule above. It is the one live
|
||||
caller for `propose`'s `:protect` frames.
|
||||
6. **Fold the plate frames into the mark set**, which is the nesting rule and is
|
||||
one line once both sides exist.
|
||||
7. **The shared UI component**, then its two mountings.
|
||||
|
||||
## What is revertible, and where it is
|
||||
|
||||
Commit `02069e8` contains one part that **nothing below asks for**: extrema
|
||||
detection. Specifically `segments`, `turns`, the `:extrema` branch of `propose`,
|
||||
the multi-component refusal that exists only to guard it, and three tests —
|
||||
`a-one-frame-closure-survives-only-because-it-is-protected`,
|
||||
`extrema-are-turns-worth-more-than-the-tolerance` and
|
||||
`extrema-are-refused-on-a-multi-component-signal`.
|
||||
|
||||
It has no caller because plate selections take no extrema by design — displacement
|
||||
is the whole story for a head — and the performance side reads a stored cut
|
||||
instead of finding extrema in a signal. It is correct, tested and speculative.
|
||||
|
||||
Revert it if the shape above holds. Keep it if either of these turns out to be
|
||||
wanted: a group whose extreme is not a closure and therefore has no `[:vis]` cut
|
||||
to read (a mouth at its widest, a head at the top of a nod), or a take whose mouth
|
||||
never shuts far enough to cross `aperture-cut`, where a peak-relative threshold
|
||||
marks nothing and an extremum would still find the most closed frame. Neither is
|
||||
asked for today. The DP in *The upgrade path* gets extrema for free regardless, so
|
||||
reverting costs nothing that cannot be had again more cheaply.
|
||||
|
||||
## Traps
|
||||
|
||||
- **Do not let Suggest write `:keep`.** The proposal and the hand are different
|
||||
layers; collapsing them is the bug this whole shape exists to avoid, and it
|
||||
will look like it works right up until somebody moves the tolerance slider.
|
||||
- **Do not snap a grid slot forward.** Every hold and every pick is "at or
|
||||
before". Showing a closure before the mouth shut is a lead, which is a different
|
||||
control for a different reason.
|
||||
- **Do not quantise a plate selection onto the output grid.** A kept frame is a
|
||||
native frame and lands where it lands. [time.md](time.md) is explicit that an
|
||||
event between output frames appears on the next one; forcing kept frames onto
|
||||
the grid would re-create the problem the selection exists to solve. The snap is
|
||||
the opposite operation and is on the other side of the fence: it moves the grid's
|
||||
pick, never the kept frame.
|
||||
- **Do not read the aperture pair off a subsampled ring.** Positions 5 and 15 of
|
||||
`LIPS-INNER` survive only at `verts` divisible by four.
|
||||
- **Do not thin the plate selection with the mouth's.** Two scopes, two owners,
|
||||
and the nesting runs one way only: plate frames preserve performance frames,
|
||||
never the reverse.
|
||||
- **Do not give the performance side a tolerance.** The grid has already chosen
|
||||
the sparseness. A second knob there would be a control with nothing to control.
|
||||
- **A skipped frame, a hidden feature and an absent measurement remain three
|
||||
different facts.** A selection says nothing about visibility and nothing about
|
||||
whether a face was found. Note that the preserve marks are *derived from* a
|
||||
visibility cut, which makes this easy to blur: the cut says the interior is
|
||||
hidden, the mark says the frame is worth landing on, and one is not the other.
|
||||
|
||||
## Not in scope
|
||||
|
||||
- Automatic *grouping* of related parts beyond the existing `:pose-group`.
|
||||
Related parts must share one selection — a mouth outline, its interior, the
|
||||
teeth and the generated visibility reading different frames is the bug that
|
||||
grouping prevents — and `:pose-group` is where the grouping already lives.
|
||||
- A per-instance request for a different tolerance than the symbol's. The
|
||||
resolver threads no such option today and should not grow one until something
|
||||
needs it.
|
||||
- Variable frame rate. [time.md](time.md) assumes constant fps and so does this;
|
||||
a VFR source needs presentation timestamps before any of this means anything.
|
||||
247
docs/lane-handoff.md
Normal file
247
docs/lane-handoff.md
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
# Lane and symbol-clip handoff
|
||||
|
||||
Status (2026-10-01): the timeline is the one timing interface. A lane is a
|
||||
generic non-overlapping row of symbol clips; it is not a special drawing type.
|
||||
Dropping a library symbol makes a naturally playing clip, while creating a new
|
||||
empty symbol makes a one-frame held clip at the playhead. With no destination
|
||||
lane, either operation creates one. Existing legacy root symbol rows can be
|
||||
dragged into a lane. Blocks move by mouse; edge drags claim time by trimming
|
||||
neighbors; Shift-edge drags ripple every later clip; and the center of a shared
|
||||
cut composes the two edge edits into a rolling edit. Linked audio follows picture
|
||||
moves while its edges remain independently trimmable.
|
||||
|
||||
The commits beginning at `3d3c1bb` are the argument for the model and are worth
|
||||
reading before touching what they did — they are the design record, more than
|
||||
this file is.
|
||||
|
||||
3d3c1bb An occurrence is a node, with a clock of its own
|
||||
9446829 Reuse, duplicate and make unique: deciding what is shared
|
||||
26517af A position is an argument, not another command
|
||||
94c0a21 A correction is a layer, and a layer's values are a channel
|
||||
72b57e3 Regenerate the base, keep the hand work, and say when you cannot
|
||||
76106d3 The shot is as long as somebody said it was
|
||||
598c186 One word for one thing: it is a cel
|
||||
|
||||
## Read first, in this order
|
||||
|
||||
1. [The Lane Model](lane-model.md) — the design, and the status note under
|
||||
*Proof obligations* says what is built. It supersedes `animation-model.md`,
|
||||
`timing-model.md` and `architecture.md` wherever they overlap.
|
||||
2. `frontend/src/arthur/domain/lane.cljs` — every command, and the reasoning in
|
||||
its docstrings.
|
||||
3. `frontend/test/arthur/domain/lane_test.cljs` — what the model is asserted to
|
||||
do. It is the fastest way to see the shapes.
|
||||
4. `frontend/src/arthur/domain/channel.cljs`, the correction-layer section.
|
||||
|
||||
## Vocabulary — one word for one thing
|
||||
|
||||
Renamed in `598c186`, after four words had accumulated for one object. Use these
|
||||
and do not reintroduce the others.
|
||||
|
||||
| word | means |
|
||||
| --- | --- |
|
||||
| instance | the `:kind`. The general thing, anywhere in a document |
|
||||
| clip | an instance in a lane. It can hold one source frame or play a symbol naturally |
|
||||
| cel | specifically a one-frame source held over a clip's duration; the empty-symbol/drawing creation policy |
|
||||
| lane | a group with `:layout :sequence` |
|
||||
| drawing | content authored into a symbol; not a different timeline node type |
|
||||
| placement | ONLY where a node sits: `nest/placement`, and the transform that puts a face on the stage. Never the node itself |
|
||||
|
||||
`occurrence` and `exposure` are not words for a cel. **`exposure` means something
|
||||
else and still does**: `:time :expose` is how many frames each step of a subtree
|
||||
lasts, which is what shooting on twos is — `node/expose`, `clock/exposed-frame`,
|
||||
`subs/render ::exposure`. Keeping these apart is why the block is called a cel.
|
||||
|
||||
`:layout :sequence` stays as the field, and is the one place two words are kept
|
||||
on purpose: the layout names the RULE — children follow one another and may not
|
||||
overlap — and a group carrying it is called a lane. `node/lane?` is where they
|
||||
meet.
|
||||
|
||||
## Decisions already made — do not re-litigate
|
||||
|
||||
These were each argued out and are load-bearing. Changing one is a design
|
||||
decision, not a cleanup.
|
||||
|
||||
- **The shot length is authored.** `:frames` is the symbol's window; the
|
||||
occupied extent of its lanes is a different fact derived from the cels. A
|
||||
command grows the window only when the caller passes `:extent :grow-symbol`,
|
||||
and never shrinks it. Blanking the end of a shot leaves empty frames at the
|
||||
end, because deriving the window from the extent would make deleting the last
|
||||
drawing silently shorten the film. `lane/finish`.
|
||||
- **Placing ripples; overwrite is `blank` then non-rippling placement.**
|
||||
`lane/overwrite-drawing` composes those pieces as one transaction. Insertion
|
||||
retains its ripple rule; overwrite does not move any surviving cel.
|
||||
- **A position inside a cel refuses and names `split`.** One command must not
|
||||
quietly perform two. The UI offers the retry.
|
||||
- **A correction has no time space of its own.** Its `:support` and its values'
|
||||
keys are in the frames the base channel's keys are in — the node's. A
|
||||
correction on a lane is in lane frames and reaches across the drawings under
|
||||
it; one on a cel travels with that cel. Ownership already answered it.
|
||||
- **A layer's values are a channel.** Constant, ramp and return motion are one
|
||||
mechanism. Do not add a second way to say what a value is over time.
|
||||
- **A conflict is not a `problem`.** A document whose topology outgrew a
|
||||
correction loads, evaluates and saves; `clip/conflicts` lists the decisions
|
||||
waiting for a person. `problems` means the document will not load.
|
||||
- **Refuse rather than guess.** Every command returns `{:clip :selection}` or
|
||||
`{:refused why}`, never a half-applied edit. Where the model needs a choice
|
||||
nobody has made, refusing and saying why is the behaviour, not a placeholder.
|
||||
- **A clip is not a row.** Rows, expansion and selection are editor state. The
|
||||
document has never known about rows and must not learn — which is what let
|
||||
the row model change three times in one sitting (blocks, then a
|
||||
selected-clip portal, then sound lanes under the audio heading) without
|
||||
touching a single document.
|
||||
- **A lane is generic.** Drawing creation, library placement, and adopting an
|
||||
existing root instance all produce the same child instance shape. The only
|
||||
difference is playback policy: a new empty drawing holds source frame zero;
|
||||
a dropped library symbol plays at speed one.
|
||||
- **Lanes are explicit.** A symbol may contain ordinary overlapping children
|
||||
without a lane. Selecting a lane row opts creation into its claim-time
|
||||
behavior; selecting a cel enters that cel's source symbol instead.
|
||||
- **Placement claims time.** Lanes never store overlaps. A new or extended clip
|
||||
trims, removes, or splits whatever previously owned the claimed interval.
|
||||
Real compositing overlap uses another lane, where ordering remains explicit.
|
||||
|
||||
## The timeline opens the whole document
|
||||
|
||||
Expanding a lane opens exactly one clip — the selected one — and that portal
|
||||
opens the lanes and nodes of the symbol it places, recursively, mapped into
|
||||
the open symbol's ruler. The portal follows the LINEAGE of the selection, so
|
||||
working on something nested keeps the rows that revealed it open. A held clip
|
||||
opens too, with its rows marked `:unmapped?`: shown across the hold, with no
|
||||
keys and no draggable edges, because a frozen clock gives its frames no place
|
||||
on this ruler. `docs/lane-nesting-notes.md` has the reasoning and what is
|
||||
still missing.
|
||||
|
||||
Double-clicking a clip opens the symbol it places as a tab, the same as
|
||||
double-clicking that symbol in the pool. Shift while dragging a clip body
|
||||
turns the temporal move into a structural one — see the nesting notes for why
|
||||
that is mostly refused today.
|
||||
|
||||
## Current timeline interaction
|
||||
|
||||
- Creation follows the primary active row and the playhead; the complete rule is
|
||||
in `docs/creating-in.md`. A lane row creates a new cel and claims its interval,
|
||||
while selecting a cel creates inside the symbol that cel places.
|
||||
- Drawing with a lane row active creates a new one-frame drawing cel. Dropping a
|
||||
library symbol there creates a natural-duration playing cel. Explicit timeline
|
||||
drops use the row and frame under the pointer.
|
||||
- Dragging a clip body moves it. A linked audio node follows a picture move;
|
||||
moving or trimming the audio itself remains independent.
|
||||
- Dragging a right edge changes its endpoint. Growth consumes adjacent spans
|
||||
instead of overlapping them. Shift-drag inserts or removes lane time by moving
|
||||
every later clip by the same delta.
|
||||
- At a shared boundary, the left and right hit zones trim one side. The center
|
||||
is a rolling edit: right-edge resize followed by left-edge resize at one frame.
|
||||
- Split, trim-in, and trim-out are direct buttons and are disabled without an
|
||||
editable selected span. Movement is a mouse gesture, not a toolbar command.
|
||||
|
||||
## Next steps, in order
|
||||
|
||||
The implemented correction slice and its remaining UI limits are recorded in
|
||||
[Correction authoring](correction-authoring-plan.md).
|
||||
|
||||
1. **Slip source and retime.** Both have real design questions open and the doc
|
||||
says to refuse rather than approximate: retime needs a defined warp and
|
||||
interpolation behaviour, and is not moving keys whose numbers happen to fall
|
||||
inside a selection.
|
||||
2. **Deleting reused content.** Reference discovery exists (`node/sources`,
|
||||
`clip/places`, `clip/contains-symbol?`); the policy does not.
|
||||
3. **Displayed-range correction gestures.** The first correction panel asks for
|
||||
explicit owner frames. Dragging a range in a retimed/nested view still needs
|
||||
a proved mapping; do not make it snap through floors or loops.
|
||||
4. **Collaboration.** `lane-model.md` is explicit that one leaf per channel does
|
||||
NOT solve two people editing different keys of the same channel. No conflict
|
||||
policy exists for that.
|
||||
|
||||
## Mechanisms to reuse — these keep paying out
|
||||
|
||||
- **`:span` is in the node's OWN frames** and `:time` says where they land in
|
||||
the lane. Moving an edge of a cel is therefore one write to `:span`, with
|
||||
`:time` and `:playback` untouched. This is why split costs nothing, why the
|
||||
two halves of a split go on meaning what the one cel meant, why trimming the
|
||||
front of a playing insert starts it later into its animation instead of
|
||||
restarting it, and why extending a hold leaves lane keys alone. `lane/local`
|
||||
and `lane/edged` are the whole geometry; trim, split and blank are all it.
|
||||
- **`lane/finish`** is the one commit path: it validates, applies the shot-length
|
||||
policy, and returns the refusal. New commands go through it.
|
||||
- **`:required-frames` plus the retry event** is the pattern for "this needs a
|
||||
decision you have not made": the domain reports what it would need, the UI
|
||||
offers one button. `events/ui/lane-retry`.
|
||||
- **`lane/lane-frame`** converts a symbol frame to a lane frame, or returns nil
|
||||
through a stepped or looping lane where there is no single answer. Nil refuses;
|
||||
it never snaps.
|
||||
- **`channel/conflict-with`** is the rule for whether one offset fits a base,
|
||||
used by `conflicts` and regeneration. Validation additionally follows prior
|
||||
replacement layers, so it cannot approve a stack that throws when read.
|
||||
- **Generated sampling applies to the base, not the hand correction.** Picture
|
||||
rate and pose selection may choose an earlier generated frame; correction
|
||||
support and values still read the node's current authored frame.
|
||||
- **Correction commands live in `domain/correction.cljs`.** IDs come from the
|
||||
event caller; the pure command materializes default transform channels,
|
||||
appends one layer, and validates the complete document. The inspector authors
|
||||
rotation and position offsets in explicit owner frames. One Apply is one undo
|
||||
step. `channel/reconcile` is the shared ordered-stack compatibility rule used
|
||||
by validation, conflict reporting, and regeneration.
|
||||
- **Two test patterns worth copying.** `the-cursor-agrees-with-the-specification-in-any-frame-order`
|
||||
holds the optimized cursor to `value-at` in forward, backward and random order
|
||||
— add a case to it for any new channel shape. And `drawn` in `lane_test`
|
||||
samples every frame before and after an edit, which is how split and trim are
|
||||
proved to change nothing: state a claim as "the same picture" rather than as
|
||||
numbers computed by hand.
|
||||
|
||||
## Known gaps and traps
|
||||
|
||||
- **Audio is a clip in a lane too, and a lane holds one kind.** A sound placed
|
||||
from the pool lands in a lane and is moved and trimmed by the same commands
|
||||
as picture. The capability the earlier note asked for is the homogeneity
|
||||
rule rather than a field: `symbol/lane-problems` refuses a lane holding both
|
||||
kinds, and `lane/place-symbol` and `lane/adopt` refuse BEFORE claiming time,
|
||||
because placement claims time and would otherwise have deleted the sound to
|
||||
make room for the picture and left a valid document behind. Audio nested
|
||||
inside a placed symbol — a take's own sound — is still shown flattened by
|
||||
`nest/audio-tracks`; what is in a lane of the open symbol is drawn as a lane
|
||||
and not flattened twice.
|
||||
- **`:z` is required on cels and means nothing there.** A lane never has two
|
||||
cels on one frame, so draw order between them cannot matter. `node/problems`
|
||||
requires `:z` on every node uniformly, which is its own kind of simplicity —
|
||||
but the field is noise on a cel.
|
||||
- **`channel/offset-onto` throws** on a shape mismatch that no regeneration has
|
||||
recorded as a conflict. That is deliberate — a correction that silently does
|
||||
not take is the failure the design exists to prevent, and `channel/problems`
|
||||
catches the authored case — but it is a throw in the read path, so any new
|
||||
producer of layers must not create a mismatched one.
|
||||
- **`docs/timing-handoff.md` is a separate, unreconciled thread.** Performance-
|
||||
pose selection and plate drawings/tracing, instance-specific picture-rate
|
||||
requests, `pose/put-cut` addressing only `:main`. It predates the lane model
|
||||
and nobody has squared the two.
|
||||
- **Slip and retime are still absent.** The timeline action strip now applies
|
||||
split/trim uniformly to a selected root or lane clip, but source-time slip and
|
||||
retime still need their own proved semantics before they become controls.
|
||||
- **`shadow-cljs release app` clobbers the dev bundle.** Both builds write
|
||||
`../static/arthur/js`, which Django serves, and the optimized build does not
|
||||
export the `arthur` global — so after a release the browser tests fail with
|
||||
`ReferenceError: arthur is not defined`. Run `npx shadow-cljs compile app` to
|
||||
restore it. A running `watch app` does not notice; it rebuilds on the next
|
||||
source change.
|
||||
|
||||
## Running it
|
||||
|
||||
From `frontend/`:
|
||||
|
||||
npx shadow-cljs compile test && node out/node-tests.js # 469 tests, 9,592 assertions
|
||||
npx shadow-cljs compile app # the bundle Django serves
|
||||
npx shadow-cljs release app # then `compile app` again — see above
|
||||
|
||||
The browser tests need the Django dev server up (`mise exec -- python manage.py
|
||||
runserver 8778` from the repo root) and a compiled dev bundle:
|
||||
|
||||
node --experimental-websocket test/browser/lane.mjs # generic symbol-lane flow
|
||||
CHROME=/usr/bin/chromium node --experimental-websocket test/browser/take.mjs
|
||||
|
||||
`take.mjs` defaults to a macOS Chrome path, hence `CHROME=`. It writes a real
|
||||
project to the local server by design; `lane.mjs` never writes to the server.
|
||||
|
||||
From the repo root: `mise exec -- python manage.py test clips` — 56 tests.
|
||||
|
||||
Documents are schema 3. A version 2 document is not read and nothing converts
|
||||
one; there is no backward compatibility to preserve anywhere in this work.
|
||||
71
docs/lane-is-a-view-notes.md
Normal file
71
docs/lane-is-a-view-notes.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Implementation notes — "A lane is a view"
|
||||
|
||||
Running log for `docs/lane-is-a-view-plan.md`. `[ ]` not started, `[~]` in
|
||||
progress, `[x]` done with `npm test` green.
|
||||
|
||||
Baseline at `fb38990`: 475 tests, 9621 assertions, 0 failures.
|
||||
|
||||
## Order of work
|
||||
|
||||
The plan's seven steps, re-grouped — see *Deviation from the plan's order* below.
|
||||
|
||||
- [x] A. Domain: `symbol/children`, `symbol/lane?`, `symbol/overlaps`,
|
||||
`lane.cljs` → `span.cljs`, the overlap check in `span/finish`
|
||||
(plan steps 3, 4, and the domain half of 5)
|
||||
- [x] B. Events: re-base callers and remove lane-node-specific commands; keep
|
||||
explicit `::new-lane` and cross-lane adoption (plan step 5)
|
||||
- [x] C. UI: row per symbol, explicit lane creation, and drag handling
|
||||
(plan steps 1, 2, and the UI half of 5)
|
||||
- [x] D. Audio: delete `holds-other?`, the mixed-lane refusal, the `in-lane`
|
||||
filter in `sound-rows` (plan step 6)
|
||||
- [x] E. Tests: `domain/sequence_test`, `events/lane_test`, `browser/lane.mjs`
|
||||
- [x] F. Shift-to-reparent still works, untouched (plan step 7)
|
||||
|
||||
Not in this pass — see *Left for a second pass*: tearing out the held cel, the
|
||||
instance-playback control, drawing a loop's repeats, the audio period guard.
|
||||
|
||||
## Deviation from the plan's order
|
||||
|
||||
The plan's steps 1 and 2 are display work that keys off "a symbol's children",
|
||||
and step 3 is what MAKES the cels a symbol's children. Until then a cel's
|
||||
`:parent` is the lane node, so there is nothing for the display to read: step 2
|
||||
cannot draw "a symbol's children as blocks" while the children belong to a
|
||||
group. So the data model moves first (A) and the display follows (C). The
|
||||
content of each step is unchanged; only the order is.
|
||||
|
||||
The one thing this gives up is the plan's promise that every step leaves the
|
||||
editor usable — between A and C the timeline draws the new shape with the old
|
||||
code. `npm test` is green at each step either way.
|
||||
|
||||
## Decisions taken
|
||||
|
||||
1. **Lane mode is `:display :lane` on the SYMBOL** — plan's recommendation 2,
|
||||
and open question 1 answered "the symbol, not the instance". A symbol placed
|
||||
twice is drawn as a lane in both places. Added to `symbol/symbol-keys` and to
|
||||
`leaf/leaves`' `select-keys` so it saves like `:frames`.
|
||||
2. **A symbol's children are its parent-less nodes** that have a placed span.
|
||||
The plan's step 6 settles it: "an audio node is already a parent-less child
|
||||
of a symbol, which is exactly the new shape". Span-less nodes — a shape on
|
||||
screen for the whole shot — are not in the sequence and are skipped, which is
|
||||
also what stops the commands destructuring a nil span.
|
||||
3. **A symbol holds at most one sequence.** It follows from 1 and 2: the
|
||||
container is the symbol. Two lanes of picture is now two symbols placed in a
|
||||
third, which is what compositing already was.
|
||||
4. **The open symbol gets a row of its own in lane mode**, and only then. The
|
||||
blocks have to sit on a row and the open symbol had none; expanding it turns
|
||||
its children into ordinary rows. Not a row always, which would shift every
|
||||
row in the pane for no gain.
|
||||
5. **Open question 2** — a lane row's edge drag trims the PLACING INSTANCE's
|
||||
span, via `span/resize-out`, like the handle on every other row. Rippling
|
||||
the children is what the cel blocks' own edges already do, and giving one
|
||||
handle two meanings is what the plan refuses elsewhere.
|
||||
6. **Open question 3** — the lane work first, the held cel after. The plan says
|
||||
they are independent, and the held cel is joined to a loop control that does
|
||||
not exist yet; doing it second costs one more pass over `lane_test`'s
|
||||
fixtures and risks nothing.
|
||||
7. **Lane creation stays explicit.** A blank document and `new symbol` create
|
||||
ordinary symbols. The separate `new → lane` command creates and places a
|
||||
symbol with `:display :lane` in the effective creation target derived from
|
||||
selection and playhead; the new lane then becomes the primary selection.
|
||||
|
||||
## Notes
|
||||
301
docs/lane-is-a-view-plan.md
Normal file
301
docs/lane-is-a-view-plan.md
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
# A lane is a view
|
||||
|
||||
Plan, 2026-10-01, written at `2dc5735`. It undoes the lane model as a thing in
|
||||
the document and keeps what it was for. Build on what is there and tear out
|
||||
half of it.
|
||||
|
||||
## The decision
|
||||
|
||||
> A lane is a view over a symbol with sequential, non-overlapping children.
|
||||
|
||||
Nothing in the document is a lane. There is no lane type, no lane group, no
|
||||
`:layout :sequence`, no lane commands and no lane validation. The word
|
||||
survives in exactly two places: the UI, where a symbol can be DRAWN as a lane,
|
||||
and the drag handling that re-spans a symbol's children while it is being
|
||||
drawn that way.
|
||||
|
||||
The display model goes back to a row per symbol. A symbol in lane mode draws
|
||||
its children as blocks on its own single row; expanded, they are rows like
|
||||
anything else. Everything else is an ordinary row that expands into what it
|
||||
places.
|
||||
|
||||
## What a lane was, and what each part becomes
|
||||
|
||||
| was | becomes |
|
||||
| --- | --- |
|
||||
| a group node with `:layout :sequence` | nothing — the symbol is the container |
|
||||
| `node/lane?` | a view question: is this symbol drawn in lane mode |
|
||||
| `symbol/lane-clips nodes lane-id` | the children of a symbol, sorted by `node/placed-span` |
|
||||
| `symbol/lane-problems` | `symbol/overlaps`, a diagnostic the write path calls |
|
||||
| `lane/lane-frame` | `clip/source-time` — one clock instead of two |
|
||||
| `domain/lane.cljs` | re-based onto `domain/span.cljs`: re-spanning a symbol's children |
|
||||
| `clip/lane-node`, the born-with lane | gone; a symbol is born empty again |
|
||||
| `::ui/new-lane`, `::ui/adopt-in-lane`, lane renaming | gone, gone, and ordinary node renaming |
|
||||
|
||||
`lane.cljs`'s fourteen commands are not deleted — they are what "endpoint drag
|
||||
overlap handling" means, and they already do the right arithmetic. What
|
||||
changes is their subject: every one of them currently takes a host symbol AND
|
||||
a lane id and asks `lane-clips nodes lane-id`; each takes a symbol and asks
|
||||
for its children. `extend-hold`, `resize-out`, `resize-in`, `roll`, `blank`,
|
||||
`place-symbol`, `adopt`, `append-drawing`, `reuse-drawing`,
|
||||
`duplicate-drawing`, `overwrite-drawing`, `make-unique`. `span/finish` is
|
||||
already the one commit path and stays exactly as it is.
|
||||
|
||||
Put them in `span.cljs`, which already owns "one write to one node's span" and
|
||||
`finish`. The sequence operations are the same subject — re-spanning children
|
||||
— and keeping them apart was a consequence of lanes existing.
|
||||
|
||||
## Children in lane mode never overlap
|
||||
|
||||
This is an invariant, not a condition to check for and report. Placement
|
||||
claims time: anything placed, moved or grown over occupied time TRIMS the
|
||||
extents it lands on — trimming the incumbent, removing one wholly covered, or
|
||||
splitting one it lands inside — so the result has no overlap because the
|
||||
operation that could have made one did not. That is `blank` followed by a
|
||||
non-rippling placement, which is what `overwrite-drawing` already composes.
|
||||
|
||||
Enforced at the boundary, which already exists: `span/finish` is the single
|
||||
commit path for every one of these commands, it validates before it returns,
|
||||
and it refuses rather than half-applying. So `finish` gains the overlap check
|
||||
for a symbol in lane mode, and no command can commit one. An overlap that
|
||||
appears anyway is a bug in a command, not a state to design around.
|
||||
|
||||
Keep the check as a named diagnostic — `symbol/overlaps`, taking a symbol and
|
||||
returning the pairs — used three ways:
|
||||
|
||||
1. `span/finish` refuses when it would commit one.
|
||||
2. The test suite asserts no command can produce one: a property over the
|
||||
commands in the style of `drawn` in `lane_test`, which samples rather than
|
||||
computing expected numbers by hand.
|
||||
3. A document that somehow arrives holding one still LOADS — a display hint
|
||||
must never be able to stop a document loading — and the timeline draws it
|
||||
visibly wrong with the status line saying so. Not `clip/problems`, which
|
||||
means the document will not load, and not `clip/conflicts`, which means a
|
||||
person has a decision to make. This is neither: it is a bug report.
|
||||
|
||||
Toggling lane mode ON for a symbol whose children already overlap is the one
|
||||
place a person can ask for the impossible. Refuse it and say why, with the
|
||||
`:required-frames` retry pattern offering to trim them into a sequence — the
|
||||
domain reports what it would need, the UI offers one button.
|
||||
|
||||
Outside lane mode nothing is enforced, because overlapping children are what
|
||||
compositing IS. An endpoint drag there is an ordinary span edit that may
|
||||
overlap; the claim-time rule follows the mode.
|
||||
|
||||
## The two drag intentions
|
||||
|
||||
Unchanged from `docs/lane-nesting-notes.md`, and both kept:
|
||||
|
||||
- **Plain drag** of a clip body is temporal: it moves in time, within its
|
||||
symbol or into another symbol drawn as a lane, and it REPLACES — trimming,
|
||||
removing and splitting extents as needed so nothing overlaps.
|
||||
- **Shift-drag** is structural: the dragged node goes INSIDE the symbol the
|
||||
clip under the pointer places, through `nest/move-node`, which preserves the
|
||||
world transform and the root timing. This must keep working for symbols
|
||||
contained in a lane, which is the case it exists for.
|
||||
|
||||
Overlap cannot distinguish them — dropping on occupied time already means
|
||||
claiming it — so the modifier says which, and the label by the pointer says it
|
||||
back. `nest/move-refusal` already answers before the drop.
|
||||
|
||||
## Where lane mode lives
|
||||
|
||||
A symbol is drawn as a lane because somebody said so, not because of what its
|
||||
children happen to look like at this moment. Deriving it from "the children do
|
||||
not currently overlap" means a symbol stops being a lane the moment anything
|
||||
overlaps, and the rules that maintain non-overlap switch off exactly when they
|
||||
are needed.
|
||||
|
||||
Two options:
|
||||
|
||||
1. **Editor state**, `[:ui :lane-mode #{sid}]`. Purest reading of "a lane is a
|
||||
view". But the drag rules follow the mode, so an unsaved, per-person toggle
|
||||
would decide whether dropping a symbol trims its neighbour or composites
|
||||
over it — the same gesture doing two different things to the document
|
||||
depending on something the document does not record.
|
||||
2. **A display hint on the symbol**, e.g. `:display :lane`, saved like any
|
||||
other field (`clip-keys`, `leaf/leaves`, `leaf/clip` in the same commit).
|
||||
Still not a type: nothing in evaluation reads it, `symbol/problems` does
|
||||
not check it, and a symbol with it set behaves identically on the stage.
|
||||
|
||||
Recommended: 2. It is one field, it keeps editing rules reproducible between
|
||||
people, and it does not make the symbol a different kind of thing. The thing
|
||||
to hold the line on is that nothing outside the timeline, and the commit
|
||||
path's overlap check, is allowed to read it.
|
||||
|
||||
## Two things are called loop
|
||||
|
||||
Before any of this, name them apart, in the way the vocabulary table in
|
||||
`lane-handoff.md` names a cel apart from an exposure.
|
||||
|
||||
- **Loop playback** is the transport repeating the open symbol while it plays.
|
||||
It is `[:playback :loop?]` in app-db, the ⟳ button in the strip, and it is
|
||||
EDITOR STATE. The document does not know about it.
|
||||
- **A looping instance** is a node repeating the symbol it places: a four-frame
|
||||
tire turning for the hundred and twenty frames the instance is on screen.
|
||||
It is `:playback {:end :loop}` on the node, and it is in the DOCUMENT.
|
||||
|
||||
The car tire is the second one, and here is its actual status: it already
|
||||
works in the evaluator and cannot be asked for. `node/placed-frame` does the
|
||||
modulo, `node/problems` already admits `:end` of `:stop`, `:hold` or `:loop`,
|
||||
`nest/audio-tracks` already expands a loop into its periods — and nothing in
|
||||
the UI sets it. It is implemented and unreachable.
|
||||
|
||||
So three things are missing, and they are the work:
|
||||
|
||||
1. **A control.** Where an instance's playback is edited: `:in`, `:speed`, and
|
||||
what happens at the end. One place, three fields, rather than a loop
|
||||
checkbox somewhere else.
|
||||
2. **Drawing the repeats.** `ui/timeline`'s docstring already admits that only
|
||||
the first pass of a looping instance is drawn, so a tire turning thirty
|
||||
times shows one turn's keys and then nothing. A looping block should show
|
||||
its passes — at minimum the period boundaries, so the row says how many
|
||||
times round it goes.
|
||||
3. **The audio period guard** below, which a loop needs whether or not holds
|
||||
become loops.
|
||||
|
||||
## Tear out the held cel
|
||||
|
||||
A held cel is a 1-frame symbol shown for many frames. A 1-frame symbol with
|
||||
`:end :loop`, lengthened, is the same picture by a different route — and the
|
||||
second route is a case the model already has, so keeping the first one is
|
||||
keeping a special case for free.
|
||||
|
||||
**What is already true**, so that nothing has to move: `:span` is on the node,
|
||||
in its own frames; `:time` (`:at`, `:rate`) is on the node; looping is on the
|
||||
node, as `:playback :end`. The SYMBOL owns only `:frames`, the authored
|
||||
window. So looping and span are instance properties already, and this change
|
||||
is about deleting a mode, not relocating a field.
|
||||
|
||||
It does mean the two changes are joined at one point: making every drawing a
|
||||
looping instance is not safe until a looping instance can be seen and edited,
|
||||
or every drawing in the document acquires a property with no control on it.
|
||||
|
||||
**What has to be decided and collapsed:**
|
||||
|
||||
- There are two spellings of looping — `:time :loop?` and `:playback :end
|
||||
:loop` — and both are read, in `node/placed-frame` and in
|
||||
`nest/audio-tracks`. Keep one. `:playback {:in :speed :end}` already says
|
||||
what happens at the ends, so `:end :loop` is the one to keep and
|
||||
`:time :loop?` is the one to delete.
|
||||
- `:playback :speed 0` stops being produced. Make it illegal in
|
||||
`node/problems` rather than legal-but-unused, so a frozen clock has exactly
|
||||
one spelling: a 1-frame loop.
|
||||
- `lane/extend-hold` exists only because holds were special — it refuses
|
||||
anything whose speed is not 0 and then edits a span. Once a hold is a loop,
|
||||
lengthening one IS `resize-out`, and the command collapses into it.
|
||||
- `cel` survives as the word for the creation policy — a new empty symbol is
|
||||
one frame — but stops naming a playback mode.
|
||||
|
||||
**What it buys, and this is the point:** one rule for nesting, which settles
|
||||
the refusal that blocks shift-to-reparent today. `nest/inside` currently has
|
||||
no `:time` for a hold, for `:end :hold`, or for a loop, and so refuses all
|
||||
three. The general rule that covers all of them: **resolve the move with the
|
||||
destination's map at the CURRENT frame — the affine piece the current frame
|
||||
falls in.**
|
||||
|
||||
- A loop of length L is affine within the period the current frame is in.
|
||||
Timing is preserved inside that period and repeats after it, which is what
|
||||
looping means.
|
||||
- A 1-frame loop — the ex-hold — has a period of length 1, so the map within
|
||||
it is trivially invertible and lands the moved node on frame 0, aligned to
|
||||
the current frame. That is exactly the answer `docs/lane-nesting-notes.md`
|
||||
argues for from first principles, arrived at here as an instance of the
|
||||
general rule instead of a special case.
|
||||
- `:end :hold` is affine in the played part and frozen in the tail, which the
|
||||
same sentence covers.
|
||||
|
||||
**The trap, which must be handled in the same change.** `nest/audio-tracks`
|
||||
expands a loop into one walk PER PERIOD:
|
||||
|
||||
periods (range (floor (/ (to-local source lo) length))
|
||||
(ceil (/ (to-local source hi) length)))
|
||||
|
||||
Today a held cel is skipped entirely — `(pos? speed)` is the guard, and the
|
||||
comment says a visual freeze does not emit a sustained audio sample. Turn
|
||||
every drawing into a 1-frame loop and that guard stops firing: a drawing held
|
||||
for 120 frames becomes 120 recursive walks, and any sound inside it is emitted
|
||||
120 times. That is both a wrong mix and a performance cliff on the most common
|
||||
node in the document. Required with this change: a cheap `symbol/audible?`
|
||||
precheck so a source with no audio anywhere inside it is never period-expanded,
|
||||
and a cap or a different formulation for the ones that are.
|
||||
|
||||
Also worth knowing before the change: `ui/timeline`'s own docstring already
|
||||
says a looping instance draws only its first pass. With every drawing a loop,
|
||||
that sentence now describes every drawing — harmless, since one pass of a
|
||||
1-frame symbol is the whole of it, but the docstring should stop sounding like
|
||||
a limitation.
|
||||
|
||||
**Dropping into a 1-frame symbol.** The window is authored and crops what it
|
||||
holds, so a 10-frame symbol dropped into a 1-frame drawing shows its frame 0
|
||||
and nothing else. That is consistent — `:frames` is the shot length and
|
||||
`:extent :grow-symbol` is the opt-in — but it is probably not what somebody
|
||||
dragging means. Offer the growth through the `:required-frames` retry the
|
||||
model already uses: the command reports what it would need, the UI offers one
|
||||
button.
|
||||
|
||||
## Order of work
|
||||
|
||||
Each step compiles, passes `npm test`, and leaves the editor usable.
|
||||
|
||||
1. **Row per symbol.** In `ui/timeline.cljs`, delete `portal`, the `::portal`
|
||||
hint row, the `under?` lineage predicate and the `chosen` argument; emit a
|
||||
row for a clip whose parent is a lane instead of skipping it. Keep the
|
||||
`:cels` blocks for the collapsed row, and keep `inside-rows` — including
|
||||
its `:unmapped?` branch, which is what makes a held drawing's contents
|
||||
reachable at all.
|
||||
2. **Lane mode as a hint.** Add the field and the toggle, draw a symbol's
|
||||
children as blocks when it is set and as rows when it is not, and move the
|
||||
lane-row drag handling onto it. Both display paths now exist and nothing in
|
||||
the domain has changed.
|
||||
3. **Re-base the commands.** Move `lane.cljs` into `span.cljs`, replacing
|
||||
`(lane-clips nodes lane-id)` with the symbol's children and dropping the
|
||||
`lane-id` argument. `frontend/test/arthur/domain/lane_test.cljs` is the
|
||||
proof: its fixtures should change and its assertions should not, and any
|
||||
assertion that has to change is a behaviour change worth noticing.
|
||||
4. **Move the invariant.** `symbol/lane-problems` becomes `symbol/overlaps`,
|
||||
called by `span/finish` for a symbol in lane mode, plus the property test
|
||||
that no command can produce an overlap.
|
||||
5. **Delete the rest.** `node/lane?`, `symbol/lane-clips`, `clip/lane-node`,
|
||||
`::ui/new-lane`, `::ui/adopt-in-lane`, the lane branch of
|
||||
`::ui/new-symbol`, lane renaming, `aimed-lane`, and the
|
||||
`:lane?`/`sound-lane?` row flags. Rename what is left so the word does not
|
||||
appear outside the timeline.
|
||||
6. **Audio falls out.** An audio node is already a parent-less child of a
|
||||
symbol, which is exactly the new shape — so the audio-in-lane rules added
|
||||
in `2dc5735` (`holds-other?`, the mixed-lane refusal, the `in-lane` filter
|
||||
in `sound-rows`) delete rather than migrate. A symbol drawn as a lane whose
|
||||
children are sounds is an audio lane, and that is the whole of it.
|
||||
7. **Shift-to-reparent stays** as it is: `nest/move-node` and
|
||||
`nest/move-refusal` never knew about lanes.
|
||||
|
||||
## What must not be lost
|
||||
|
||||
All of this was broken at some point today and is now proved; each has a test
|
||||
to keep.
|
||||
|
||||
- A held clip's contents are reachable from the root timeline, with no keys
|
||||
and no draggable edges — `source-time` is nil for a hold, and the walk used
|
||||
to stop there.
|
||||
- Double-clicking a clip opens its symbol as a tab, and the editor survives
|
||||
it: `symbol/lineage` must not report a cycle for an id the symbol does not
|
||||
hold, and opening a symbol must drop a selection pointing into the one being
|
||||
left.
|
||||
- Selection waits for pointer-up, so a press does not re-draw the timeline out
|
||||
from under the gesture it is starting.
|
||||
- A drop never silently deletes what it lands on.
|
||||
- A sound is drawn once, not twice.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Is lane mode a property of the symbol or of the instance placing it?** A
|
||||
symbol placed twice would be drawn the same way in both places under the
|
||||
first reading. That is probably right, and worth saying out loud.
|
||||
2. **Does a lane row's edge drag trim the placing instance's span, or ripple
|
||||
the children?** Same handle, two commands; the row is now an instance, so
|
||||
it has a span of its own for the first time.
|
||||
3. **Does tearing out the held cel come before or after the lane work?** It
|
||||
is independent of it — `nest` never knew about lanes — and it is what makes
|
||||
shift-to-reparent work on the thing people would actually drag onto. Doing
|
||||
it first means the lane work lands on a model with one playback mode fewer;
|
||||
doing it after means two changes to `lane_test`'s fixtures instead of one.
|
||||
591
docs/lane-model.md
Normal file
591
docs/lane-model.md
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
# The Lane Model
|
||||
|
||||
Revised 2026-10-01. Clip ownership, source playback, one-row generic lanes,
|
||||
direct clip movement and edge editing, correction evaluation, and correction
|
||||
authoring for rotation and position are implemented. The former cel-sheet
|
||||
projection was removed: the timeline is the single timing interface. Sections
|
||||
below that describe a cel sheet are retained as design history and are superseded
|
||||
by this revision. Retiming commands are not.
|
||||
See the status note under
|
||||
[Proof obligations](#proof-obligations-and-implementation-order).
|
||||
|
||||
[Lane and cel handoff](lane-handoff.md) records what is built, the decisions
|
||||
that are settled, and what to do next.
|
||||
|
||||
This revises the Claude artifact [The Lane Model](https://claude.ai/code/artifact/cd42981d-ed08-493f-94df-b7dd6657f0e6).
|
||||
Its prose and diagram source were recovered from session
|
||||
`1c603f71-84eb-498e-aeaf-4c0346f1f513`; the live artifact was not accessible for
|
||||
reading or editing here. This repository document is the revised design. The
|
||||
original artifact has not been updated, and edits made there outside the recorded
|
||||
session may not be represented here.
|
||||
|
||||
For the subjects covered here, this document supersedes the original artifact
|
||||
and conflicting proposals in `animation-model.md`, `timing-model.md`, and
|
||||
`architecture.md`. Those documents retain useful detail about the existing system.
|
||||
|
||||
## Goal and compatibility policy
|
||||
|
||||
Arthur is one animation document with several ways to see and edit it: drawing
|
||||
on the stage, arranging clips, timing cels, editing curves, and generating
|
||||
motion from footage. Each view exposes relevant facts and invokes shared editing
|
||||
operations. Switching views must preserve the meaning of the work.
|
||||
|
||||
The user explicitly requires no backward compatibility. Replace obsolete shapes,
|
||||
APIs, and tests when a better model requires it. Do not retain compatibility
|
||||
branches, adapters, or migrations solely to preserve the current document format.
|
||||
A format marker can reject unsupported files clearly; it does not promise to
|
||||
convert them. This policy does not authorize deleting existing user assets.
|
||||
|
||||
Simplicity means predictable composition, clear ownership, and few independent
|
||||
rules. Minimizing field count is secondary to representing independent choices.
|
||||
|
||||
## What stays
|
||||
|
||||
- A symbol is the one container for authored scene nodes. A drawing can be a
|
||||
one-frame symbol; an animation uses the same container over more frames.
|
||||
- Nodes have stable identities and flat parent references. Shared content is
|
||||
referenced rather than copied implicitly.
|
||||
- Animatable properties are addressed by channel paths. Generated and authored
|
||||
values participate in the same evaluation machinery.
|
||||
- Authored data, generated blocks, and source media remain separate. Documents
|
||||
reference immutable blocks; caches and resolver indexes remain derived.
|
||||
- A pure reference evaluator specifies the result. Playback, seeking, preview,
|
||||
export, and optimized cursors must agree with it.
|
||||
- Existence, visibility, and missing measured data remain distinct facts.
|
||||
|
||||
## Content, cels, lanes, and rows
|
||||
|
||||
These have different identities and responsibilities:
|
||||
|
||||
| Concept | Owns | Example |
|
||||
| --- | --- | --- |
|
||||
| Content | Reusable nodes and their animation | Drawing `a2`, an animated head, or a sound asset |
|
||||
| Cel | One use of content, its interval, source playback, and local treatment | `a2` exposed on frames 12–16 |
|
||||
| Lane | A sequence of cels and shared properties | The girl's drawings and the girl's overall transform |
|
||||
| View row or column | Presentation and editor state | Timeline row, cel-sheet column, or property curve |
|
||||
|
||||
Use the existing instance/node identity mechanism for cels. A cel
|
||||
should not acquire a second identity system just because it is shown as a cel.
|
||||
Lanes group cels; they do not introduce another node-holding content type.
|
||||
The concrete candidate below uses existing group and instance nodes; its ownership
|
||||
boundaries are part of the design. It is now the implemented shape, and the field
|
||||
spellings below are the ones the runtime reads.
|
||||
|
||||
Each cel has a stable ID. Moving it, changing its hold, swapping its source,
|
||||
or trimming it preserves that ID. Repeating it creates a new cel that may
|
||||
reference the same content. A split retains the original ID on the left and gives
|
||||
the right piece a new ID; commands return the resulting selection explicitly.
|
||||
|
||||
Cels are the canonical authored arrangement. A source-at-time channel or
|
||||
interval index may be compiled from them for evaluation, but is not a second
|
||||
editable copy of the schedule. This replaces the earlier proposal that every cel
|
||||
must be represented solely as a source key. Ordinary property animation still
|
||||
uses lightweight keys; it does not need cel objects.
|
||||
|
||||
A lane has non-overlapping half-open cel intervals `[start,end)`
|
||||
in its own time space. Uncovered intervals are gaps. Empty lanes are valid.
|
||||
Compositing and simultaneous sounds are represented by multiple lanes or ordinary
|
||||
scene composition; an accidental overlap never silently selects a winner.
|
||||
Transitions, if added, need explicit overlap and mixing semantics.
|
||||
|
||||
Properties can belong to content, one cel, or the lane. For example:
|
||||
|
||||
- Rotate the reusable drawing: all its uses change.
|
||||
- Rotate one cel: only that cel changes.
|
||||
- Animate the lane's rotation: whichever drawing is showing follows it.
|
||||
|
||||
A cel can have its own transform, gain, corrections, and source timing
|
||||
while remaining a block in the same timeline row. Independent treatment never
|
||||
requires a new row or an otherwise unnecessary wrapper symbol.
|
||||
|
||||
### Concrete candidate: a lane and ordinary instances
|
||||
|
||||
A lane is a group node with `:layout :sequence`. Its cels are ordinary
|
||||
instance nodes whose `:parent` points to the group. All remain in their symbol's
|
||||
flat node map. The sequence constraint is document semantics; which rows the UI
|
||||
expands remains editor state. Ordinary groups retain unconstrained composition.
|
||||
|
||||
This example is a document the runtime accepts, built and evaluated by
|
||||
`frontend/test/arthur/domain/lane_test.cljs`. Times here are zero-based. Channels
|
||||
use the existing representation; cel source references and playback have
|
||||
replaced the `[:source]` channel, which no longer exists.
|
||||
|
||||
```clojure
|
||||
;; Within :main's :nodes; referenced drawings/animations live in :symbols.
|
||||
{:girl
|
||||
{:id :girl :kind :group :layout :sequence :z "b"
|
||||
:channels {[:xform :rot]
|
||||
{:animated? true :interp :linear :keys {0 0, 6 30, 12 0}}}}
|
||||
|
||||
:cel-a
|
||||
{:id :cel-a :kind :instance :parent :girl :z "a"
|
||||
:time {:at 0 :rate 1} :span [0 4]
|
||||
:source {:symbol :drawing-a}
|
||||
:playback {:in 0 :speed 0 :end :stop}}
|
||||
|
||||
:cel-b
|
||||
{:id :cel-b :kind :instance :parent :girl :z "b"
|
||||
:time {:at 4 :rate 1} :span [0 4]
|
||||
:source {:symbol :drawing-b}
|
||||
:playback {:in 0 :speed 0 :end :stop}
|
||||
:channels {[:xform :pos]
|
||||
{:animated? true :interp :hold :keys {0 [0 0], 1 [2 0]}}}}
|
||||
|
||||
:animated-insert
|
||||
{:id :animated-insert :kind :instance :parent :girl :z "c"
|
||||
:time {:at 8 :rate 1} :span [0 4]
|
||||
:source {:symbol :wave}
|
||||
:playback {:in 3 :speed 1 :end :stop}}}
|
||||
```
|
||||
|
||||
The lane's rotation reads lane time. Each cel's channels read cel
|
||||
time. Its content reads source time. The stills sample frame 0, while the insert
|
||||
samples source frames 3, 4, 5, and 6. The group transform composes with the
|
||||
cel transform and then the content's own transform.
|
||||
|
||||
`:span` remains in the node's own coordinates, consistent with ordinary nodes.
|
||||
The interval in lane time is derived through `:time`; do not also store parent
|
||||
start/end values. Sequence children require finite intervals and positive
|
||||
placement rates. Ordering and overlap checks use the mapped intervals, not `:z`.
|
||||
The current sequence group contains visual symbol clips. Audio remains an
|
||||
independent root node (and can be linked to picture); if audio lanes are added,
|
||||
their capability must be explicit rather than inferred per frame.
|
||||
|
||||
A source reference is fixed within a cel. The lane changes content when
|
||||
another cel becomes active. This is a deliberate revision of the original
|
||||
diagnosis that making `:of` a channel was necessary to avoid vertical growth:
|
||||
multiple instances can occupy one row when the view presents their containing
|
||||
sequence. A lane-level source schedule is therefore derived, not authored twice.
|
||||
|
||||
## Source selection and source playback are independent
|
||||
|
||||
The current implementation makes framed sources play and keyed sources hold.
|
||||
Retire that rule. Channel storage shape must not determine playback behavior.
|
||||
Adding or removing a key must not turn a still into an animation or vice versa.
|
||||
|
||||
A cel names content and describes how its source time is sampled. In the
|
||||
basic case, after mapping lane time into cel time:
|
||||
|
||||
```text
|
||||
source_time = in_point + speed × cel_time
|
||||
```
|
||||
|
||||
A newly created cel starts at local time zero. Moving it preserves this
|
||||
origin relative to its content. Trimming can narrow its local support without
|
||||
resetting that origin; split pieces likewise preserve the source and property
|
||||
values at the cut. Trimming, slipping, and retiming are distinct operations with
|
||||
explicitly different effects on the interval and the source map.
|
||||
|
||||
| Intent | Source playback |
|
||||
| --- | --- |
|
||||
| Hold a drawing | Constant source frame, equivalently speed 0 |
|
||||
| Play an animated symbol | Advancing source time, normally speed 1 |
|
||||
| Cut between animations | Several cels, each with its own in-point and speed |
|
||||
| Mix stills and animation in a lane | Constant and advancing maps in the same sequence |
|
||||
|
||||
The source reference itself is discrete and never numerically interpolated.
|
||||
Interpolation belongs to properties that support it; a property registry should
|
||||
declare value types, defaults, and permitted interpolation and correction modes.
|
||||
Generic key toggles must consult those capabilities rather than assume every
|
||||
non-boolean value can be tweened.
|
||||
|
||||
Define source bounds and end behavior explicitly: stop contributing outside the
|
||||
source, hold an endpoint, or loop an explicit range. A still uses a valid constant
|
||||
frame. A loop uses a nonempty half-open range and a defined modulo rule. Playback
|
||||
never guesses these policies from whether a channel happens to have keys.
|
||||
|
||||
Audio shares cel arrangement, trimming, gain ownership, and clock mapping.
|
||||
It does not inherit visual frame-hold semantics: holding one audio sample is not
|
||||
an audio freeze effect. Validate supported playback policies by media capability.
|
||||
Actual audio scheduling must follow active cels, including gaps and cuts,
|
||||
rather than playing every sound reachable through a structural reference.
|
||||
|
||||
## Time spaces and sampling
|
||||
|
||||
Name the relevant space whenever an API accepts a time or range: project,
|
||||
symbol/lane, cel, or source. Store authored frame coordinates exactly;
|
||||
avoid cumulative rounding when moving through nested mappings. Quantize at a
|
||||
declared sampling boundary, not at every traversal step. Audio also needs its
|
||||
continuous clock/sample space rather than visual frame quantization.
|
||||
|
||||
A hold is an evaluable time map with no unique inverse. A loop can map many
|
||||
displayed cels to one source time. APIs must distinguish forward sampling
|
||||
from inverse editing, and expose enough context to resolve a cel or
|
||||
explicitly refuse an ambiguous operation. Do not report a missing time map merely
|
||||
because inversion is unavailable.
|
||||
|
||||
Separate invertible placement timing from source sampling. A zero source speed
|
||||
can mean hold without making the cel's own edit clock non-invertible.
|
||||
Reparenting through changing transforms or non-invertible timing must either
|
||||
preserve the full result by an explicit bake or return a reason it cannot; a
|
||||
matrix captured at one frame does not prove preservation across the animation.
|
||||
|
||||
The source's frame step, generated-pose sampling, and the lane's transform clock
|
||||
are independent scopes. Drawing on twos must not accidentally step a smooth lane
|
||||
transform. An explicit whole-subtree stepping operation can exist separately.
|
||||
|
||||
Share the quantization primitive where possible, but retain its units, phase,
|
||||
rounding policy, and order relative to retiming and lead. The original suggestion
|
||||
that cel and picture-rate sampling are simply one floor is insufficient:
|
||||
noninteger grids and source-frame quantization require specified behavior.
|
||||
Identity timing can be implicit; remove `:time :mode` if it only duplicates that.
|
||||
|
||||
A cel interval is authored. Lane content extent is derived from its
|
||||
cels, including the explicit end of the last one. A separately authored
|
||||
container trim/window is legitimate when it intentionally gates children. Do not
|
||||
conflate that window with occupied extent or infer a final hold from the next key
|
||||
when no next key exists. A range of frame numbers alone cannot encode visibility
|
||||
or a missing measurement.
|
||||
|
||||
## Shared editing operations
|
||||
|
||||
Every view issues the same domain commands. A command accepts an explicit target
|
||||
and edit policy, computes a valid change, and returns the change, resulting
|
||||
selection, and any refusal reason. A button and a drag must not implement two
|
||||
versions of cel extension.
|
||||
|
||||
An edit target identifies the symbol, cel path, selected entities or
|
||||
properties, and the time range with its space. Navigation also distinguishes
|
||||
editing shared content directly from editing it through a particular cel.
|
||||
Crossing a source cut must not silently redirect an active drawing edit to a
|
||||
different symbol: retain the explicit content target until navigation changes it.
|
||||
|
||||
Core commands include new drawing, reuse drawing, duplicate drawing, make unique,
|
||||
blank range, split, trim, move, extend cel, slip source, retime, and apply a
|
||||
bounded property edit. Ripple/overwrite policy and the set of affected lanes are
|
||||
explicit command arguments. Preview consequences before committing a gesture.
|
||||
|
||||
New drawing creates fresh empty content and a cel. Blank range removes
|
||||
content coverage without inventing a hidden drawing. These are different actions.
|
||||
Reuse creates another cel pointing at existing content. Duplicate creates
|
||||
a new content identity. Make unique rebinds the selected cel only.
|
||||
|
||||
Copy semantics must specify nested sharing. A normal content copy duplicates its
|
||||
owned nodes and channels while preserving references to other reusable symbols.
|
||||
For a fully independent drawing assembled from nested symbols, provide an
|
||||
explicit deep-copy operation with ID remapping. Never promise decoupling while
|
||||
leaving the relevant edited object shared. Immutable media blocks may remain shared.
|
||||
|
||||
Commands are atomic undo transactions, even when they touch several leaves.
|
||||
Pointer movement and keyboard invocation use explicit begin/preview/commit or
|
||||
cancel boundaries; a timing heuristic alone must not decide user intent.
|
||||
Collaboration applies a transaction consistently, validates affected references,
|
||||
and detects conflicts at the owned data being changed. One leaf per channel does
|
||||
not solve simultaneous edits to different keys of that same channel; define a
|
||||
conflict policy rather than claiming that granularity solves all collaboration.
|
||||
|
||||
### Default timing behavior: cel edits preserve lane keys
|
||||
|
||||
Working default from the follow-up discussion: extending a drawing's hold changes
|
||||
cel timing, leaving lane animation at its authored times. The user raised
|
||||
keeping keyframes in place as a possibility; this is the proposed predictable
|
||||
default, not a claim that they selected every timing policy below.
|
||||
|
||||
Ownership supplies the remaining rule: properties attached to a cel
|
||||
travel with it. Extending its end does not stretch those properties; moving it
|
||||
changes where their existing local times land. No per-key attachment flag is
|
||||
needed to recover ownership that the document already expresses.
|
||||
|
||||
For the concrete example, extend `:cel-a` by two lane frames with ripple:
|
||||
|
||||
| Fact | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Cel A's lane interval | `[0,4)` | `[0,6)` |
|
||||
| Cel B's lane interval | `[4,8)` | `[6,10)` |
|
||||
| Animated insert's lane interval | `[8,12)` | `[10,14)` |
|
||||
| Girl's rotation peak | Lane frame 6 | Lane frame 6 |
|
||||
| B's position change | B frame 1, lane frame 5 | B frame 1, lane frame 7 |
|
||||
| Insert's first source frame | Source frame 3 | Source frame 3 |
|
||||
|
||||
The rotation peak now coincides with a different point in the drawing sequence.
|
||||
That is the intended consequence of changing cels underneath timed motion.
|
||||
The position correction stays attached to drawing B's cel. Neither the
|
||||
background's keys nor audio on another lane moves.
|
||||
|
||||
The command contract for this edit names the symbol and cel, a delta in
|
||||
lane frames, `:ripple` behavior, and an explicit scope of cel timing. It
|
||||
extends A's local support by the delta converted through A's placement rate,
|
||||
and shifts subsequent cel placements by that delta in lane time. It does
|
||||
not modify any channel's key map, source in-point, or playback speed. Reject a
|
||||
nonpositive resulting duration. Validate and commit the entire change together.
|
||||
|
||||
The symbol's authored end is another explicit boundary: preview an overflow and
|
||||
offer to extend the symbol or cancel. A command can request that extension as
|
||||
part of its transaction; it must not silently truncate later cels or grow
|
||||
other uses of a shared symbol. In the example, a 12-frame symbol needs an explicit
|
||||
extension to 14 frames or the edit must be refused without partial changes.
|
||||
|
||||
Retime performance is a separate operation over explicitly selected cels
|
||||
and channels. It applies the same time transformation to their relevant clocks,
|
||||
keys, and correction supports. Stretching an interval requires a defined warp
|
||||
and interpolation behavior; it is not merely moving keys whose frame numbers
|
||||
happen to lie inside the selection. Until supported, refuse this operation
|
||||
rather than approximating it with a cel ripple.
|
||||
|
||||
The initial UI should default stage transforms to the lane when drawing in a cel
|
||||
workflow, so movement usually remains independent of cel timing. The
|
||||
inspector names the target: lane motion, this cel, or shared drawing.
|
||||
Changing that scope is explicit. It changes what the edit means, not just which
|
||||
panel happens to be open.
|
||||
|
||||
## Three-frame rotation and correction layers
|
||||
|
||||
A range says where an edit applies; it does not specify the motion. Offer distinct
|
||||
commands for a constant adjustment, a ramp, and a return-to-start motion. For UI
|
||||
frames 10–12, the internal range contains exactly three frame samples after
|
||||
conversion from the displayed numbering convention.
|
||||
|
||||
- Constant adjustment: the same offset throughout those three samples.
|
||||
- Ramp: interpolate from the specified start value to the target over the range.
|
||||
- Return motion: interpolate from the starting value to a peak and back.
|
||||
|
||||
For a return motion sampled on three frames, the values can be `0, angle, 0`.
|
||||
Outside the selected range, the underlying animation must evaluate exactly as it
|
||||
did before. A range-scoped correction layer expresses this directly; blindly
|
||||
inserting boundary keys can alter neighboring segments or destroy existing motion.
|
||||
|
||||
Implement corrections as an ordered stack over the base channel. Each correction
|
||||
has stable identity, explicit support interval, blend operation, and values in a
|
||||
named time space. Outside its support it is inactive. `replace` can supply a value
|
||||
over an absent base; `offset` cannot offset a nonexistent value. Blend capability
|
||||
depends on property type, and geometry corrections require compatible topology.
|
||||
|
||||
Regeneration replaces the generated base and preserves corrections. If changed
|
||||
topology or removed targets make a correction incompatible, report a resolvable
|
||||
conflict instead of silently dropping or misapplying it. Provenance explains
|
||||
where the base came from; explicit sampling policy determines its playback.
|
||||
|
||||
This is core to the workflow: generate motion, correct it by hand, adjust the
|
||||
generator, and keep the corrections. It should be proven before adding many views.
|
||||
|
||||
## Other unifications worth keeping
|
||||
|
||||
Pose choices, tracing-frame choices, and ordinary held values should share the
|
||||
channel evaluator and cursor infrastructure. Preserve their different ownership,
|
||||
fallback behavior, and sampling scope. A pose choice must address the relevant
|
||||
content/feature explicitly; switching to another symbol must not accidentally
|
||||
reuse a track just because both symbols contain a node with the same local name.
|
||||
|
||||
Keep the two animation idioms distinct: keyed geometry modifies one mark over
|
||||
time; drawing substitution selects content that may have different structure.
|
||||
Linear geometry interpolation requires compatible vertex correspondence, not
|
||||
merely two drawings that happen to look related.
|
||||
|
||||
Derived library grouping may collect drawings used by a single lane. This is a
|
||||
convenience, not ownership or deletion authority. Reference discovery for cycle
|
||||
validation, copying, and deletion examines all structural references, including
|
||||
currently inactive cels. Authored folders, favorites, and labels remain
|
||||
legitimate user data even when the UI could have suggested defaults.
|
||||
|
||||
## UX: location, selection, and controls
|
||||
|
||||
The breadcrumb sits above the timeline and states the editing location, shared
|
||||
content identity, and cel context when applicable. Show local time and
|
||||
its project context where a useful mapping exists. Holds and loops need an honest
|
||||
description instead of a fictitious unique global frame.
|
||||
|
||||
Creation follows the primary active row, resolved at the playhead as specified in
|
||||
[`creating-in.md`](creating-in.md). The wider selection set still names what copy,
|
||||
delete and transform affect; it is not a second list of creation destinations. A
|
||||
shared drawing indicates its reuse and offers Make this cel unique. Names help identify content;
|
||||
linked-use indicators must rely on IDs, because different drawings can share names.
|
||||
|
||||
| Surface | Primary scope and controls |
|
||||
| --- | --- |
|
||||
| Topbar | Project name, save/open/export, project rate and stage size |
|
||||
| Location bar | Breadcrumb, add lane/content, shared-content context |
|
||||
| Cel action strip | New drawing, duplicate drawing, hold longer/shorter, blank range |
|
||||
| Lane header | Lane selection, lock, mute/solo where applicable, onion settings, expansion |
|
||||
| Stage tools | Drawing and transform modes, active target and scope |
|
||||
| Inspector | Selected content/cel/lane properties and valid key controls |
|
||||
|
||||
Cel actions have visible contextual buttons, shortcuts, a context menu, and
|
||||
command-palette entries. These are different entrances to the same commands.
|
||||
Shortcut names from the original sketch (`N`, `D`, `H`, `B`, `K`) are provisional;
|
||||
their meanings must match the visible labels and avoid tool conflicts.
|
||||
|
||||
The inspector normally edits values and the timeline normally edits timing, but
|
||||
this is an organizational default. Numeric duration and in-point controls are
|
||||
useful inspector edits to the same domain facts. Do not ban a convenient control
|
||||
just to preserve a visual division.
|
||||
|
||||
Default nesting navigation enters content; expanding a lane reveals properties.
|
||||
Other views may show hierarchies differently without changing the document.
|
||||
Tabs can pin explicit locations. Zoom, expansion, onion preferences, and current
|
||||
selection are editor state rather than animation content. Persistent workspace
|
||||
preferences can be saved separately.
|
||||
|
||||
## A session, revised
|
||||
|
||||
1. In `main`, create a girl lane and a new drawing. Draw; use New drawing (`N`)
|
||||
to create the next one with the previous cel ghosted behind it.
|
||||
2. Use Duplicate drawing (`D`) when the current shapes are the starting point.
|
||||
Use Reuse drawing for a deliberately linked cel. The UI shows the
|
||||
difference before an edit can change other uses.
|
||||
3. Time the performance. Hold longer (`H`) extends the selected cel and
|
||||
ripples later cels in the explicitly targeted lane. A trim gesture
|
||||
can use overwrite instead. The preview shows which boundaries will move.
|
||||
4. Choose a two-frame default cel for newly created drawings, or run a
|
||||
separate Retime cels command on a selected range. This does not quantize
|
||||
lane transforms or silently retime already authored cels.
|
||||
5. Place the background in a lane below. Its source holds one frame throughout
|
||||
its cel. Key the lane's X position at the beginning and end and choose
|
||||
linear interpolation. The background slides while the girl's drawings cut.
|
||||
6. Select three frames on the girl's lane, choose Return motion, and rotate to
|
||||
the desired peak. A bounded rotation correction affects the girl across any
|
||||
drawing boundaries in that range. Existing motion survives outside it.
|
||||
7. Insert a playing animated symbol among the girl's held drawings. Set that
|
||||
cel's source playback to advance. No lane conversion is required.
|
||||
|
||||
The timeline shows named cel blocks with property marks and optional curve
|
||||
subrows. The cel sheet shows the same cels by frame and lane. The
|
||||
graph editor edits the same properties; the stage resolves the same document.
|
||||
Onion skin is configurable and counts neighboring cel events, skipping gaps
|
||||
by default; a long hold does not consume the budget. Repeated uses of the same
|
||||
drawing remain distinct events. Deduplicating identical ghosts is a display option.
|
||||
|
||||
## Proof obligations and implementation order
|
||||
|
||||
The source-channel prototype has been removed: a cel names one symbol
|
||||
and carries its own playback clock, and `node/problems` rejects the old
|
||||
`[:source]` channel. What a lane IS lives in `arthur.domain.symbol` beside the
|
||||
other rules about a node map; `arthur.domain.lane` holds the commands over
|
||||
one — add lane, place a drawing (new, reused or duplicated), make unique, split,
|
||||
trim, move, blank and extend hold. Each is one history step, and each refuses rather than
|
||||
half-applying. The timeline draws a lane's cels as cel blocks on the
|
||||
lane's own row, and offers Make unique only where the selected cel actually
|
||||
shares its drawing.
|
||||
|
||||
There is ONE placement function and a position argument, so appending is not a
|
||||
different operation from inserting: `:end` is a position like any other, the one
|
||||
where nothing has to move. Placing ripples — cels at or after the
|
||||
position move later by the new cel's duration — and `:keep` versus
|
||||
`:grow-symbol` still decides what happens at the shot's end. OVERWRITE is not a
|
||||
policy argument yet, deliberately: taking frames away from the cel
|
||||
already there is trimming, and until `trim` exists, placement that would need it
|
||||
refuses instead of approximating it. A position inside an existing cel
|
||||
refuses too, and names `split` — one command does not quietly perform two.
|
||||
|
||||
Splitting turned out to cost almost nothing, which is evidence for the
|
||||
representation rather than for the command. The two pieces keep ONE `:time` and
|
||||
differ only in `:span`, so the right piece's own frames carry on where the
|
||||
left's stopped and its source clock, keys and corrections go on meaning what
|
||||
they meant — a held drawing holds the same frame either side, a playing insert
|
||||
plays through the cut without a seam, and the test for it samples every frame
|
||||
before and after and asserts the picture is identical. That falls out of `:span`
|
||||
being in the node's own coordinates; it is not something split arranges.
|
||||
|
||||
Content copies are shallow by default and keep their references to other
|
||||
symbols; `:deep? true` is the explicit copy that shares nothing, so the promise
|
||||
of independence is only made where it is kept.
|
||||
|
||||
THE SHOT LENGTH IS AUTHORED, which is the decision the range commands forced.
|
||||
`:frames` is the symbol's window — how long the shot IS — and the occupied
|
||||
extent of its lanes is a different fact derived from the cels. A command
|
||||
grows the window only when the caller says `:grow-symbol`, and never shrinks it:
|
||||
blanking the end of a shot leaves a shot with empty frames at the end, because
|
||||
that is a true statement about what somebody authored, and deriving the window
|
||||
from the extent would make deleting the last drawing quietly shorten the film.
|
||||
`finish` keeps the two numbers apart by name now rather than by a `max` that
|
||||
read like an accident.
|
||||
|
||||
Trim NARROWS one edge and moves nothing else; lengthening is `extend-hold`,
|
||||
which carries the ripple and shot-length policies because it needs them.
|
||||
Move is one write to `:time :at` and REFUSES a destination that would overlap,
|
||||
because moving a drawing and re-timing the ones around it are different
|
||||
intentions — clear the room with `blank` or `trim` first, which is the
|
||||
composition. Blank leaves a gap and does not close it; a cel wholly inside
|
||||
the range goes, one overlapping an end is trimmed to it, and the one spanning
|
||||
the range is split. Their drawings stay in the library, since a lane does not
|
||||
own its content.
|
||||
|
||||
All three are the same geometry as `split`: a `:span` is in the cel's own
|
||||
frames, so moving an edge is one write and `:time` and `:playback` are never
|
||||
touched. That is why trimming the front of a playing insert starts it later into
|
||||
its animation instead of restarting it — the difference between trimming and
|
||||
slipping, and the reason they stay separate commands.
|
||||
|
||||
Correction layers EVALUATE. `channel/problems` used to refuse an `:over` stack
|
||||
and `value-at`/`cursor` used to throw on one; both now read it, and the
|
||||
agreement test that holds the optimized cursor to the specification covers
|
||||
stacked channels in forward, backward and random frame order. A layer's values
|
||||
are themselves a channel, so a constant adjustment, a ramp and a return motion
|
||||
are one mechanism; `:support` is half-open and a layer is inactive outside it;
|
||||
and a layer has no time space of its own, because the node its channel is on
|
||||
already has one. Nothing had to change in the codec — a channel is one leaf, so
|
||||
a correction persists inside it — and nothing had to change in validation
|
||||
plumbing, since `node/problems` already reports every channel's problems.
|
||||
|
||||
Both halves of ownership are under test at lane level: a three-frame correction
|
||||
on the girl's lane reaches across the drawing boundary beneath it and leaves
|
||||
every frame outside its support identical, and a correction owned by one
|
||||
cel travels with that cel when a hold before it grows.
|
||||
|
||||
Regeneration keeps them, which is the obligation the layer design exists to
|
||||
meet: `rebased` replaces a base and carries its corrections across, and a
|
||||
correction the new base no longer fits is MARKED rather than dropped or
|
||||
misapplied — `clip/conflicts` lists those for a view to offer, separately from
|
||||
`problems`, because a conflict is a decision nobody has made yet and not a
|
||||
document that will not load. Turning the mouth's `:verts` knob is a real
|
||||
topology change and is what the test uses. Two latent faults turned up there and
|
||||
are fixed: `regenerate-head` compared authored channels to measured ones
|
||||
directly, so the first correction on the head would have stopped it following
|
||||
re-measurement for good; and an incompatible offset threw in the read path,
|
||||
which would have taken the stage down on exactly the case the model says to
|
||||
report.
|
||||
|
||||
Overwrite is `blank` followed by non-rippling placement, composed inside one
|
||||
transaction; insertion keeps its ripple rule. Still unbuilt: slip source,
|
||||
retime, and deleting reused content. A lane cannot hold AUDIO cels — `lane-problems`
|
||||
requires visual ones, though this document says a lane may hold either and
|
||||
should reject only a mixture.
|
||||
|
||||
`domain/correction.cljs` now produces Constant adjustment, Ramp, and Return
|
||||
motion layers for rotation and position. The inspector exposes them on a selected
|
||||
lane or cel using an explicit range in that owner's frames; this deliberately
|
||||
leaves displayed-range dragging through nested or retimed owners for later. One
|
||||
Apply is one undo step. Conflicted layers are listed, can be removed, and can be
|
||||
retried when the complete ordered stack is compatible again. Validation,
|
||||
conflict reporting, and regeneration share that ordered-stack rule, including
|
||||
coverage by adjacent replacement layers. Slip source and retime are still not
|
||||
implemented; a refusal is the current behavior where the model demands an
|
||||
explicit choice nobody has made yet.
|
||||
The cel sheet is the same projected cels and selection addresses with its axes
|
||||
turned: frames down and lanes across, so commands selected there and in the
|
||||
timeline have identical targets; a gap selects its column's lane rather than
|
||||
retaining a stale selection from another column. The suite stands at 437 tests and 5,804
|
||||
assertions, with `frontend/test/browser/lane.mjs` driving the editor through
|
||||
create, hold, overflow, undo, reuse, make unique, duplicate, split, insert,
|
||||
trim, move, blank, correction authoring, and two-lane sheet targeting. Rewrite tests that encode superseded
|
||||
behavior rather than preserving behavior to keep them green.
|
||||
|
||||
Build small adversarial documents and test their domain operations before
|
||||
expanding the interface:
|
||||
|
||||
| Scenario | Required invariant |
|
||||
| --- | --- |
|
||||
| Same drawing exposed twice, then one made unique | Linked edits affect both before copying and only the selected content after |
|
||||
| Holds, playing inserts, nonzero in-points, and gaps on one lane | Source behavior is independent of property key count and channel encoding |
|
||||
| Adjacent cels, final hold, split, trim, ripple, and overwrite | Exact boundaries, stable IDs, deterministic collision handling |
|
||||
| Extend a hold under lane keys and cel-local corrections | Lane key times remain fixed; later cel corrections travel with their owners; source playback origins survive |
|
||||
| Ripple beyond the symbol end | Explicit extent policy; refusal leaves the document unchanged; resizing and retiming undo together |
|
||||
| Girl on twos over a moving background | Drawing cadence does not quantize either lane's continuous properties |
|
||||
| Three-frame correction crossing a drawing boundary | Exact support, same result outside it, one undo step |
|
||||
| Nested retiming, holds, loops, and fractional sampling | Explicit time spaces; ambiguous inverse edits cannot silently choose a target |
|
||||
| Audio inside changing source cels | Only active intervals sound, with correct trim and source timing |
|
||||
| Regenerate with corrections and a topology change | Compatible edits survive; incompatible ones produce actionable conflicts |
|
||||
| Reference cycles and deletion of reused content | Inactive references are validated too; no dangling references |
|
||||
| Save/load and command undo/redo | Identity, source maps, corrections, and evaluation round-trip |
|
||||
| Timeline and cel-sheet invocation of one command | Identical document changes and selection targets |
|
||||
| Random forward/backward seeks and export | Reference and optimized evaluation agree, including defaults and absence |
|
||||
| Concurrent commands on overlapping and disjoint targets | Transactions remain valid; conflicts are explicit and undo preserves others' work |
|
||||
|
||||
Implementation order: cel ownership and playback semantics; shared
|
||||
commands and validation; correction layers and time-addressing contracts; then
|
||||
breadcrumb, cel strip, and a cel-sheet projection. Use those two temporal
|
||||
views plus direct stage editing to prove the model before broadening the UI.
|
||||
|
||||
A new presentation should not require duplicate animation state. A genuinely new
|
||||
authoring capability may require new domain data. The model is successful when
|
||||
such additions have a clear owner and compose with existing operations, not when
|
||||
it can claim that no future feature will ever need another field.
|
||||
161
docs/lane-nesting-notes.md
Normal file
161
docs/lane-nesting-notes.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# Lane nesting interaction notes
|
||||
|
||||
Status: design note, 2026-10-01. This records the interaction before more lane
|
||||
UI is implemented.
|
||||
|
||||
## The capability that must not be lost
|
||||
|
||||
A lane owns temporal placement, but a symbol instance is still a doorway into
|
||||
another symbol. A drawing accidentally authored at the root must be movable into
|
||||
an instance in any lane, including another lane, without changing its visible
|
||||
position or timing.
|
||||
|
||||
That operation already exists as `nest/move-node`. It resolves the source and
|
||||
destination at the current root frame, transplants the node, and re-expresses
|
||||
its transform and time under the new parent. The lane UI must expose a target
|
||||
path for it; it must not replace it with a weaker `:parent` assignment.
|
||||
|
||||
There are therefore two different drag intentions:
|
||||
|
||||
1. **Temporal move:** drag a clip body onto lane space. It remains a clip in a
|
||||
lane, moves in time, and claims the destination interval by trimming/removing
|
||||
incumbents.
|
||||
2. **Structural move:** drag from the clip's grab affordance onto another symbol
|
||||
instance. The dragged node is transplanted into the target instance's source
|
||||
symbol with `nest/move-node`, preserving its world transform and root timing.
|
||||
|
||||
These cannot be inferred from overlap alone. Dropping clip A onto time occupied
|
||||
by clip B already means “A claims that time and trims B.” Structural nesting
|
||||
therefore needs an explicit grab affordance/mode. Its cursor is `grab` and
|
||||
`grabbing`; trim edges keep their resize cursors and the ordinary body keeps its
|
||||
timeline-move behavior.
|
||||
|
||||
Both visible clip blocks and an expanded symbol header are structural drop
|
||||
targets. This permits moving a root drawing directly into `symbol-3` even when
|
||||
its lane is collapsed.
|
||||
|
||||
## Compact expansion: one selected-clip portal
|
||||
|
||||
Expanding a lane must not restore row-per-clip vertical growth. Instead, an
|
||||
expanded lane reveals exactly one clip portal: the currently selected clip in
|
||||
that lane.
|
||||
|
||||
```text
|
||||
▾ foreground lane [symbol-1][symbol-2][symbol-3]
|
||||
▾ symbol-3 instance/source header and drop target
|
||||
▸ body lane nested rows, mapped to the root ruler
|
||||
▸ face lane
|
||||
position nested keyframes mapped to root time
|
||||
```
|
||||
|
||||
- Selecting another block in the same lane swaps the portal in place.
|
||||
- With no selected clip in that lane, expansion shows a compact “select a clip
|
||||
to inspect” row. It must not follow the playhead during playback; that would
|
||||
make the timeline restructure itself while playing.
|
||||
- The portal header represents the selected instance and is the structural drop
|
||||
target for moving root or sibling content into its source symbol.
|
||||
- Sub-expanding the portal uses the existing recursive symbol-row walk. Nested
|
||||
lanes and channels are mapped through the instance clock into the open/root
|
||||
ruler, as ordinary expanded instances already are.
|
||||
- The lane's own transform/channel rows remain available separately. They affect
|
||||
every clip in the lane and are not properties of the selected portal.
|
||||
|
||||
This keeps the cost of inspection constant: an expanded lane adds one selected
|
||||
symbol branch, not one branch for every temporal clip it contains.
|
||||
|
||||
## Keyframe visibility
|
||||
|
||||
Two levels should be visible without changing editors:
|
||||
|
||||
- The selected clip's instance-level keys (transform, visibility, corrections)
|
||||
appear as ticks inside that clip block on the lane row.
|
||||
- Expanding the lane opens the selected clip portal, where source-symbol and
|
||||
recursively nested keys appear on their own rows, mapped to root time.
|
||||
|
||||
Thus the collapsed lane answers “where does this clip change?” and the expanded
|
||||
portal answers “which property inside this symbol changes?” The second view is
|
||||
still the root timeline; entering the symbol is not required merely to see or
|
||||
edit its keys.
|
||||
|
||||
## Drag targets and feedback
|
||||
|
||||
- Grab onto lane background: move/adopt the instance into that lane.
|
||||
- Grab onto a symbol clip: structurally transplant into that clip's source
|
||||
symbol.
|
||||
- Grab onto the expanded portal header: the same structural transplant, with a
|
||||
larger and less ambiguous target.
|
||||
- Grab onto itself or one of its descendants: refuse before drop to prevent a
|
||||
symbol cycle.
|
||||
- A structural target receives an inset highlight and the preview stays in that
|
||||
target. A lane-time target receives the dashed temporal clip preview.
|
||||
- Successful structural drops expand the target lane and select the moved node
|
||||
beneath the target portal, so the result is immediately visible.
|
||||
|
||||
## Data model consequence
|
||||
|
||||
No lane-as-symbol type is required. The hierarchy remains:
|
||||
|
||||
```text
|
||||
symbol -> sequence lane -> instance clip -> source symbol -> its lanes/nodes
|
||||
```
|
||||
|
||||
Lane membership owns time partitioning. Symbol instances own composition
|
||||
nesting. The UI may present the selected instance below its lane, but that is a
|
||||
derived portal, not another ownership edge and not a duplicated node.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. ~~Render instance-level key ticks within lane clips.~~ Done: a clip's keys
|
||||
are on its block, drawn after the blocks so they land on the one they
|
||||
belong to.
|
||||
2. ~~Add selected-clip portal expansion to `timeline/rows`.~~ Done, with two
|
||||
additions the note did not anticipate:
|
||||
- The portal is chosen by the whole LINEAGE of the selection, not the
|
||||
selected id. Selecting a shape inside the clip, or the end of its span,
|
||||
is still working inside that clip, and matching the id alone closed the
|
||||
portal the moment anything under it was touched.
|
||||
- A HELD clip opens too. `clip/source-time` is nil for a hold, so the walk
|
||||
used to stop there and the inside of every drawing was unreachable from
|
||||
the root timeline. Its rows are now shown across the hold and marked
|
||||
`:unmapped?`: no keys, and no draggable edges, because no frame inside it
|
||||
has a place on this ruler.
|
||||
3. ~~Add the explicit structural affordance.~~ Done as SHIFT on a clip-body
|
||||
drag rather than a separate grab handle: shift turns a temporal move into a
|
||||
structural one, the target clip takes an inset highlight, and a label by the
|
||||
pointer says which of the two is about to happen.
|
||||
4. Route structural drops through `nest/move-node`. **Wired, and blocked in
|
||||
the domain.** The gesture asks `nest/move-refusal` on the way past, so the
|
||||
label says before the drop what the command would say after it. Two
|
||||
refusals stand in the way of ordinary use:
|
||||
- *both have to be on screen at this frame.* Inherent, and worth keeping:
|
||||
the move preserves the world transform and there is no common frame to
|
||||
preserve it at otherwise. It does mean nesting one clip into another in
|
||||
the SAME lane can never work — a lane never overlaps itself — so this is
|
||||
a between-lanes gesture with the playhead somewhere both are showing.
|
||||
- *a held or looping clip has no clock to move through.* `nest/inside`
|
||||
returns no `:time` for a hold, and a held one-frame drawing is the most
|
||||
common thing in a document, so today nesting into one is refused — which
|
||||
is most of what anybody would try.
|
||||
5. After the transplant, expand the destination portal and reveal/select the
|
||||
moved row. `::ui/move-node` already selects the moved node and opens the
|
||||
rows down to it; the portal follows from the lineage rule in 2.
|
||||
|
||||
## The held destination, unresolved
|
||||
|
||||
A held cel shows ONE source frame for its whole span, so there is no
|
||||
invertible map from the lane's frames to the drawing's and `move-node`
|
||||
refuses. But the refusal is stronger than the facts require. Inside a frozen
|
||||
destination only one frame is ever observed, so:
|
||||
|
||||
- the RATE of any map into it is unobservable — every rate shows frame `in`;
|
||||
- what IS observable is that the moved node should show, at that one frame,
|
||||
what it shows now at the current root frame.
|
||||
|
||||
That pins a unique sensible answer — rate 1, aligned so the current frame maps
|
||||
to the shown frame — and nothing else about the mapping can be seen. If that
|
||||
argument holds, it is a rule rather than a guess, and it is the difference
|
||||
between structural nesting working for drawings and not working at all. It
|
||||
needs its own proof: a drawing authored at the root, nested into a held cel in
|
||||
another lane, sampled before and after to show the same picture, in the style
|
||||
of `drawn` in `lane_test`.
|
||||
|
||||
|
|
@ -8,11 +8,13 @@ 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 :source {:symbol :face-1}
|
||||
:parent :face :z "a0"}
|
||||
:face-2 {:kind :instance :source {:symbol :face-2}
|
||||
:parent :face :z "a1"}}}
|
||||
:face-1 {:nodes {:head {...} :mouth {:parent :head ...} ...}}
|
||||
:face-2 {:nodes {:head {...} :mouth {:parent :head ...} ...}}}
|
||||
|
||||
|
|
|
|||
66
docs/one-grid-plan.md
Normal file
66
docs/one-grid-plan.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# The editing grid is the symbol's own frames
|
||||
|
||||
## What was wrong
|
||||
|
||||
Every number a person authors — a span, a `:time :at`, a key, a cut — is in the
|
||||
frame space of the symbol it lives in (`docs/time.md`, and that part is right).
|
||||
The timeline, though, drew its ruler in OUTPUT frames: `clip/output-frames`, the
|
||||
transport's length. In a 12fps project holding 30fps symbols those two spaces sit
|
||||
at a ratio of 2.5, so:
|
||||
|
||||
* a clip at symbol frame 31 was drawn at ruler frame 12.4 — **no clip edge landed
|
||||
on a frame mark**, because almost none of them can;
|
||||
* a gesture measured in ruler frames had to be multiplied into the symbol's
|
||||
frames and rounded (`nest/dragged`), so dragging one ruler frame moved the clip
|
||||
2 or 3 symbol frames — **0.8 or 1.2 ruler frames, never the 1 the pointer
|
||||
said**. That is the jumpiness;
|
||||
* the preview drew the gesture's own number of ruler frames while the commit
|
||||
wrote the rounded one, so **the ghost sat somewhere the settled clip did not**;
|
||||
* before the rounding was added, the fraction went into the document and every
|
||||
later edge edit on that clip was refused for ever (`span/*` refuses a
|
||||
fractional edge, as it should).
|
||||
|
||||
One cause, four symptoms. None of them is an edge case to patch.
|
||||
|
||||
## The model
|
||||
|
||||
1. **A symbol's own frames are the only coordinate anything authored lives in.**
|
||||
Unchanged.
|
||||
2. **The editor edits in the open symbol's frames.** The ruler, the marks, the
|
||||
playhead's position on it, every pointer→frame answer, every drop frame and
|
||||
every drag delta are the open symbol's frames. No multiplication anywhere in
|
||||
the gesture path, so no rounding and nothing fractional to refuse.
|
||||
3. **The output grid is playback's alone** — the clock, the audio mix, export,
|
||||
and the frame the stage draws. Exactly two pure functions cross between them
|
||||
and nothing else does:
|
||||
* `clip/shown-frame clip sid f` — which of `sid`'s frames output frame `f`
|
||||
shows (`cadence/frame`: the latest at or before it).
|
||||
* `clip/first-output-frame clip sid n` — the output frame that first shows
|
||||
symbol frame `n`; the inverse, for seeking from the ruler.
|
||||
4. `nest/inside`, `nest/placement`, `nest/spans` and the gestures take the
|
||||
subject symbol's OWN frame. They used to take an output frame and multiply it
|
||||
secretly, which is what made every caller's units a guess. A caller holding
|
||||
the playhead converts with `clip/shown-frame`, at its own edge, visibly.
|
||||
|
||||
## The gestures
|
||||
|
||||
5. **One pointer→frame function** for the whole timeline, `frame-under`. A drag's
|
||||
delta is the difference of two of its answers, never a pixel ratio rounded
|
||||
separately — so the preview and the commit are the same number by
|
||||
construction.
|
||||
6. **The junction between two clips is one handle with one meaning**: roll. It
|
||||
moves the end of the left clip and the start of the right one together, which
|
||||
is `span/roll`, which is already nothing but `resize-out` then `resize-in`.
|
||||
The three 4px-wide zones it used to pick between — trim-left, roll,
|
||||
trim-right, inside twelve pixels — were the "it just picks one" the handle
|
||||
was accused of. An edge that is not shared still has its own in/out handles.
|
||||
|
||||
## Audio goes with the picture it belongs to
|
||||
|
||||
A take's sound lived inside the take symbol, so placing the take brought it and
|
||||
placing the FACE the take is made of brought nothing. A symbol now says what it
|
||||
sounds like — `:audio`, a sound source — and placing one places a linked audio
|
||||
node beside the clip. Detection sets it on the face it extracts, which is the
|
||||
automatic link; `::ui/link-audio` sets or clears it by hand, which is the manual
|
||||
one. `:linked-to` on the audio node already existed and already follows a moved
|
||||
picture.
|
||||
|
|
@ -146,7 +146,6 @@ Full specification in `docs/animation-model.md`. The subset to build:
|
|||
[:xform :rot] {:animated? false :value 0.0}
|
||||
[:xform :scale] {:animated? false :value [1.0 1.0]}
|
||||
[:xform :skew] {:animated? false :value [0.0 0.0]}
|
||||
[:xform :anchor] {:animated? false :value [0.0 0.0]}
|
||||
[:geom :pts] {:animated? true :interp :hold
|
||||
:dense {:store "sha256:…" :offset 0 :stride 40 :frames 600}
|
||||
:generated {:by :roto/lips-outer :analysis "sha256:…"
|
||||
|
|
@ -170,15 +169,24 @@ uses to offer a parameter panel instead of raw keys. It lives on the *channel*,
|
|||
not the node, because a node wants a rotoscoped `[:geom :pts]` and a
|
||||
hand-animated `[:xform :pos]` at the same time.
|
||||
|
||||
`:skew`, `:span`, `:anchor` and `:over` stay in the shape even though nothing
|
||||
drives them yet: each is a component of a decomposition or of a composition
|
||||
order, and adding one later migrates every stored transform.
|
||||
`:skew`, `:span` and `:over` stay in the shape even though nothing drives them
|
||||
yet: each is a component of a decomposition or of a composition order, and adding
|
||||
one later migrates every stored transform.
|
||||
|
||||
`:anchor` was in this list and has since been **deleted**, which is the one place
|
||||
the reasoning above came out wrong. It is not a component of the decomposition:
|
||||
`T(a)·M·T(-a)` is `M` conjugated by a translation, and a parent already is a
|
||||
translated frame, so an anchor is a peg written inline — one that cannot be
|
||||
selected, keyed, shared, or put above a measured channel. Rotation and scale
|
||||
happen about the node's own origin; a pivot nobody chose is derived per drag by
|
||||
`domain/gesture` and a pivot somebody chose is a peg. See
|
||||
docs/animation-model.md, "There is no `:anchor`, because an anchor is a peg".
|
||||
|
||||
Transform composition, per node:
|
||||
|
||||
```
|
||||
local = T(pos) · T(anchor) · R(rot) · K(skew) · S(scale) · T(-anchor)
|
||||
world = world(parent) · local
|
||||
local = T(pos) · R(rot) · K(skew) · S(scale)
|
||||
world = world(parent) · pinv · local
|
||||
```
|
||||
|
||||
## What the prototype knows that you would otherwise rediscover
|
||||
|
|
@ -333,11 +341,12 @@ per-frame header.
|
|||
numbers: it centres on the face oval's bbox and zooms until the face is 80% of
|
||||
the raster height, so every vertex carries a cropping decision made once from one
|
||||
frame's landmarks. Dropping it is a deletion. Placement becomes `[:xform :*]` on
|
||||
an authored `:face` node, the stage clips whatever hangs off, and project
|
||||
an authored `:place` node inside the face, the stage clips whatever hangs off,
|
||||
and project
|
||||
dimensions stop being tied to the footage. See "What space geometry is in" in
|
||||
`docs/animation-model.md`.
|
||||
|
||||
The anchor transform freezes onto `:head`, one level under `:face`, and the
|
||||
The anchor transform freezes onto `:head`, one level under `:place`, and the
|
||||
normalise on/off/per-plate toggle is which of the three channel shapes that node
|
||||
carries. Always measure and always store factored, whatever the toggle says:
|
||||
smoothing and velocity-minimum key selection both require the split to exist in
|
||||
|
|
|
|||
41
docs/time.md
Normal file
41
docs/time.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Time selection
|
||||
|
||||
Project `:fps` is the playback and export grid. Each symbol has its own native
|
||||
`:fps` and `:frames`; keys, spans, trace choices and corrections stay in that
|
||||
native space. A symbol without an explicit rate inherits the document rate;
|
||||
changing project fps first records that rate so its existing timing stays put.
|
||||
The untouched symbol in a new document is deliberately different: it has no
|
||||
authored timing to preserve, so it stays on the project grid and its empty frame
|
||||
extent is rescaled to keep the same duration. This makes changing fps before
|
||||
authoring establish the editor's grid instead of preserving the 30fps default.
|
||||
|
||||
An output frame selects the latest native frame at or before its time:
|
||||
`floor(output-frame * native-fps / output-fps)`. Thus 30fps content in a 12fps
|
||||
project reads source frames 0, 2, 5, 7, 10… and retains its duration. A partial
|
||||
last output frame is included. Changing back to 30 restores the original grid.
|
||||
Nothing rewrites or discards the dense measurements.
|
||||
|
||||
The same boundary selection runs when entering a placed symbol. Placement and
|
||||
artistic speed are applied before selection; the stored `:time :rate` and
|
||||
`:playback :speed` never contain a frame-rate conversion. The derived maps used
|
||||
by timeline rows, picking and editing account for the units of each symbol.
|
||||
`clip/frames` is a native length; `clip/output-frames` is a transport/export
|
||||
length. Resolver frame queries return native frames for edits.
|
||||
|
||||
There is one fps control. The old transient picture-fps control and node
|
||||
sample-fps fields are gone. Existing exposure, trace choices and per-instance
|
||||
pose tracks remain available: a pose track can hold a chosen closed-mouth frame
|
||||
without deleting its neighboring measurements. Those choices stay in native
|
||||
frames when output fps changes. Automatic content-aware frame selection is not
|
||||
implemented; [frame-selection.md](frame-selection.md) is how it should be. An
|
||||
event between output frames appears on the next output frame; it cannot create
|
||||
an extra frame in a 12fps output.
|
||||
|
||||
Audio uses continuous time through the same derived placement maps, without
|
||||
picture floors or holds. Frame-rate units cancel before Web Audio playbackRate
|
||||
is set, so only deliberate speed changes affect pitch and duration. Export and
|
||||
playback use the same output count and resolver.
|
||||
|
||||
Earlier imports with frame-rate conversion baked into stored retimes must be
|
||||
re-imported. There is no second reader for that representation. Source video
|
||||
presentation timestamps are still future work; this model assumes constant fps.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# Timing model
|
||||
|
||||
[Time selection](time.md) defines the current frame-rate representation.
|
||||
|
||||
[The Lane Model](lane-model.md) defines the revised target for occurrence timing,
|
||||
source playback, sampling scope, and inverse editing. It supersedes conflicting
|
||||
proposals here; the sections below describe earlier implementation decisions.
|
||||
|
||||
The source footage, authored drawings, generated face motion, and stage placement
|
||||
have different frame decisions. They share a clock but do not share one kept-frame
|
||||
list. `timing-handoff.md` records earlier implementation notes.
|
||||
|
|
@ -35,7 +41,7 @@ measurements without losing the anchor choices.
|
|||
|
||||
A source image used for tracing should be registered with that image's measured
|
||||
stabilizing transform, then the selected head transform, then the authored
|
||||
`:face` placement. This makes the photo and head-local vectors share the same
|
||||
`:place` placement the face carries. This makes the photo and head-local vectors share the same
|
||||
orientation and position. Tracing-photo selection is a separate editor address;
|
||||
it does not choose the head anchor.
|
||||
|
||||
|
|
@ -78,7 +84,8 @@ candidate poses so stage cuts can still select any of them.
|
|||
| --- | --- | --- |
|
||||
| Source frames and timestamps | Footage/analysis | Constant-rate frame indexing exists; variable timestamps remain future work |
|
||||
| Head anchor map | `:head` node | Implemented, stored with the node |
|
||||
| Tracing cel starts and photo address | Authored cel | Separate future work |
|
||||
| Trace frames (photo address) and origin | The face's `:plate` `:time :holds`, and its `:head` `:reads` | Implemented, see `docs/tracing-symbol-plan.md` |
|
||||
| Showing a tracing layer, and its opacity | Editor state, `[:ui :tracing]` | Implemented; a drawing aid, never saved or keyed |
|
||||
| Generated picture-rate proposal and closure protection | Roto clip/symbol | Generated-only picture sampling exists; closure protection remains future work |
|
||||
| Stage pose cuts | Symbol instance | Implemented, stored with the instance |
|
||||
|
||||
|
|
|
|||
411
docs/tracing-symbol-plan.md
Normal file
411
docs/tracing-symbol-plan.md
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
# Plan: tracing is a symbol
|
||||
|
||||
Status: built, 2026-10-03, on branch `worktree-tracing-layers`. Where the build
|
||||
differs from the plan below, the build wins:
|
||||
|
||||
- The head's field is `:reads` (`{:holds [...]}` or `{:holds-of :plate}`), not
|
||||
`:follow`.
|
||||
- A still is not one frame. It gets as many frames as remain in the symbol it is
|
||||
dropped into, at that symbol's rate, and is trimmed like any clip.
|
||||
- An image's `:media` is `{:image <blob sha256>}`, served at `/blob/<sha>`. The
|
||||
server's `Image` row exists for the pool's list and labels, not for identity.
|
||||
- Dropping media that a tracing symbol in the document already shows reuses that
|
||||
symbol.
|
||||
- Nothing can be created or dropped inside a tracing symbol (`creation/target`,
|
||||
`drop-destination-at`, `nest/move-refusal`), and one cannot be opened in a tab.
|
||||
- Schema 6. Every project is marked 6. One that still carries `:trace` on a
|
||||
node is refused when opened, with what to do about it, rather than all old
|
||||
projects being refused.
|
||||
|
||||
No backward compatibility (see `lane-model.md`, "Goal and compatibility policy").
|
||||
|
||||
## What is wrong today
|
||||
|
||||
Tracing is not a thing in the document. It is three mechanisms that each know
|
||||
about faces:
|
||||
|
||||
- **`:trace {:frames :origin}` on a face's `:head`.** It decides which measured
|
||||
frame the head reads (`symbol/base-channel-frame` → `trace/held-frame`), and
|
||||
the underlay also reads it to decide which photo to show (`trace/photo-frame`).
|
||||
- **`ui/underlay`**, a painter that walks `trace/shown` → `trace/faces` (a
|
||||
separate instance walk), looks up each face's subject → analysis → footage,
|
||||
asks the resolver where `[...path :head]` went, and builds the photo's matrix
|
||||
by hand: `world(head) · M(p)⁻¹ · 1/imageH` (`trace/photo-matrix`).
|
||||
- **`[:ui :trace {:faces #{} :opacity}]`**, a per-face switch in editor state,
|
||||
plus the special "opening a face shows its footage, opening a take does not"
|
||||
rule (`trace/showing-for`).
|
||||
|
||||
What you cannot do: place footage or a still as a reference where you like, move
|
||||
it, scale it, turn it, trim it, hold it, put it in a lane, or trace something
|
||||
that is not a tracked face. The photo is not selectable and has no row. The one
|
||||
case that works (a face over its own footage) runs on code that nothing else
|
||||
uses.
|
||||
|
||||
## The model in one paragraph
|
||||
|
||||
A **tracing symbol** is a symbol with `:type :trace`. It has no nodes. It names
|
||||
media (a footage range or a still image) and has that media's frame count, fps and
|
||||
pixel size. It is **placed by an ordinary instance**, so lanes, spans, trim,
|
||||
split, move, playback (`:in :speed :end`), transform, nesting, selection, picking
|
||||
and gestures all work on it with no new code. It is evaluated to a single
|
||||
**`:trace` op**, which never reaches the raster or an export. A face's footage is
|
||||
not a special case. It is one of these instances, placed inside the face as a
|
||||
child of `:head` and carrying the measured registration transform. The trace keys
|
||||
become **holds on that instance's time**. The head's "origin" becomes the head
|
||||
saying **which node's frames it follows**.
|
||||
|
||||
This follows the precedent of `:type :palette` symbols (no nodes, they name an
|
||||
asset, and they are placed by instances in a lane), so the uniformity rule holds
|
||||
(see the `model-uniformity` memory). Nothing new holds nodes, and no special
|
||||
instance kind is added.
|
||||
|
||||
## Data model
|
||||
|
||||
### The tracing symbol
|
||||
|
||||
```clojure
|
||||
:footage-8625 ; a symbol id like any other
|
||||
{:id :footage-8625 :name "8625.mov"
|
||||
:type :trace
|
||||
:media {:footage #uuid "f8ca…" :range [12 241]} ; or {:image "sha256:…"}
|
||||
:frames 229 ; the range's length; 1 for a still
|
||||
:fps 30 ; the footage's own rate, so cadence handles 30→12 for free
|
||||
:width 1440 :height 1920 ; pixel size = the symbol's own stage
|
||||
:audio {:footage #uuid "f8ca…"} ; optional, the existing "a symbol says what it sounds like"
|
||||
:nodes {}}
|
||||
```
|
||||
|
||||
- `:media` is the one new symbol key. Add it to `symbol/symbol-keys` and to the
|
||||
symbol leaf's `select-keys` in `leaf/leaves`.
|
||||
- The symbol's local space is **pixels of the media**: `[0 w) × [0 h)`.
|
||||
`clip/center` of a node-less symbol already returns its stage middle, which here
|
||||
is `[w/2 h/2]`. So `place-symbol` puts the anchor at the image centre with no
|
||||
new code.
|
||||
- `symbol/problems`: `:type :trace` needs `:media` with exactly one of
|
||||
`:footage`/`:image`, needs `(empty? nodes)`, and for footage needs
|
||||
`(= frames (- end start))`.
|
||||
- **One tracing symbol per footage range, shared.** Two faces from one take
|
||||
place the same symbol, and so does a hand-placed reference. Like any symbol it
|
||||
is reused by reference, and `bring/symbols` copies it like any other.
|
||||
|
||||
### Placing it
|
||||
|
||||
It is an ordinary `:kind :instance` with `:source {:symbol :footage-8625}`:
|
||||
|
||||
- **Start and end** are `:span` (in its own frames) and `:time :at`. You get
|
||||
these from the existing trim, split, move and roll.
|
||||
- **Which frame shows** comes from `:playback {:in :speed :end}`. Footage plays
|
||||
with speed 1, a frozen frame has speed 0, and a still is 1 frame with `:end :hold`.
|
||||
- **Placement in space** is `[:xform …]`, the same as every node: gestures,
|
||||
inspector and keys.
|
||||
- **In a lane, or on its own**: it is a parent-less spanned node, so a `:display
|
||||
:lane` symbol draws it as a block like any cel. It can also sit as a free node
|
||||
or under a group. To have a reference with its own tab, wrap it in an ordinary
|
||||
symbol.
|
||||
|
||||
**Default on creation** (in the drop event, not in `place-symbol`): scale so the
|
||||
image's height fits the stage height, centred on the drop point. This is a
|
||||
creation default like "use center anchor when dropping", and nothing updates it
|
||||
afterwards.
|
||||
|
||||
### Holds: the one new time feature
|
||||
|
||||
`:time {:holds [0 12 30]}` floors a node's local frame to the last hold at or
|
||||
before it. Before the first hold, the first hold applies. This is `:expose`
|
||||
generalized from a regular grid to authored frames. It is applied in
|
||||
`node/local-frame` at the same point as `:expose`, and is inherited in the same
|
||||
way. It is in the node's **own** frames. On a tracing instance with
|
||||
`:in 0 :speed 1`, own frames are source frames, so the hold list is the set of
|
||||
traced frames.
|
||||
|
||||
It is general on purpose. Holding a playing symbol on chosen drawings is the
|
||||
same feature. It costs about three lines in `local-frame`, one `problems` clause
|
||||
(sorted, distinct, finite), and the timeline drawing hold frames as marks on the
|
||||
row.
|
||||
|
||||
`symbol/frame-map` refuses floors (`:expose > 1`). It must also refuse a
|
||||
non-empty `:holds`, for the same reason.
|
||||
|
||||
## The face: "a child symbol that represents the trace"
|
||||
|
||||
The face symbol after a freeze:
|
||||
|
||||
```
|
||||
:place group authored source→stage mapping (image heights → stage px)
|
||||
:head group measured M(p) :reads — see below
|
||||
:mouth … parts generated, every frame
|
||||
:plate instance of :footage-8625 ← the trace
|
||||
measured channels: fit(p) = M(p)⁻¹ · S(1/imageH)
|
||||
:time {:holds [0 12 30]} ← the trace keys
|
||||
```
|
||||
|
||||
### Registration comes from the parent
|
||||
|
||||
The plate is a child of `:head`. Its own measured channels are the
|
||||
**stabilizing fit** at frame p: the inverse of the head's measured transform,
|
||||
with pixels → image heights folded into the scale (it stays a similarity, so it
|
||||
decomposes into `pos`/`rot`/`scale`). Freeze already computes the fit; the
|
||||
head's measured channels are its inverse (`freeze/invert`). The plate gets a
|
||||
second dense block, which is three small channels.
|
||||
|
||||
Because holds are a **time** floor, the plate's channels and the frame its
|
||||
content shows are read at the **same** held frame q. Its world transform is:
|
||||
|
||||
```
|
||||
world(plate) = place · M(p_head) · M(q)⁻¹ · S(1/H)
|
||||
```
|
||||
|
||||
That is exactly `trace/photo-matrix`, but now it falls out of the ordinary walk.
|
||||
It is registered to whatever the head is doing, by construction:
|
||||
|
||||
| head reads (p_head) | plate shows (q) | result |
|
||||
| --- | --- | --- |
|
||||
| f (continuous) | f (no holds) | footage where filmed |
|
||||
| f (continuous) | held key | held photo rides the moving head (today's behaviour) |
|
||||
| held key (same as plate) | held key | `M(q)·M(q)⁻¹ = I`: photo sits where filmed |
|
||||
| 0 (start) | f or held | stabilized footage under a still head |
|
||||
|
||||
If a frame has no measurement, the plate's dense channel has nothing there, so
|
||||
`xform-at` gives nil, so the plate is not placed and no photo shows. That is what
|
||||
happens today too.
|
||||
|
||||
### Trace keys versus origin: who owns what
|
||||
|
||||
The two decisions are separate and stay separate:
|
||||
|
||||
- **Trace keys** are which footage frames get drawn over (the "plate drawings"
|
||||
in `frame-selection.md`). They belong to the **plate**, as `:time :holds`. A
|
||||
hand-placed tracing layer with holds is the *same thing*: the face's plate is
|
||||
an ordinary tracing placement and nothing more.
|
||||
- **Origin** is how the head moves between kept frames. `frame-selection.md`
|
||||
already says `:origin` "is not a tracing setting" but a performance one, so it
|
||||
belongs to the **head**:
|
||||
|
||||
```clojure
|
||||
:head {…} ; continuous — reads its own frame
|
||||
:head {… :reads {:holds [0]}} ; start
|
||||
:head {… :reads {:holds-of :plate}} ; at keys — reads its measured channels at
|
||||
; the frames :plate's holds select
|
||||
```
|
||||
|
||||
`{:holds [...]}` is also what a face with no footage uses for "at keys", since it
|
||||
has no plate to follow.
|
||||
|
||||
`:reads` changes only the **head's own channel reads**, not its children's
|
||||
frames. The parts must keep running every frame, which is why this cannot be a
|
||||
`:time` hold on the head. It is today's `traces` branch of
|
||||
`base-channel-frame` with the hold list read from the named node. A node
|
||||
reference has precedent (`:stencil`, `:pose-group`). It points from the follower
|
||||
to the thing followed, so there is still one stored list of frames and nothing to
|
||||
keep in sync. Validate it in `symbol/problems` the way `:stencil` is validated:
|
||||
the target exists in the symbol, and it is not the head itself or an ancestor of
|
||||
the head.
|
||||
|
||||
Rejected alternatives, so they are not re-proposed:
|
||||
|
||||
- **Keys on the head, and the plate reads them.** This is today's direction. It
|
||||
makes the plate special: a free tracing layer could not have keys that a face's
|
||||
plate also understands.
|
||||
- **Hold `:time` on `:head`.** Exposure inherits strictly, so the mouth and eyes
|
||||
would freeze along with the head.
|
||||
- **A wrapper "registered footage" symbol holding the fit.** It is correct but
|
||||
adds a symbol per face. The time-floor holds already put the fit read and the
|
||||
content read on the same frame, so the wrapper buys nothing.
|
||||
- **Plate as a sibling of `:head` at identity.** It is only registered when the
|
||||
head and the photo read the same frame, so it breaks the continuous-plus-holds
|
||||
and start rows above.
|
||||
|
||||
## Evaluation: an op that is never rendered
|
||||
|
||||
- **`clip/resolver`**, in the instance branch: when the source symbol has
|
||||
`:type :trace`, it does not recurse. If `(:tracing? opts)` is set, it emits one op:
|
||||
|
||||
```clojure
|
||||
{:kind :trace :node [id] :m <copy of world> :media … :frame shown-frame :size [w h]}
|
||||
```
|
||||
|
||||
The frame comes from the same `placed-frame` path as any instance, so playback,
|
||||
holds and the fps cadence apply. Do not build a child resolver for a trace
|
||||
symbol.
|
||||
- **`transform-op`** gets a `:trace` case that composes the matrix. Row paths,
|
||||
solo filtering and nesting at any depth then work for free.
|
||||
- **The output guarantee is structural.** `:tracing?` defaults to false. Only the
|
||||
stage's `::render/resolver` passes true. Export, `clip/center` (a big photo
|
||||
must not pull a symbol's pivot), thumbnails and the bench never ask for trace
|
||||
ops. `raster/draw-ops!` keeps throwing on unknown kinds, so a leak fails loudly.
|
||||
- **`ui/player`** sends picture ops to the raster and `:trace` ops to the
|
||||
painter.
|
||||
- **`ui/underlay` becomes `ui/tracing`.** It paints `:trace` ops in draw order
|
||||
with `drawImage` at `op.m` and the global opacity. The media URL is the footage
|
||||
manifest's `urls[range-start + frame]`, or the image blob URL. The three
|
||||
steadiness fixes stay: LRU cache, hold the last still per op `:node`, and read
|
||||
ahead while playing. Everything that walked faces is deleted.
|
||||
- **`pick`**: a `:trace` op is hit when the point, mapped through `m⁻¹`, falls in
|
||||
`[0 w) × [0 h)`. Picture ops are tested first and trace ops only if nothing
|
||||
drawn is under the pointer, so a full-frame photo does not steal every click.
|
||||
When the global switch is off, traces are neither painted nor picked.
|
||||
- **Gestures**: no change. The face's plate is measured, so `gesture/refusal`
|
||||
already says "place the instance it is in". A hand-placed layer is authored and
|
||||
moves, turns and scales like anything else.
|
||||
|
||||
## On and off
|
||||
|
||||
All of it is EDITOR STATE, as ed88c5e decided for the per-face switch: showing
|
||||
a reference is a way of looking at the stage, so it is not an undo step, does not
|
||||
travel to collaborators, and cannot reach an export.
|
||||
|
||||
```clojure
|
||||
[:ui :tracing {:on? true :opacity 0.5 :hidden #{[sid node-id] …}}]
|
||||
```
|
||||
|
||||
- **One layer** is an entry in `:hidden`, keyed by the symbol the tracing
|
||||
instance is in and its node id. The symbol id is needed because every face's
|
||||
plate is called `:plate`. Keyed this way, hiding a face's plate hides it in
|
||||
every placement of that face, which is what the per-face switch did. Shown is
|
||||
the default, so opening a face shows its footage with no setup.
|
||||
- **How it's applied**: `clip/resolver` already knows which symbol and node a
|
||||
trace op comes from, so the op carries `:layer [sid id]`. The painter and
|
||||
`pick` skip ops whose layer is hidden. The resolver does not change when you
|
||||
toggle a layer, so nothing is rebuilt.
|
||||
- **Global**: `:on?` and `:opacity`, a toggle plus an opacity slider in
|
||||
`ui/palette/bar` next to the palette controls. When it is off, traces are
|
||||
neither painted nor picked.
|
||||
- **Switching one layer on also switches the global setting on.** This applies
|
||||
from the tracing instance's inspector, from its timeline row, and from the
|
||||
face/roto section. A layer you just enabled must not stay invisible behind a
|
||||
switch you forgot. Turning one layer off never touches the global switch. It is
|
||||
one event (`::ui/show-trace layer on?`) that updates `:hidden` and, when
|
||||
turning on, sets `:on? true` in the same handler, so the three places cannot
|
||||
behave differently.
|
||||
- **`[:vis]`** still works on a tracing instance like on any node: key it to
|
||||
show a reference only over part of the shot. It is not the on/off switch.
|
||||
- **Deleted**: `[:ui :trace :faces]`, `::trace-face`, `::trace-faces`,
|
||||
`trace/showing-for`, `traceable-faces`, `shown`, `faces`, and the "a take shows
|
||||
nothing by default" rule.
|
||||
|
||||
## UI
|
||||
|
||||
- **Timeline.** A tracing instance is an ordinary row or clip block, styled to
|
||||
read as reference-only (hatched block, an eye icon in place of the colour
|
||||
chip). Hold frames are marks on the row. The row's eye button dispatches
|
||||
`::ui/show-trace`, which replaces the face row's `tl-trace` button.
|
||||
- **Inspector, tracing instance.** Show the media (footage name and range, or
|
||||
image), an on/off control (which goes through `::ui/show-trace`), and the hold
|
||||
list ("hold here" / "remove hold" plus seek buttons, which is `trace-keys`
|
||||
re-aimed at `:time :holds`). Playback, transform and span use the existing
|
||||
sections.
|
||||
- **Inspector, face/roto section.** The same hold controls, aimed at the face's
|
||||
`:plate`. Origin buttons write `:head :reads`. The section is found by "this
|
||||
face has a `:plate`", not by `trace/traceable?`.
|
||||
- **Palette bar.** The global toggle and opacity slider.
|
||||
- **Making one.**
|
||||
- Footage: the convert dialog gets a choice between "animate faces" (today's
|
||||
flow) and "tracing layer" (no detection). The tracing-layer choice makes the
|
||||
`:type :trace` symbol for the chosen range and places it where the video was
|
||||
dropped.
|
||||
- Footage can also be dragged from the pool with a modifier or as a second
|
||||
drag kind.
|
||||
- A still image: drop it on the pool and it uploads; drop it on the stage or
|
||||
timeline and it is placed with `:end :hold` and a span of the host's
|
||||
remaining frames.
|
||||
|
||||
## Freeze, bring, regenerate
|
||||
|
||||
- **`freeze/subject-part`** emits the `:plate` instance under `:head`, with fit
|
||||
measured channels and `:time {:holds []}`, and emits one `:type :trace` symbol
|
||||
for the analysed footage range. `bring/take` already copies every symbol the
|
||||
take reaches and rewrites `:source :symbol` references, so the tracing symbol
|
||||
comes along. It gets the footage's `:audio` too, so a dropped tracing layer
|
||||
can bring its sound through the existing link.
|
||||
- **`head-mode`** writes `:reads` on the head and `:holds` on the plate, in
|
||||
place of `:trace`. The `frame-selection.md` plate proposal materializes into the
|
||||
plate's `:time :holds` in place of `:trace :frames`.
|
||||
- **Regenerate** replaces both measured blocks (head and plate) and keeps
|
||||
`:holds` and `:reads`. Check that `regenerate-head`'s measured/authored
|
||||
comparison handles a second measured node.
|
||||
|
||||
## Server
|
||||
|
||||
- `Image` endpoints mirroring `Sound`: `POST /api/images` stores a `Blob` and
|
||||
returns `{id, width, height, url}`, and `GET /api/images` lists them. The
|
||||
migration is a model only. Bump `schema_version` and refuse older documents
|
||||
clearly. Do not convert them.
|
||||
|
||||
## Deleted outright
|
||||
|
||||
`trace/of`, `prepare`, `held-frame`, `problems`, `toggle-frame`, `photo-frame`,
|
||||
`measured-local`, `photo-matrix`, `traceable?`, `faces`, `traceable-faces`,
|
||||
`showing-for`, `shown`, `opacity-default`. `domain/trace.cljs` probably goes
|
||||
away entirely; if anything is left, it is a hold helper that belongs in `node`.
|
||||
|
||||
Also deleted: `symbol/prepared-traces` and the `traces` arm of
|
||||
`base-channel-frame`, which becomes the `:reads` lookup. `:trace` on nodes (and
|
||||
`symbol/problems` reports it as removed). `::project/set-trace`.
|
||||
`::render/underlay` and `::render/tracing` become one `::render/tracing` that
|
||||
returns `[:ui :tracing]`. The face lookup in `params/view`.
|
||||
|
||||
Expected effect on code size: net negative. One time-floor clause, one op kind,
|
||||
one resolver branch, one pick case and a `:reads` lookup replace the face walk,
|
||||
the hand-built photo matrix, per-face showing state and the underlay's face
|
||||
bookkeeping. Measure the change and report the number honestly (see the
|
||||
`cljs-style` memory).
|
||||
|
||||
## Tests
|
||||
|
||||
**Domain (node).**
|
||||
|
||||
- `:holds` in `local-frame`, with eval-frame and resolver agreeing forward,
|
||||
backward and in random order.
|
||||
- A trace op appears only with `:tracing?`, and `export/run!` output contains no
|
||||
`:trace` op.
|
||||
- `transform-op` composes the matrix through two nesting levels.
|
||||
- `clip/center` ignores traces, and is `[w/2 h/2]` for a tracing symbol.
|
||||
- `pick` returns picture ops before traces and inverse-maps through a rotated
|
||||
layer.
|
||||
- `symbol/problems` covers `:type :trace`, `:reads` targets and cycles, and
|
||||
`:holds` shape.
|
||||
- Registration: port `trace_test`'s photo-matrix assertions onto the walk.
|
||||
For every row of the registration table, the plate's world transform equals
|
||||
the expected matrix. At a held key with `:reads {:holds-of :plate}`, it equals
|
||||
`world(:place) · S(1/H)`.
|
||||
- Freeze emits the plate and the tracing symbol. Regeneration keeps the holds.
|
||||
- The `::ui/show-trace` event sets the global switch on when turning a layer on
|
||||
and leaves it alone when turning one off.
|
||||
|
||||
**Browser** (CDP, see the `arthur-verify-dont-guess` memory):
|
||||
|
||||
- Drop a still, then drag, turn and scale it.
|
||||
- Toggle a layer on with the global switch off, and confirm both are on and the
|
||||
photo paints.
|
||||
- Export a frame and check it has no photo pixels.
|
||||
- Open a face: the plate is registered at a hold with origin "at keys", and
|
||||
stabilized with origin "start".
|
||||
|
||||
## Order of work, each step green
|
||||
|
||||
1. `:time :holds` in `node/local-frame`, `problems`, `frame-map`, and timeline
|
||||
marks.
|
||||
2. `:type :trace` symbol, `:media`, the `:trace` op behind `:tracing?`,
|
||||
`transform-op`, the player split, `ui/tracing` painter and `pick`. Footage
|
||||
media only, placed by hand from a REPL or test document.
|
||||
3. The face: freeze and bring emit the plate and tracing symbol, `:reads`
|
||||
replaces `:trace`, delete the old trace and underlay paths, and re-aim the
|
||||
inspector's face section.
|
||||
4. On/off: the `:hidden` set and `:layer` on trace ops, the row eye, the
|
||||
`::ui/show-trace` rule, the global toggle and opacity in the palette bar, and
|
||||
delete `[:ui :trace :faces]`.
|
||||
5. Creation: "tracing layer" in the convert dialog, pool drag, then the image
|
||||
endpoint, pool images and image drops.
|
||||
6. Docs: `animation-model.md` "A photographic underlay is not an op" becomes "a
|
||||
trace is an op that never reaches the raster". Update `frame-selection.md`
|
||||
(where plate keys live) and `lane-model.md` (tracing clips in lanes).
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **"A take shows its faces' footage."** Dropping the old "only in a face's own
|
||||
tab" default means opening a take shows every face's plate if the global
|
||||
switch is on. The recommendation is to accept that, since the switch is one
|
||||
click.
|
||||
2. **Lane-model audio rule.** `lane-problems` requires visual cels. A tracing cel
|
||||
is visual for this purpose, but say so explicitly when a lane may hold both
|
||||
tracing and drawing cels.
|
||||
|
|
@ -88,18 +88,66 @@ cd frontend && mise exec -- npx shadow-cljs watch app
|
|||
```
|
||||
|
||||
Then open **<http://localhost:8778/>**. Django serves the page from
|
||||
`clips/templates/clips/index.html`, and staticfiles serves the bundle out of
|
||||
`static/arthur/js`, where `shadow-cljs` already writes it — so nothing copies files
|
||||
between the two.
|
||||
`clips/templates/clips/index.html`, its styles from `static/arthur/app.css`, and
|
||||
the bundle out of `static/arthur/js`, where `shadow-cljs` already writes it — so
|
||||
nothing copies files between the two.
|
||||
|
||||
### The window
|
||||
|
||||
One screen, five panes, no scrolling page. `src/arthur/ui/shell.cljs` is the grid
|
||||
and nothing else; each pane owns its own subscriptions.
|
||||
|
||||
```
|
||||
top the document: its name and last status, export, new / open / save
|
||||
left media pool — the open document's symbols, and footage on the server
|
||||
centre the palette strip (16 slots) above the stage
|
||||
right inspector — the clip, the selected node, the tracked objects
|
||||
bottom timeline — transport, ruler, a row per node
|
||||
```
|
||||
|
||||
**It opens on a blank document**, and **new** makes another one. Nothing is
|
||||
loaded until it is asked for.
|
||||
|
||||
**Whole documents live under `open ▾`, not in the media pool**, and the split is
|
||||
load-bearing rather than tidy. Opening a project REPLACES the stage; everything
|
||||
in the pool is a thing to put ON it. Listing documents beside the symbols inside
|
||||
one of them makes them read as two kinds of the same thing. The menu lists the
|
||||
projects the server holds; the built-in scenes are under their own heading,
|
||||
italic, and are not projects — they are compiled into the bundle and the server
|
||||
has never heard of them.
|
||||
|
||||
Everything that holds nodes is a **symbol**, and none is special: a new document
|
||||
has one called `main` because it has to be called something. Which symbol is on
|
||||
screen is editor state, `[:ui :open]`, not a fact about the document — the stage
|
||||
draws it, the timeline lists it, the transport plays it and a new shape goes into
|
||||
it. A document opens on the longest symbol nothing else places.
|
||||
|
||||
Selection lives in app-db under `:ui`, as `[:node <symbol> <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.
|
||||
|
||||
**Drop a video on the media pool** and it uploads, extracts and goes straight on
|
||||
into detection. Dragging a symbol out of the pool onto the stage places an
|
||||
instance of it at the playhead.
|
||||
|
||||
The timeline's rows are the open symbol's nodes, front-most first, with a dot per
|
||||
keyframe and a bar over the frames the node exists on; a dense channel is hatched
|
||||
rather than ticked, because one value per frame is a solid block that says less
|
||||
than the bar does. Opening a row shows its channels; opening an **instance** row
|
||||
shows the symbol it places, with every frame number mapped back into the open
|
||||
symbol's frame space — see the namespace docstring in `ui/timeline.cljs`, which
|
||||
is where that mapping is argued.
|
||||
|
||||
### Paint sketch
|
||||
|
||||
Click **new polygon**, place at least three vertices on the stage, then click
|
||||
**finish shape**. Select a shape to drag its vertices. Scrub to another frame and
|
||||
click **new drawing key** to copy the visible outline there; the previous drawing
|
||||
holds until that key. The numbered drawing-key buttons jump to editable keys.
|
||||
The transition control between two drawing keys can switch that gap between a
|
||||
hold and linear vertex tweening. Other gaps keep their own timing. Tweening works
|
||||
Pick a tone from the palette strip, click **polygon**, place at least three
|
||||
vertices on the stage, then click **finish**. Select a shape — on the stage, or by
|
||||
its timeline row — to drag its vertices. Scrub to another frame and click
|
||||
**drawing key here** in the inspector to copy the visible outline there; the
|
||||
previous drawing holds until that key. The numbered key buttons jump to editable
|
||||
keys. The transition control between two drawing keys can switch that gap between
|
||||
a hold and linear vertex tweening. Other gaps keep their own timing. Tweening works
|
||||
best when the same vertex
|
||||
keeps the same meaning in every drawing. Paint shapes use the timeline clock
|
||||
directly, so the roto exposure grid does not delay a drawing key or step its
|
||||
|
|
@ -109,7 +157,7 @@ tween. Use the project **save** button to persist the drawings.
|
|||
has used since step 5, when shadow-cljs's `:dev-http` did no directory-index
|
||||
resolution and the suite learned to ask for the file.
|
||||
|
||||
Four built-in clips, on buttons in the transport:
|
||||
Four built-in clips, under **built-in examples** in the open menu:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
|
|
@ -125,14 +173,14 @@ The demo scene itself is `src/arthur/demo/scene.edn`. Both the synthetic take
|
|||
and real footage use `src/arthur/flow/take.cljs` for the measurement order and
|
||||
`src/arthur/flow/freeze.cljs` for the landmark-to-channel conversion.
|
||||
|
||||
**stage 8625** loads the locally saved `IMG_8625.MOV` project and places its
|
||||
post-processed timeline twice. The stage layout is
|
||||
**8625 stage study**, in the open menu, loads the locally saved `IMG_8625.MOV` project and places its
|
||||
post-processed face symbol twice. The stage layout is
|
||||
`src/arthur/demo/stage_8625.edn`: the right picture and sound start at frame 48,
|
||||
and the two pictures overlap slightly in stage space. Audio has its own timeline
|
||||
and the two pictures overlap slightly in stage space. Audio has its own
|
||||
nodes, linked to the picture instances but with independent spans and gain
|
||||
channels. The right sound swells and pans across the stage, then fades out at
|
||||
frame 260 while its picture continues to
|
||||
frame 280. The button needs that saved 8625 project in the local server database.
|
||||
frame 280. The row needs that saved 8625 project in the local server database.
|
||||
|
||||
### Projects and the EDN fixtures
|
||||
|
||||
|
|
@ -148,16 +196,17 @@ the same ClojureScript clip.
|
|||
The intended editor creates and changes that in-memory clip directly: a project
|
||||
browser and **new stage** action, timeline instance placement, node and channel
|
||||
editors, then the existing save path. EDN remains useful for checked-in examples
|
||||
and reproducible studies. The current UI has save and open, but no project
|
||||
browser, blank-stage action, or authoring controls yet; open chooses the most
|
||||
recent project.
|
||||
and reproducible studies. The UI now has the blank-stage action (**new**), a
|
||||
project browser (`open ▾`), and placement by dragging a symbol out of the media
|
||||
pool; node and channel editors are still to come — the inspector reports a
|
||||
channel's shape but has nowhere to change its values.
|
||||
|
||||
### Real footage
|
||||
|
||||
Choose a video in the **footage** file input. The server probes it, re-encodes it
|
||||
Drop a video on the media pool, or use its **+** button. The server probes it, re-encodes it
|
||||
to an H.264 proxy and a raw stream of the same coded frames, pulls WAV audio and one tracing JPEG per frame,
|
||||
then makes the resulting footage selectable. Click **load frames** to detect and
|
||||
freeze it. Extraction progress is currently read from `/api/extractions/<key>`; a
|
||||
then makes the resulting footage selectable and runs detection on it. **roto**, in
|
||||
the pool's header, does the same for footage that is already there. Extraction progress is currently read from `/api/extractions/<key>`; a
|
||||
future WebSocket can push the same job state. The uploaded bytes, extraction job,
|
||||
and decoded footage have separate records, so the same uploaded video can be
|
||||
reopened without decoding it again.
|
||||
|
|
@ -216,14 +265,14 @@ the root — all of it is extraction output, and tier 3 does not belong in the r
|
|||
|
||||
Loading detects one face per frame, measures the mouth, eyes and brows from
|
||||
landmarks and the teeth from source pixels, then freezes them into channels,
|
||||
and adds a button for the footage clip. Detection happens once when you load;
|
||||
and opens the footage clip. Detection happens once when you load;
|
||||
playback only resolves channels and paints. Frames without a detection remain
|
||||
marked absent even though their neighbouring poses are used to condition the
|
||||
track. The scene now records stable subject and feature IDs and explicit eye
|
||||
pairs; dense channels can mark one feature absent while another is observed.
|
||||
Current MediaPipe loading supplies only the full-face detection mask. The stage
|
||||
stays 320×200 regardless of the footage dimensions. Real
|
||||
footage starts at the source picture rate. The **picture fps** buttons sample the
|
||||
footage starts at the source picture rate. The **picture** buttons in the inspector sample the
|
||||
frozen roto at lower rates while the source track, duration and audio clock stay
|
||||
unchanged. Picking frames to trace into cels is a separate future editing step.
|
||||
**save** also stores the detection mask, dense landmarks and raw RGBA mouth crops
|
||||
|
|
@ -252,7 +301,7 @@ runs the old JS tool on 8777, and the two are meant to run side by side.
|
|||
|
||||
## Saving
|
||||
|
||||
**save** and **open** in the transport. A save has three ordered stages:
|
||||
**new**, **open** and **save** in the top bar. A save has three ordered stages:
|
||||
is the tier split:
|
||||
|
||||
1. the **analysis** record, so every block stored afterwards can name the detector
|
||||
|
|
@ -269,10 +318,11 @@ unchanged document says `0 leaves · 0 blocks`, which is both halves of the
|
|||
addressing working at once — an unchanged leaf keeps its version, and a
|
||||
content-addressed block is already there.
|
||||
|
||||
Two things are deliberately visible as failures. Saving `swarm` is refused,
|
||||
because its blocks have hand-written names and a document may only name content
|
||||
addresses. And **open** takes the most recently updated project and shows its first
|
||||
clip: there is no project browser, and the store holds one clip at a time.
|
||||
Saving `swarm` is deliberately visible as a failure: its blocks have
|
||||
hand-written names and a document may only name content addresses.
|
||||
|
||||
`open ▾` lists every project the server holds, newest first, and shows the first
|
||||
clip of whichever one is picked — the store holds one clip at a time.
|
||||
|
||||
## The oracle, which is finished
|
||||
|
||||
|
|
@ -313,12 +363,12 @@ them is `clips/templates/clips/index.html`.
|
|||
|
||||
## Two evaluators, on purpose
|
||||
|
||||
`domain/timeline` has both `eval-frame` and `resolver`, and they are not
|
||||
`domain/symbol` has both `eval-frame` and `resolver`, and they are not
|
||||
alternatives:
|
||||
|
||||
- **`(eval-frame timeline f store)`** is the specification. Allocating, order-free,
|
||||
- **`(eval-frame symbol f store)`** is the specification. Allocating, order-free,
|
||||
obviously correct. Tests and one-off renders use it.
|
||||
- **`(resolver timeline store)` -> `(fn [f] ops)`** is what playback uses. It caches
|
||||
- **`(resolver symbol store)` -> `(fn [f] ops)`** is what playback uses. It caches
|
||||
the topological order and the z paths, holds a cursor per channel and reuses
|
||||
one point buffer per node, so a frame allocates the op maps and nothing else.
|
||||
|
||||
|
|
|
|||
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@mediapipe/tasks-vision": "1.0.1",
|
||||
"polygon-clipping": "^0.15.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
|
@ -1015,6 +1016,16 @@
|
|||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/polygon-clipping": {
|
||||
"version": "0.15.7",
|
||||
"resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz",
|
||||
"integrity": "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2",
|
||||
"splaytree": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
|
@ -1216,6 +1227,12 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
|
|
@ -1416,6 +1433,15 @@
|
|||
"source-map": "^0.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/splaytree": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.2.3.tgz",
|
||||
"integrity": "sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.20 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-browserify": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@mediapipe/tasks-vision": "1.0.1",
|
||||
"polygon-clipping": "^0.15.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
{:app {:target :browser
|
||||
:output-dir "../static/arthur/js"
|
||||
:asset-path "/static/arthur/js"
|
||||
:compiler-options {:source-map true}
|
||||
:modules {:main {:init-fn arthur.core/init}}}
|
||||
|
||||
:test {:target :node-test
|
||||
|
|
|
|||
|
|
@ -2,16 +2,22 @@
|
|||
"Render independently placed audio tracks into one stage audio clock.
|
||||
|
||||
The mix is derived from saved audio track leaves and immutable footage blobs.
|
||||
The transport still has one audio element, so seeking, rate changes and looping
|
||||
stay tied to the same clock the picture reads.
|
||||
The transport still has ONE clock, so seeking, rate changes and looping stay
|
||||
tied to the same position the picture is drawn from.
|
||||
|
||||
THE AUDIO BUFFER IS THE PRODUCT AND THE WAV IS ONE PACKAGING OF IT. Playback
|
||||
wants a URL an `<audio>` element can hold; an export wants the samples, either
|
||||
as WAV bytes to put in an archive or as the `AudioBuffer` a muxer takes as an
|
||||
audio track. So `buffer!` renders and the two wrappers below it package, rather
|
||||
than the render being spelled once per consumer."
|
||||
THE AUDIO BUFFER IS THE PRODUCT AND THE WAV IS ONE PACKAGING OF IT. `buffer!`
|
||||
renders and the wrappers below it package, rather than the render being spelled
|
||||
once per consumer.
|
||||
|
||||
PLAYBACK NO LONGER PACKAGES AT ALL. It takes the buffer as it is — see
|
||||
`clock-source!` and `arthur.clock.graph` — because encoding a WAV so an
|
||||
`<audio>` element had a URL to hold cost O(the clip's length) on the main
|
||||
thread, on open, on every tab switch and on every edit to a track. The WAV is
|
||||
now what an EXPORT wants: bytes for an archive, or the buffer itself for a
|
||||
muxer. `clock!` below is the element backend's packaging and goes when it does."
|
||||
(:require [arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.nest :as nest]
|
||||
[arthur.domain.node :as node]))
|
||||
|
||||
(defn wav-bytes
|
||||
|
|
@ -28,9 +34,22 @@
|
|||
bytes (js/ArrayBuffer. (+ 44 (* frames channels 2)))
|
||||
view (js/DataView. bytes)
|
||||
samples (mapv #(.getChannelData buffer %) (range channels))
|
||||
peak (reduce max 0
|
||||
(for [channel samples i (range frames)]
|
||||
(js/Math.abs (aget channel i))))
|
||||
;; A HAND-WRITTEN LOOP over the typed arrays, not `(reduce max (for ...))`.
|
||||
;; The lazy sequence that read beautifully allocated one boxed double per
|
||||
;; SAMPLE — ten million of them for a three-minute mix — and spent the
|
||||
;; whole of a sixteen-second project open walking them and collecting
|
||||
;; them. Same arithmetic, no allocation.
|
||||
peak (loop [c 0 p 0]
|
||||
(if (< c channels)
|
||||
(recur (inc c)
|
||||
(let [^js data (nth samples c)]
|
||||
(loop [i 0 p p]
|
||||
(if (< i frames)
|
||||
(recur (inc i)
|
||||
(let [a (js/Math.abs (aget data i))]
|
||||
(if (> a p) a p)))
|
||||
p))))
|
||||
p))
|
||||
level (if (> peak 0.98) (/ 0.98 peak) 1)]
|
||||
(doseq [[offset word] [[0 "RIFF"] [8 "WAVE"] [12 "fmt "] [36 "data"]]]
|
||||
(dotimes [i 4] (.setUint8 view (+ offset i) (.charCodeAt word i))))
|
||||
|
|
@ -43,45 +62,62 @@
|
|||
(.setUint16 view 32 (* channels 2) true)
|
||||
(.setUint16 view 34 16 true)
|
||||
(.setUint32 view 40 (* frames channels 2) true)
|
||||
(dotimes [i frames]
|
||||
;; Channel-outer so the channel's array is looked up once rather than once
|
||||
;; per frame. The byte offsets are unchanged, so the interleaving is too.
|
||||
(dotimes [c channels]
|
||||
(let [sample (* level (aget (get samples c) i))]
|
||||
(let [^js data (nth samples c)]
|
||||
(dotimes [i frames]
|
||||
(let [sample (* level (aget data i))]
|
||||
(.setInt16 view (+ 44 (* (+ (* i channels) c) 2))
|
||||
(js/Math.round (* 32767 (max -1 (min 1 sample)))) true))))
|
||||
(js/Math.round (* 32767 (max -1 (min 1 sample)))) true)))))
|
||||
(js/Uint8Array. bytes)))
|
||||
|
||||
(defn- wav-url [^js buffer]
|
||||
(js/URL.createObjectURL
|
||||
(js/Blob. #js [(wav-bytes buffer)] #js {:type "audio/wav"})))
|
||||
|
||||
(defn- source! [footage-id]
|
||||
(-> (js/fetch (str "/api/footage/" footage-id))
|
||||
(defn- fetch-ok! [url what]
|
||||
(-> (js/fetch url)
|
||||
(.then (fn [response]
|
||||
(when-not (.-ok response)
|
||||
(throw (ex-info "audio track's footage is missing"
|
||||
{:footage footage-id :status (.-status response)})))
|
||||
(.json response)))
|
||||
(throw (ex-info (str "audio track's " what " is missing")
|
||||
{:url url :status (.-status response)})))
|
||||
response))))
|
||||
|
||||
(defn- decode-bytes! [bytes]
|
||||
(.decodeAudioData (js/OfflineAudioContext. 1 1 44100) bytes))
|
||||
|
||||
(defn- source!
|
||||
"Promise of `[source {:buffer :fps}]` for an audio node's `:source`. Footage
|
||||
counts its frames at its own rate; a sound file has no frames of its own, so
|
||||
its `:fps` is nil and it counts in the document's."
|
||||
[{:keys [footage sound] :as source}]
|
||||
(if sound
|
||||
(-> (fetch-ok! (str "/api/sounds/" sound) "sound")
|
||||
(.then #(.json %))
|
||||
(.then #(fetch-ok! (.-audio %) "blob"))
|
||||
(.then #(.arrayBuffer %))
|
||||
(.then decode-bytes!)
|
||||
(.then (fn [buffer] [source {:buffer buffer}])))
|
||||
(-> (fetch-ok! (str "/api/footage/" footage) "footage")
|
||||
(.then #(.json %))
|
||||
(.then (fn [^js manifest]
|
||||
(-> (js/fetch (.-audio manifest))
|
||||
(.then (fn [response]
|
||||
(when-not (.-ok response)
|
||||
(throw (ex-info "audio track's blob is missing"
|
||||
{:footage footage-id :status (.-status response)})))
|
||||
(.arrayBuffer response)))
|
||||
(.then (fn [bytes]
|
||||
(let [decoder (js/OfflineAudioContext. 1 1 44100)]
|
||||
(-> (.decodeAudioData decoder bytes)
|
||||
(.then (fn [buffer]
|
||||
[footage-id {:buffer buffer
|
||||
:fps (.-fps manifest)}])))))))))))
|
||||
(-> (fetch-ok! (.-audio manifest) "blob")
|
||||
(.then #(.arrayBuffer %))
|
||||
(.then decode-bytes!)
|
||||
(.then (fn [buffer] [source {:buffer buffer :fps (.-fps manifest)}]))))))))
|
||||
|
||||
(defn- automate! [^js param channel start end fps factor default store]
|
||||
(let [channel (or channel (ch/framed default))]
|
||||
(.setValueAtTime param (* factor (ch/value-at channel start store)) (/ start fps))
|
||||
(let [channel (or channel (ch/framed default))
|
||||
sample (fn [f] (ch/value-at channel
|
||||
(if-let [{:keys [at rate]} (:sample-time channel)]
|
||||
(js/Math.floor (* rate (- f at))) f)
|
||||
store))]
|
||||
(.setValueAtTime param (* factor (sample start)) (/ start fps))
|
||||
(cond
|
||||
(:dense channel)
|
||||
(doseq [f (range (inc start) end)]
|
||||
(.setValueAtTime param (* factor (ch/value-at channel f store)) (/ f fps)))
|
||||
(.setValueAtTime param (* factor (sample f)) (/ f fps)))
|
||||
|
||||
(:animated? channel)
|
||||
(doseq [[f v] (sort-by key (:keys channel))
|
||||
|
|
@ -91,25 +127,22 @@
|
|||
(.setValueAtTime param (* factor v) (/ f fps)))))))
|
||||
|
||||
(defn tracks-of
|
||||
"The audio nodes of one of the clip's timelines.
|
||||
"The sounds symbol `sid` plays, including those inside what it places — see
|
||||
`nest/audio-tracks`. Playback mixes the open symbol's."
|
||||
[document sid]
|
||||
(nest/audio-tracks document sid))
|
||||
|
||||
A timeline parameter rather than always the root, because a symbol is a
|
||||
timeline and may carry its own sound. `:main` is the clip's own, which is what
|
||||
playback mixes."
|
||||
[document tid]
|
||||
(filter #(= :audio (:kind %)) (vals (:nodes (clip/timeline document tid)))))
|
||||
|
||||
(defn- render! [document tid sources store]
|
||||
(defn- render! [document sid sources store]
|
||||
(let [fps (:fps document)
|
||||
frames (:frames (clip/timeline document tid))
|
||||
tracks (tracks-of document tid)
|
||||
frames (clip/output-frames document sid)
|
||||
tracks (tracks-of document sid)
|
||||
output (js/OfflineAudioContext.
|
||||
2 (js/Math.ceil (* (/ frames fps) 44100)) 44100)]
|
||||
(doseq [track tracks]
|
||||
(let [[start end] (or (:span track) [0 frames])
|
||||
(let [[start end] (or (node/placed-span track) [0 frames])
|
||||
start (max 0 start)
|
||||
end (min frames end)
|
||||
{:keys [buffer fps]} (get sources (get-in track [:source :footage]))
|
||||
{:keys [buffer fps] :or {fps (:fps track)}} (get sources (:source track))
|
||||
sound (.createBufferSource output)
|
||||
gain (.createGain output)
|
||||
pan (.createStereoPanner output)]
|
||||
|
|
@ -118,7 +151,7 @@
|
|||
(set! (.-loop sound) (boolean (get-in track [:time :loop?])))
|
||||
(automate! (.-playbackRate sound)
|
||||
(get-in track [:channels [:audio :rate]])
|
||||
start end (:fps document) (or (get-in track [:time :rate]) 1) 1 store)
|
||||
start end (:fps document) (* (or (get-in track [:time :rate]) 1) (/ (:fps document) fps)) 1 store)
|
||||
(automate! (.-gain gain)
|
||||
(get-in track [:channels [:audio :gain]])
|
||||
start end (:fps document) 1 1 store)
|
||||
|
|
@ -133,20 +166,19 @@
|
|||
(.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 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))))))))
|
||||
(into-array (map source! (distinct (map :source tracks)))))
|
||||
(.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
|
||||
|
|
@ -158,13 +190,73 @@
|
|||
(throw (ex-info "the clip's audio did not load"
|
||||
{:url url :status (.-status response)})))
|
||||
(.arrayBuffer response)))
|
||||
(.then (fn [bytes]
|
||||
(.decodeAudioData (js/OfflineAudioContext. 1 1 44100) bytes)))))
|
||||
(.then decode-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))))))
|
||||
(defn fit-buffer
|
||||
"Fit fallback audio to the open timeline, padding with silence or trimming.
|
||||
The buffer and clock must share a duration so looping wraps at the timeline's
|
||||
end rather than repeating a short soundtrack underneath a longer animation."
|
||||
[^js buffer seconds]
|
||||
(let [rate (.-sampleRate buffer)
|
||||
frames (max 1 (js/Math.ceil (* seconds rate)))]
|
||||
(if (= frames (.-length buffer))
|
||||
buffer
|
||||
(let [channels (.-numberOfChannels buffer)
|
||||
fitted (.createBuffer (js/OfflineAudioContext. channels 1 rate)
|
||||
channels frames rate)]
|
||||
(dotimes [c channels]
|
||||
(.set (.getChannelData fitted c)
|
||||
(.subarray (.getChannelData buffer c) 0 (min frames (.-length buffer)))))
|
||||
fitted))))
|
||||
|
||||
(defn clock-source!
|
||||
"Promise of `{:buffer :seconds}` — the audio the transport runs its clock on
|
||||
while symbol `sid` is open, and how long that clock is.
|
||||
|
||||
Same order of preference as the WAV packaging in `clock!` below: the symbol's
|
||||
own placed tracks, mixed; the document's audio file, for the symbol the
|
||||
document opens on and only that one; and otherwise NO BUFFER AT ALL and the
|
||||
symbol's own length.
|
||||
|
||||
THE SILENT CASE IS WHY THE DURATION IS RETURNED BESIDE THE BUFFER rather than
|
||||
read off it. A symbol with no sound still needs a clock exactly as long as it
|
||||
is, and `clock!` had to synthesize that silence and then ENCODE it, full
|
||||
length, so an element had a duration to report. There is nothing to decode for
|
||||
a symbol with no sound: saying how long it is answers the only question the
|
||||
silence was ever asked."
|
||||
[document sid fallback-url store]
|
||||
(-> (buffer! document sid store)
|
||||
(.then (fn [^js buffer]
|
||||
(cond
|
||||
buffer
|
||||
{:buffer buffer :seconds (.-duration buffer)}
|
||||
|
||||
(and fallback-url (= sid (clip/opens-on document)))
|
||||
(-> (decode! fallback-url)
|
||||
(.then (fn [b]
|
||||
(let [seconds (/ (clip/output-frames document sid) (:fps document))]
|
||||
{:buffer (fit-buffer b seconds) :seconds seconds}))))
|
||||
|
||||
:else
|
||||
{:buffer nil
|
||||
:seconds (/ (clip/output-frames document sid) (:fps document))})))))
|
||||
|
||||
(defn clock!
|
||||
"Promise of the URL the transport should play while symbol `sid` is open.
|
||||
|
||||
THE ELEMENT BACKEND'S PACKAGING of `clock-source!`, kept while that backend is
|
||||
— see `arthur.clock`. Every cost this namespace had on open is in the two
|
||||
`wav-url` calls below.
|
||||
|
||||
The frame is derived from the audio element and from nothing else, so every
|
||||
open symbol needs a sound exactly as long as it is. In order: its own placed
|
||||
tracks, mixed; the document's audio file, for the symbol the document opens on
|
||||
and only that one; and otherwise SILENCE of the symbol's length — a ten-frame
|
||||
symbol played against the whole take's soundtrack would run ten frames and then
|
||||
keep the clock going for minutes."
|
||||
[document sid fallback-url store]
|
||||
(-> (clock-source! document sid fallback-url store)
|
||||
(.then (fn [{:keys [buffer seconds]}]
|
||||
(wav-url (or buffer
|
||||
(.createBuffer (js/OfflineAudioContext. 1 1 44100)
|
||||
1 (max 1 (js/Math.ceil (* 44100 seconds))) 44100)))))))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
THE FRAME IS DERIVED FROM THE AUDIO, never counted:
|
||||
|
||||
frame = ⌊currentTime · fps⌋
|
||||
frame = ⌊position · fps⌋
|
||||
|
||||
A loop that counted frames and hoped to keep up would drift, and drift against
|
||||
a voice is the one artefact that cannot be fixed downstream — a lip-sync tool
|
||||
|
|
@ -12,26 +12,79 @@
|
|||
The failure mode becomes a visible stutter rather than an invisible slide, and
|
||||
those are very different bugs to own.
|
||||
|
||||
½× and ¼× are `playbackRate` and nothing else. The audio slows, `currentTime`
|
||||
advances proportionally, and the derived frame follows — so slow motion cannot
|
||||
desync by construction. Implementing rate as a multiplier on a counted frame
|
||||
would give the picture a rate and the sound another.
|
||||
½× and ¼× are the backend's playback rate and nothing else. The audio slows,
|
||||
the position advances proportionally, and the derived frame follows — so slow
|
||||
motion cannot desync by construction. Implementing rate as a multiplier on a
|
||||
counted frame would give the picture a rate and the sound another.
|
||||
|
||||
It is outside app-db because the audio element is the source of truth and
|
||||
copying it into the db every frame would make the db a lagging mirror of
|
||||
something authoritative elsewhere. What DOES belong in the db is the playhead
|
||||
as a piece of document state — see events/playback — and that is written from
|
||||
here, not read by here."
|
||||
(:require [arthur.domain.node :as node]))
|
||||
It is outside app-db because the audio is the source of truth and copying it
|
||||
into the db every frame would make the db a lagging mirror of something
|
||||
authoritative elsewhere. What DOES belong in the db is the playhead as a piece
|
||||
of document state — see events/playback — and that is written from here, not
|
||||
read by here.
|
||||
|
||||
(defonce ^:private el (atom nil))
|
||||
TWO BACKENDS, ONE ARITHMETIC. `position` comes from either a Web Audio graph
|
||||
(`clock.graph`, the default) or an `<audio>` element (`clock.element`, kept
|
||||
switchable while the first earns trust). Everything below the position — the
|
||||
derivation, the clamp, the exposure grid — is here and is the same either way,
|
||||
so the two can be compared on the same take rather than swapped on faith.
|
||||
Build with `:closure-defines {arthur.clock/BACKEND \"element\"}`, or call
|
||||
`use-backend!` from the console, to put the element back."
|
||||
(:require [arthur.clock.element :as element]
|
||||
[arthur.clock.graph :as graph]
|
||||
[arthur.clock.transport :as t]
|
||||
[arthur.domain.node :as node]))
|
||||
|
||||
(goog-define ^String BACKEND "graph")
|
||||
|
||||
(defonce ^:private mode (atom (keyword BACKEND)))
|
||||
|
||||
;; `{:source x :backend b}`. The source is kept so that re-attaching the same
|
||||
;; thing can be recognised as the no-op it is — see `install!`.
|
||||
(defonce ^:private current (atom nil))
|
||||
|
||||
(defn graph?
|
||||
"Whether the app should wire up the graph backend. Read by `ui/shell`, which
|
||||
renders the audio element only when this is false, and by `events/playback`,
|
||||
which fetches a buffer rather than a WAV URL when it is true."
|
||||
[]
|
||||
(and (= :graph @mode) (graph/available?)))
|
||||
|
||||
(defn use-backend!
|
||||
"Switch backends. Takes effect on the next thing that attaches one, which is
|
||||
the next tab switch or document open — nothing is torn down under a take
|
||||
that is already playing."
|
||||
[m]
|
||||
(reset! mode m))
|
||||
|
||||
(defn- install!
|
||||
"Put a backend on `source`, unless `source` is already the clock's.
|
||||
|
||||
IDEMPOTENCE IS LOAD-BEARING HERE. The element's `:ref` is an inline closure,
|
||||
so React hands it the same node again on every re-render of the shell —
|
||||
rebuilding the clock there would mean a pane being dragged released whatever
|
||||
was playing. The source is the identity: the element, or the buffer and its
|
||||
length."
|
||||
[source make]
|
||||
(let [{:keys [backend] prev :source} @current]
|
||||
(when-not (and backend (= prev source))
|
||||
(when backend (t/-release! backend))
|
||||
(reset! current {:source source
|
||||
:backend (when (some? source) (make))}))))
|
||||
|
||||
(defn attach!
|
||||
"Hand the clock its audio element. Idempotent."
|
||||
"Hand the clock an audio element. Idempotent. Element backend only — the
|
||||
`:ref` that calls this is on a node `ui/shell` renders only in that mode."
|
||||
[audio-el]
|
||||
(reset! el audio-el))
|
||||
(install! audio-el #(element/backend audio-el)))
|
||||
|
||||
(defn element [] @el)
|
||||
(defn attach-buffer!
|
||||
"Hand the clock `seconds` of audio to run on, as an `AudioBuffer` or as nil
|
||||
for silence of that length. Graph backend only."
|
||||
[buffer seconds]
|
||||
;; Keyed on the length as well as the buffer, because two silent clocks of
|
||||
;; different lengths are two different clocks and both have a nil buffer.
|
||||
(install! [buffer seconds] #(graph/backend buffer seconds)))
|
||||
|
||||
(defn- clamp [f frames]
|
||||
(-> f (max 0) (min (dec frames))))
|
||||
|
|
@ -39,56 +92,52 @@
|
|||
(defn frame
|
||||
"The clip frame the audio is currently on."
|
||||
[fps frames]
|
||||
(if-let [a @el]
|
||||
(clamp (js/Math.floor (* (.-currentTime a) fps)) frames)
|
||||
(if-let [b (:backend @current)]
|
||||
(clamp (js/Math.floor (* (t/-position b) fps)) frames)
|
||||
0))
|
||||
|
||||
(defn playing? []
|
||||
(boolean (when-let [a @el] (and (not (.-paused a)) (not (.-ended a))))))
|
||||
(boolean (when-let [b (:backend @current)] (t/-playing? b))))
|
||||
|
||||
(defn rate []
|
||||
(if-let [a @el] (.-playbackRate a) 1.0))
|
||||
(if-let [b (:backend @current)] (t/-rate b) 1.0))
|
||||
|
||||
(defn set-rate! [r]
|
||||
(when-let [a @el] (set! (.-playbackRate a) r)))
|
||||
(when-let [b (:backend @current)] (t/-set-rate! b r)))
|
||||
|
||||
(defn play! []
|
||||
(when-let [a @el]
|
||||
;; Returns a promise that rejects if the browser has not seen a gesture yet.
|
||||
;; Swallowed: the transport button IS the gesture, so this can only fire on a
|
||||
;; programmatic play, where a console error is noise rather than news.
|
||||
(some-> (.play a) (.catch (fn [_])))))
|
||||
(when-let [b (:backend @current)] (t/-play! b)))
|
||||
|
||||
(defn pause! []
|
||||
(when-let [a @el] (.pause a)))
|
||||
(when-let [b (:backend @current)] (t/-pause! b)))
|
||||
|
||||
(defn seek!
|
||||
"Put the audio at the start of frame f. Seeking to the frame's start rather
|
||||
than its middle keeps `frame` idempotent: seek to f, read back f."
|
||||
[fps frames f]
|
||||
(when-let [a @el]
|
||||
(set! (.-currentTime a) (/ (clamp f frames) fps))))
|
||||
(when-let [b (:backend @current)]
|
||||
(t/-seek! b (/ (clamp f frames) fps))))
|
||||
|
||||
(defn set-loop!
|
||||
"Wrap at the end instead of stopping. The frame stays derived — `currentTime`
|
||||
"Wrap at the end instead of stopping. The frame stays derived — the position
|
||||
simply returns to zero — so nothing about the sync changes, which is the point
|
||||
of not counting frames.
|
||||
|
||||
It earns its place at 2x and 4x, where the whole clip is gone in under four
|
||||
seconds and a profile wants more than that to look at."
|
||||
[on?]
|
||||
(when-let [a @el] (set! (.-loop a) (boolean on?))))
|
||||
(when-let [b (:backend @current)] (t/-set-loop! b on?)))
|
||||
|
||||
(defn set-muted! [on?]
|
||||
(when-let [a @el] (set! (.-muted a) (boolean on?))))
|
||||
(when-let [b (:backend @current)] (t/-set-muted! b on?)))
|
||||
|
||||
(defn duration-frames
|
||||
"How many frames the audio actually covers, which need not be the clip's
|
||||
length. Reported rather than assumed: a clip longer than its audio is a
|
||||
legitimate thing to be told about, not a thing to silently truncate."
|
||||
[fps]
|
||||
(when-let [a @el]
|
||||
(let [d (.-duration a)]
|
||||
(when-let [b (:backend @current)]
|
||||
(let [d (t/-duration b)]
|
||||
(when (and d (js/isFinite d)) (js/Math.ceil (* d fps))))))
|
||||
|
||||
(defn exposed-frame
|
||||
|
|
@ -97,8 +146,3 @@
|
|||
rather than only inside the scene."
|
||||
[f expose]
|
||||
(node/expose f expose))
|
||||
|
||||
(defn picture-frame
|
||||
"The source pose displayed at f after picture-rate sampling and exposure."
|
||||
[f source-fps picture-fps expose]
|
||||
(node/expose (node/sample-frame f source-fps picture-fps) expose))
|
||||
|
|
|
|||
45
frontend/src/arthur/clock/element.cljs
Normal file
45
frontend/src/arthur/clock/element.cljs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
(ns arthur.clock.element
|
||||
"The clock on an `<audio>` element. The original backend, kept switchable.
|
||||
|
||||
Reads `currentTime` and takes it as the position. That is the whole of it, and
|
||||
it is why this backend needs a WAV: an element holds a URL, so a mixdown has
|
||||
to be encoded and blobbed before it can be played — see `audio/mix`'s
|
||||
`clock!`. The encode is O(the clip's length) and runs on the main thread, so a
|
||||
long take costs seconds of frozen UI on open, on every tab switch and on every
|
||||
edit to a track. `arthur.clock.graph` exists to not do that.
|
||||
|
||||
What this backend still has that the graph one has to be told: the OS media
|
||||
keys and the media session, which the element gets from the browser for free.
|
||||
|
||||
KEPT so the two can be compared on the same document rather than swapped on
|
||||
faith. When the graph backend has been trusted for a while, this namespace and
|
||||
`mix/clock!`'s WAV packaging go together."
|
||||
(:require [arthur.clock.transport :as t]))
|
||||
|
||||
(deftype Element [^js el]
|
||||
t/Transport
|
||||
(-position [_] (.-currentTime el))
|
||||
(-duration [_] (.-duration el))
|
||||
(-playing? [_] (and (not (.-paused el)) (not (.-ended el))))
|
||||
(-rate [_] (.-playbackRate el))
|
||||
(-set-rate! [_ r] (set! (.-playbackRate el) r))
|
||||
(-play! [_]
|
||||
;; Returns a promise that rejects if the browser has not seen a gesture yet.
|
||||
;; Swallowed: the transport button IS the gesture, so this can only fire on a
|
||||
;; programmatic play, where a console error is noise rather than news.
|
||||
(some-> (.play el) (.catch (fn [_]))))
|
||||
(-pause! [_] (.pause el))
|
||||
(-seek! [_ seconds] (set! (.-currentTime el) seconds))
|
||||
(-set-loop! [_ on?] (set! (.-loop el) (boolean on?)))
|
||||
(-set-muted! [_ on?] (set! (.-muted el) (boolean on?)))
|
||||
(-release! [_]
|
||||
;; Nothing. React owns the element and unmounting it is what stops it; this
|
||||
;; backend is a few property accesses wrapped in a type and holds no more
|
||||
;; than that.
|
||||
nil))
|
||||
|
||||
(defn backend
|
||||
"Wrap an audio element — or anything that answers the same five properties,
|
||||
which is what `clock-test`'s fake is."
|
||||
[el]
|
||||
(->Element el))
|
||||
266
frontend/src/arthur/clock/graph.cljs
Normal file
266
frontend/src/arthur/clock/graph.cljs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
(ns arthur.clock.graph
|
||||
"The clock on a Web Audio graph. No encode, no blob, no element.
|
||||
|
||||
THE BUFFER IS PLAYED, NOT PACKAGED. `audio/mix`'s `buffer!` already renders
|
||||
the mixdown; the element backend then spent O(the clip's length) encoding that
|
||||
buffer to a WAV purely so a `src` attribute had something to point at. An
|
||||
`AudioBufferSourceNode` takes the buffer as it is, so opening a document costs
|
||||
one node instead of a hundred megabytes of 16-bit PCM.
|
||||
|
||||
THE FRAME IS STILL DERIVED, AND FROM A BETTER CLOCK. `AudioContext.currentTime`
|
||||
is the audio device's own position in double precision, advancing every 128
|
||||
samples; an element's `currentTime` is whatever the media pipeline last
|
||||
published and is permitted to lag. Nothing is counted here either — position is
|
||||
an anchor plus elapsed context time, and the anchor is re-set on every seek and
|
||||
every rate change, so no arithmetic accumulates across either.
|
||||
|
||||
AND IT IS LATENCY-COMPENSATED, which is the one thing this backend must get
|
||||
right. `currentTime` is the time of the quantum being RENDERED, which the
|
||||
speaker is `outputLatency` behind — tens of milliseconds, far more over
|
||||
Bluetooth. Report the renderer's position and the picture leads the sound by
|
||||
exactly that much, which in a lip-sync tool is the only artefact that matters.
|
||||
So `scheduled` is the bookkeeping and `-position` is `scheduled` read one
|
||||
latency in the past. The element has the same lag underneath; the difference is
|
||||
that this one is a number we can subtract rather than an error we inherit.
|
||||
|
||||
A SYMBOL WITH NO SOUND GETS A CLOCK ANYWAY, with no buffer at all: `seconds`
|
||||
is its length and the context's clock does the rest. The element backend had to
|
||||
synthesize silence and encode THAT to a WAV, full length, so the element had a
|
||||
duration to report — the clearest sign that the element was driving the design
|
||||
rather than serving it."
|
||||
(:require [arthur.clock.transport :as t]))
|
||||
|
||||
(defonce ^:private shared (atom nil))
|
||||
|
||||
(defn available?
|
||||
"Whether this backend can run at all. False under node, where the tests live."
|
||||
[]
|
||||
(exists? js/AudioContext))
|
||||
|
||||
(defn context
|
||||
"The app's one `AudioContext`, made on first use.
|
||||
|
||||
Lazy because constructing one before anything wants to play is how a browser
|
||||
decides the page is trying to autoplay, and because `available?` is false in
|
||||
the test runner."
|
||||
[]
|
||||
(or (:ctx @shared)
|
||||
(let [ctx (js/AudioContext.)
|
||||
;; ONE output gain for the app, not one per backend: a tab switch
|
||||
;; replaces the backend, and a gain node per backend would leave the
|
||||
;; old one wired to the destination. Mute lives here for the same
|
||||
;; reason — it is a property of the transport, not of whichever
|
||||
;; buffer happens to be loaded.
|
||||
out (.createGain ctx)]
|
||||
(.connect out (.-destination ctx))
|
||||
(:ctx (reset! shared {:ctx ctx :out out})))))
|
||||
|
||||
(defn output [] (do (context) (:out @shared)))
|
||||
|
||||
(defn- latency
|
||||
"How far ahead of the speaker `currentTime` is, in seconds.
|
||||
|
||||
`outputLatency` is the whole path and the number we want. Firefox reports 0
|
||||
until the graph has actually run, so `baseLatency` stands in — it is only the
|
||||
graph's own buffering and therefore an underestimate, which errs towards the
|
||||
picture leading slightly rather than the correction overshooting. Neither
|
||||
exists everywhere; 0 is then no worse than the element."
|
||||
[^js ctx]
|
||||
(let [out (.-outputLatency ctx)
|
||||
base (.-baseLatency ctx)]
|
||||
(cond
|
||||
(and (number? out) (js/isFinite out) (pos? out)) out
|
||||
(and (number? base) (js/isFinite base) (pos? base)) base
|
||||
:else 0)))
|
||||
|
||||
(defn- seg-at
|
||||
"The segment governing context time `t`."
|
||||
[segs t]
|
||||
(or (last (filter #(<= (:from %) t) segs)) (first segs)))
|
||||
|
||||
(defn- raw
|
||||
"Where the audio is at context time `t`, in seconds into the buffer.
|
||||
|
||||
PIECEWISE, and that is the whole subtlety of this namespace. Audio already
|
||||
rendered cannot be re-rated: when the transport goes 1x -> 4x, the samples
|
||||
still travelling to the speaker were rendered at 1x, so reading them back at
|
||||
4x jumps the playhead BACKWARDS by three output latencies — about fourteen
|
||||
frames on a laptop, which is a visible lurch on every rate change and was
|
||||
exactly what the first version of this did. So each `play`, `pause`, `seek`
|
||||
and rate change records a segment, and a position is read against whichever
|
||||
segment was in force when that audio was rendered.
|
||||
|
||||
Evaluated before the oldest segment we kept, it clamps. That is what makes a
|
||||
seek read back exactly what was seeked to: a seek has nothing in flight worth
|
||||
honouring — the user has jumped — so its segment starts the timeline over."
|
||||
[segs t]
|
||||
(let [t (max t (:from (first segs)))
|
||||
s (seg-at segs t)]
|
||||
(+ (:anchor s) (* (:rate s) (- t (:from s))))))
|
||||
|
||||
(defn- at-renderer
|
||||
"Where the graph has rendered up to: `raw` at the context's own clock."
|
||||
[{:keys [^js ctx segs]}]
|
||||
(raw segs (.-currentTime ctx)))
|
||||
|
||||
(defn- at-speaker
|
||||
"Where the sound being heard is: `raw` one output latency in the past. See the
|
||||
namespace docstring — this is the whole of the compensation."
|
||||
[{:keys [^js ctx segs]}]
|
||||
(raw segs (- (.-currentTime ctx) (latency ctx))))
|
||||
|
||||
(defn- running?
|
||||
"Whether the renderer is advancing. Read off the segments rather than kept
|
||||
beside them, so there is one answer and not two that can disagree."
|
||||
[{:keys [segs]}]
|
||||
(pos? (:rate (last segs))))
|
||||
|
||||
(defn- prune
|
||||
"Drop segments no position can still need: everything before the last one that
|
||||
began at or before the in-flight window. Unbounded history would otherwise
|
||||
grow by one entry per rate change for the life of the document."
|
||||
[segs cutoff]
|
||||
(let [n (count (take-while #(<= (:from %) cutoff) segs))]
|
||||
(if (<= n 1) segs (subvec segs (dec n)))))
|
||||
|
||||
(defn- position
|
||||
"`at-speaker`, brought inside the audio. Clamped before the wrap, so the few
|
||||
milliseconds of negative position right after a looped play — the first
|
||||
samples are still in the output buffer — read as 0 rather than as the end."
|
||||
[{:keys [seconds loop?] :as st}]
|
||||
(let [p (at-speaker st)]
|
||||
(cond
|
||||
(not (and seconds (pos? seconds))) 0
|
||||
loop? (mod (max 0 p) seconds)
|
||||
:else (-> p (max 0) (min seconds)))))
|
||||
|
||||
(defn- done?
|
||||
"Run off the end. Judged at the SPEAKER, so playback is still reported as
|
||||
running while the last scheduled samples are on their way out."
|
||||
[{:keys [seconds loop?] :as st}]
|
||||
(and (not loop?) seconds (pos? seconds) (>= (at-speaker st) seconds)))
|
||||
|
||||
(defn- spin-up!
|
||||
"A fresh source node playing from where the renderer now is.
|
||||
`AudioBufferSourceNode`s are single-use, so a play, a seek while playing and a
|
||||
resumed pause each make a new one; they are cheap, which is the point of this
|
||||
backend.
|
||||
|
||||
Nil when there is no buffer — the silent clock runs on the context alone."
|
||||
[{:keys [^js ctx ^js buffer ^js out rate loop? seconds] :as st}]
|
||||
(when buffer
|
||||
(let [node (.createBufferSource ctx)]
|
||||
(set! (.-buffer node) buffer)
|
||||
(set! (.-loop node) (boolean loop?))
|
||||
(set! (.-value (.-playbackRate node)) rate)
|
||||
(.connect node out)
|
||||
;; `when` 0 is "as soon as the graph can"; the offset is where in the
|
||||
;; buffer to begin. Clamped because starting past the end is a range
|
||||
;; error rather than a no-op in some engines.
|
||||
(.start node 0 (-> (at-renderer st) (max 0) (min (or seconds 0))))
|
||||
node)))
|
||||
|
||||
(defn- spin-down! [{:keys [^js node]}]
|
||||
(when node
|
||||
(try (.stop node) (catch :default _ nil))
|
||||
(try (.disconnect node) (catch :default _ nil))))
|
||||
|
||||
(defn- restart!
|
||||
"Begin a new timeline at `anchor`, running at `rate` or held when it is 0.
|
||||
|
||||
A RESET rather than a segment, for the three cases that have nothing in flight
|
||||
worth honouring: a play starts fresh, a seek means the user has jumped, and a
|
||||
pause wants one stable number for the readout rather than a position that
|
||||
creeps forward as the output buffer drains."
|
||||
[state anchor rate]
|
||||
(let [st @state]
|
||||
(spin-down! st)
|
||||
(swap! state assoc
|
||||
:segs [{:from (.-currentTime ^js (:ctx st)) :anchor anchor :rate rate}]
|
||||
:node nil)
|
||||
(when (pos? rate)
|
||||
(swap! state assoc :node (spin-up! @state)))))
|
||||
|
||||
(deftype Graph [state]
|
||||
t/Transport
|
||||
(-position [_] (position @state))
|
||||
(-duration [_] (:seconds @state))
|
||||
(-playing? [_] (let [st @state] (boolean (and (running? st) (not (done? st))))))
|
||||
(-rate [_] (:rate @state))
|
||||
|
||||
(-set-rate! [_ r]
|
||||
;; A SEGMENT, not a reset — the one case where what is already in flight has
|
||||
;; to keep its old rate or the playhead lurches. See `raw`.
|
||||
(let [st @state
|
||||
now (.-currentTime ^js (:ctx st))]
|
||||
(if (running? st)
|
||||
(let [anchor (at-renderer st)]
|
||||
(swap! state #(-> %
|
||||
(assoc :rate r)
|
||||
(update :segs (fn [segs]
|
||||
(prune (conj segs {:from now :anchor anchor :rate r})
|
||||
(- now (latency ^js (:ctx st))))))))
|
||||
(when-let [^js node (:node st)]
|
||||
(set! (.-value (.-playbackRate node)) r)))
|
||||
;; Stopped: nothing is in flight and nothing is rendering, so the rate
|
||||
;; is simply what the next play will run at.
|
||||
(swap! state assoc :rate r))))
|
||||
|
||||
(-play! [_]
|
||||
(let [st @state]
|
||||
;; `done?` counts as not playing. It is DERIVED — no flag is cleared when
|
||||
;; the take runs off its end — so testing "running" alone would make play
|
||||
;; a dead button from the moment the audio finished.
|
||||
(when (or (not (running? st)) (done? st))
|
||||
;; The context starts suspended and only a gesture may resume it. The
|
||||
;; transport button IS the gesture, so a rejection here means a
|
||||
;; programmatic play and is noise rather than news — as on the element.
|
||||
(some-> (.resume ^js (:ctx st)) (.catch (fn [_])))
|
||||
;; Play at the end starts over, which is what the element does.
|
||||
(restart! state (if (done? st) 0 (position st)) (:rate st)))))
|
||||
|
||||
(-pause! [_]
|
||||
(let [st @state]
|
||||
(when (running? st)
|
||||
;; Anchored at what was HEARD, not at what was scheduled, so play after
|
||||
;; pause resumes from where the sound stopped. It re-plays the last few
|
||||
;; milliseconds rather than skipping them, which is the kinder of the
|
||||
;; two roundings.
|
||||
(restart! state (position st) 0))))
|
||||
|
||||
(-seek! [_ seconds]
|
||||
(restart! state seconds (if (running? @state) (:rate @state) 0)))
|
||||
|
||||
(-set-loop! [_ on?]
|
||||
(swap! state assoc :loop? (boolean on?))
|
||||
;; The audio thread reads `loop` every quantum, so a live node picks this up
|
||||
;; without being restarted — the same as setting `loop` on a playing element.
|
||||
(when-let [^js node (:node @state)]
|
||||
(set! (.-loop node) (boolean on?))))
|
||||
|
||||
(-set-muted! [_ on?]
|
||||
(set! (.-value (.-gain ^js (:out @state))) (if on? 0 1)))
|
||||
|
||||
(-release! [_]
|
||||
;; The source node is wired to the output the whole app shares, so a backend
|
||||
;; dropped while playing would go on being heard under the one that replaced
|
||||
;; it. Nothing else here needs releasing: the context and its gain outlive
|
||||
;; every backend by design.
|
||||
(let [st @state]
|
||||
(spin-down! st)
|
||||
(swap! state assoc
|
||||
:segs [{:from (.-currentTime ^js (:ctx st))
|
||||
:anchor (position st) :rate 0}]
|
||||
:node nil))))
|
||||
|
||||
(defn backend
|
||||
"A clock on `buffer`, `seconds` long. A nil buffer is a silent clock of that
|
||||
length — see the namespace docstring.
|
||||
|
||||
The context and output are injected by the four-argument form so the whole of
|
||||
this is assertable against a fake in node, where there is no Web Audio."
|
||||
([buffer seconds] (backend (context) (output) buffer seconds))
|
||||
([^js ctx ^js out buffer seconds]
|
||||
(->Graph (atom {:ctx ctx :out out :buffer buffer :seconds seconds
|
||||
:rate 1.0 :loop? false :node nil
|
||||
:segs [{:from (.-currentTime ctx) :anchor 0 :rate 0}]}))))
|
||||
38
frontend/src/arthur/clock/transport.cljs
Normal file
38
frontend/src/arthur/clock/transport.cljs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
(ns arthur.clock.transport
|
||||
"What the clock needs of a thing that plays sound, and nothing more.
|
||||
|
||||
`arthur.clock` does the arithmetic — the frame derivation, the clamping, the
|
||||
exposure grid — and a backend only has to answer where the sound has got to
|
||||
and do as it is told. Keeping the protocol this thin is what makes the two
|
||||
implementations comparable: if the graph backend and the element backend
|
||||
disagree about a take's sync, the difference is in these nine methods and not
|
||||
in anything derived from them.
|
||||
|
||||
POSITION IS WHERE THE SPEAKER IS, not where the renderer is. A backend that
|
||||
schedules audio ahead of the output owes the difference back here — see
|
||||
`arthur.clock.graph` — because the picture is drawn against this number and a
|
||||
lip-sync tool that draws against the scheduler leads the sound it is matching.")
|
||||
|
||||
(defprotocol Transport
|
||||
(-position [this]
|
||||
"Seconds into the audio, as heard. Never negative, never past `-duration`,
|
||||
and wrapped rather than clamped while looping.")
|
||||
(-duration [this]
|
||||
"Seconds of audio, or nil when the backend has not been told yet.")
|
||||
(-playing? [this]
|
||||
"Running AND not finished. A backend that has reached its end reports false
|
||||
even if nothing told it to stop, because the end of the sound is the
|
||||
authority on playback having stopped — see `ui/player`'s loop.")
|
||||
(-rate [this])
|
||||
(-set-rate! [this r])
|
||||
(-play! [this])
|
||||
(-pause! [this])
|
||||
(-seek! [this seconds]
|
||||
"Put the audio at `seconds`. Reading `-position` back must give the same
|
||||
number, which is what makes a scrub idempotent.")
|
||||
(-set-loop! [this on?])
|
||||
(-set-muted! [this on?])
|
||||
(-release! [this]
|
||||
"Give up whatever this backend holds, because something else is about to be
|
||||
the clock. NOT a pause: a backend that owns nothing has nothing to do here,
|
||||
and the position it was last at is no longer anybody's business."))
|
||||
|
|
@ -4,14 +4,20 @@
|
|||
port-plan step 3: the hand-written scene plays at 30fps against audio, scrubs,
|
||||
and runs at ½× and ¼×."
|
||||
(:require [arthur.db :as db]
|
||||
[arthur.events.collab :as collab]
|
||||
[arthur.events.footage :as footage]
|
||||
[arthur.events.history :as history]
|
||||
[arthur.events.playback]
|
||||
[arthur.events.paint]
|
||||
[arthur.events.project]
|
||||
[arthur.events.project :as project]
|
||||
[arthur.events.ui]
|
||||
[arthur.subs.playback]
|
||||
[arthur.subs.render]
|
||||
[arthur.subs.ui]
|
||||
[arthur.ui.index :as index]
|
||||
[arthur.ui.player :as player]
|
||||
[arthur.ui.shell :as shell]
|
||||
[arthur.ui.tools :as tools]
|
||||
[re-frame.core :as rf]
|
||||
[reagent.dom.client :as rdc]))
|
||||
|
||||
|
|
@ -24,14 +30,24 @@
|
|||
;; the loop would otherwise sit on an unchanged frame number and never redraw.
|
||||
(rf/clear-subscription-cache!)
|
||||
(player/refresh-subs!)
|
||||
(rdc/render @root [shell/view]))
|
||||
(rdc/render @root [:<> [shell/view] [index/view]]))
|
||||
|
||||
(defn init []
|
||||
(rf/dispatch-sync [::init])
|
||||
;; A blank document, before the first render. Synchronous for the same reason
|
||||
;; `::init` is: the shell reads the clip's dimensions, and mounting against a
|
||||
;; db that has no clip in it yet is a frame of nothing for no reason.
|
||||
(rf/dispatch-sync [::project/new])
|
||||
;; What the server already holds, asked for once. The list is small — a row per
|
||||
;; ingested take — and having it before the first click is what lets the footage
|
||||
;; picker be a picker rather than a path to type.
|
||||
(rf/dispatch [::footage/refresh])
|
||||
(rf/dispatch [::project/list-symbols])
|
||||
;; After the blank document, so an address that names a project opens it over
|
||||
;; the blank one, and the blank one is what a bad address leaves on screen.
|
||||
(collab/start!)
|
||||
(history/install-keys!)
|
||||
(tools/install-keys!)
|
||||
(reset! root (rdc/create-root (js/document.getElementById "app")))
|
||||
(mount)
|
||||
(player/start!))
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -31,9 +29,7 @@
|
|||
;; serves by hash — and the synthetic take needs a sound of its own so
|
||||
;; 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)}
|
||||
:cid (name label-key)}
|
||||
(select-keys clip [:fps :width :height])))
|
||||
|
||||
(def clips
|
||||
|
|
@ -52,9 +48,29 @@
|
|||
(defn clip-entry [id]
|
||||
(some-> (get-in clips [id :entry]) deref))
|
||||
|
||||
(def tracing
|
||||
"How tracing layers show on the stage: all of them or none, how strongly, and
|
||||
which ones are switched off, as `[symbol-id node-id]` — the symbol a tracing
|
||||
placement is in and its id, so a face's footage is one layer wherever the face
|
||||
is placed.
|
||||
|
||||
THE EDITOR'S, NOT THE DOCUMENT'S. Showing a reference is a way of looking at
|
||||
the stage, like solo and zoom: not an undo step, not sent to collaborators,
|
||||
and it cannot reach an export. A layer is shown unless it is in `:hidden`, so
|
||||
footage brought in shows without being found and switched on first — once
|
||||
tracing itself is on, which it is not until asked for."
|
||||
{:on? false :opacity 0.5 :hidden #{}})
|
||||
|
||||
(def default
|
||||
{;; --- the document ---
|
||||
:clip/current :take
|
||||
;;
|
||||
;; NOTHING IS LOADED. `core/init` dispatches `::project/new` before the first
|
||||
;; render, so the app opens on a blank stage rather than on whichever built-in
|
||||
;; scene happened to be convenient — the demos, the swarm and the two takes are
|
||||
;; rows in the media pool like anything else, and reference material is not a
|
||||
;; default. The values below are what a blank document is; they are replaced by
|
||||
;; that dispatch and exist so this map is a valid db on its own.
|
||||
:clip/current nil
|
||||
:paint/revision 0
|
||||
:palette :arthur/default ; a NAME; the ramp itself is project data
|
||||
|
||||
|
|
@ -64,19 +80,31 @@
|
|||
;; footage's. That is what deleting `makeXform` buys — the framing became a
|
||||
;; transform on a node, so nothing downstream of the freeze knows the frame
|
||||
;; size — and it is why ui/player no longer hardcodes 320x200.
|
||||
:clip (select-keys (clip-entry :take) [:fps :frames :width :height :audio :display-fps])
|
||||
:clip (let [c (domain-clip/blank)]
|
||||
{:fps (:fps c)
|
||||
:width (:width c) :height (:height c)
|
||||
:audio nil})
|
||||
|
||||
;; Which ingested footage to detect, and what the last load said. The list
|
||||
;; comes from the server — tier 3 is the backend's since step 9 — so there is
|
||||
;; no path to type any more.
|
||||
:footage {:id nil :label nil :loading? false :status nil
|
||||
:available [] :chosen nil}
|
||||
:available [] :chosen nil :uploaded #{}}
|
||||
|
||||
;; Every symbol in every saved project, for the pool's all-assets folder. Rows
|
||||
;; from `/api/symbols`, nothing loaded: a symbol from elsewhere is fetched when
|
||||
;; it is dropped.
|
||||
:assets {:symbols [] :palettes [] :loading? false}
|
||||
|
||||
;; The document's own identity on the server. `:seq` is the monotonic project
|
||||
;; version: a client that sees a delta with `seq > local + 1` refetches, which
|
||||
;; is what will make staleness self-healing once there is a broadcast to miss.
|
||||
:project {:id nil :cid nil :name nil :seq nil :busy? false :status nil}
|
||||
|
||||
;; What the server holds, for the open menu. A list of rows and nothing more —
|
||||
;; opening one fetches the document itself.
|
||||
:projects {:items [] :loading? false}
|
||||
|
||||
;; --- transport ---
|
||||
;;
|
||||
;; The playhead is in app-db like everything else. An earlier draft of
|
||||
|
|
@ -92,11 +120,12 @@
|
|||
;; machinery that would share it.
|
||||
;; --- export ---
|
||||
;;
|
||||
;; The REQUEST and its progress, never the frames. Which timeline to write and
|
||||
;; The REQUEST and its progress, never the frames. Which symbol to write and
|
||||
;; at what integer zoom is authored state like anything else; the megabytes the
|
||||
;; render produces are handed straight to a download and never enter the db.
|
||||
;; `:isolate` is the placement to render alone, or nil for the whole timeline.
|
||||
:export {:timeline :main :isolate nil :zoom 4 :busy? false :done 0 :total 0
|
||||
;; `:isolate` is the placement to render alone, or nil for the whole symbol;
|
||||
;; `:symbol` nil means whichever symbol is open.
|
||||
:export {:symbol nil :isolate nil :zoom 4 :busy? false :done 0 :total 0
|
||||
:status nil}
|
||||
|
||||
:playback {:frame 0
|
||||
|
|
@ -105,7 +134,52 @@
|
|||
;; Both for profiling: loop so a run at 4x lasts longer than the
|
||||
;; clip, mute so sitting in one does not require enduring it.
|
||||
:loop? false
|
||||
:muted? false}})
|
||||
:muted? false}
|
||||
|
||||
;; --- the editor's own state ---
|
||||
;;
|
||||
;; IN app-db, not in ratoms beside the components that read it. What is
|
||||
;; selected is asked by four panes at once — the params pane renders it, the
|
||||
;; timeline highlights its row, the stage draws its handles, the palette says
|
||||
;; which tone a new shape gets — and a `defonce` atom private to one namespace
|
||||
;; can only be shared by making the other three require that namespace for its
|
||||
;; state. It is also small and authored, which is the bar `arthur.db` sets.
|
||||
;;
|
||||
;; `:selection` is a vector whose first element says what kind of thing it
|
||||
;; names, so a pane dispatches on it rather than on which of several
|
||||
;; "selected-x" keys happens to be non-nil:
|
||||
;;
|
||||
;; [:node <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 {:open nil
|
||||
:tabs []
|
||||
:selection nil
|
||||
:selections []
|
||||
:tone 1
|
||||
:tool :select
|
||||
:brush 6
|
||||
:auto-key? false
|
||||
:draft []
|
||||
:knobs {}
|
||||
:tracing tracing
|
||||
:expanded #{}}})
|
||||
|
||||
(def rates
|
||||
"The transport's rates — all of them `playbackRate` on the audio element, so
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
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.palette :as pal]
|
||||
[arthur.domain.symbol :as symbol]
|
||||
[cljs.reader :as reader]
|
||||
[shadow.resource :as rc]))
|
||||
|
||||
|
|
@ -14,15 +15,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 nil pal/index-of nil))
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
:width 320
|
||||
:height 200
|
||||
|
||||
:timelines
|
||||
:symbols
|
||||
{:main
|
||||
{:id :main
|
||||
:frames 229
|
||||
|
|
|
|||
|
|
@ -6,8 +6,12 @@
|
|||
|
||||
(def layout (reader/read-string (rc/inline "arthur/demo/stage_8625.edn")))
|
||||
|
||||
(defn- position-track [center anchor drift phase frames]
|
||||
(let [base (mapv - center anchor)
|
||||
(defn- position-track
|
||||
"Where the peg sits over time: its `center` plus a slow two-axis drift. The
|
||||
peg's own position, so the stage point the face is pinned to is what moves —
|
||||
not an offset that has to be kept in step with a changing scale."
|
||||
[center drift phase frames]
|
||||
(let [base center
|
||||
[dx dy] drift
|
||||
wave (fn [f period] (js/Math.sin (* 2 js/Math.PI (/ (+ f phase) period))))
|
||||
x0 (wave 0 96)
|
||||
|
|
@ -22,52 +26,95 @@
|
|||
(defn compose
|
||||
"The authored layout plus a source clip -> the composed stage document.
|
||||
|
||||
A PLACEMENT IS KEYED BY ITS :uuid, not by the authored id. The authored id
|
||||
A PEG'S IDENTITY IS AUTHORED TOO, beside its instance's, for the reason the
|
||||
instance's is: `compose` is a pure function of the layout, so a generated one
|
||||
would make the same stage a different document on every call.
|
||||
|
||||
AN INSTANCE IS KEYED BY ITS :uuid, not by the authored id. The authored id
|
||||
(`:left`, `:voice-right`) is a handle for reading the EDN and for the
|
||||
`:linked-to` written there; it does not appear in the document this returns.
|
||||
What replaces it is an identity that means one placement and nothing else: seven
|
||||
What replaces it is an identity that means one instance and nothing else: seven
|
||||
instances of one symbol are seven different things to name — to export on their
|
||||
own, to link a voice to, to point at later — and an id like `:left` is a
|
||||
description of where a thing sits, which is exactly what changes when the stage
|
||||
is re-arranged. `:name` carries the label for a human and `:of` carries the
|
||||
symbol, so the node still says what it is and which drawing it plays."
|
||||
is re-arranged. `:name` carries the label for a human and `:source :symbol`
|
||||
carries the symbol, so the node still says what it is and which drawing it
|
||||
plays — and `:playback` says how time runs inside it, which is a separate
|
||||
question from which drawing that is."
|
||||
[source]
|
||||
(let [{:keys [name width height frames symbol instances audio scale]} layout
|
||||
default-anchor (or (:anchor layout)
|
||||
;; Which point of the SOURCE is pinned to the stage `:center`. Its
|
||||
;; middle, unless the layout says otherwise for an off-centre drawing.
|
||||
default-origin (or (:origin 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.
|
||||
by-id (into {} (map (juxt :id :uuid)) (concat instances audio))
|
||||
uuid-of (fn [what id]
|
||||
(or (get by-id id)
|
||||
(throw (ex-info "the stage layout names a placement that is not there"
|
||||
(throw (ex-info "the stage layout names an instance that is not there"
|
||||
{:in what :id id
|
||||
:known (vec (sort-by str (keys by-id)))}))))
|
||||
;; EVERY PLACEMENT IS A PEG AND AN INSTANCE, and this layout is what
|
||||
;; makes the pair necessary rather than tidy. `:scale` is KEYED — the
|
||||
;; faces pulse — and it has to happen about the point the face is pinned
|
||||
;; to. A stored `[:xform :anchor]` used to buy that: a static position
|
||||
;; and a moving scale, turning about a fixed point. No static position
|
||||
;; can do it alone, because `T(pos)·S(k(f))` moves the source's middle
|
||||
;; whenever `k` changes, so holding it still would mean keying `pos` in
|
||||
;; lockstep with `scale` — two channels that have to agree frame for
|
||||
;; frame, which is the thing channels exist to avoid.
|
||||
;;
|
||||
;; A peg does it with neither:
|
||||
;;
|
||||
;; peg pos = center (+ drift), scale = k(f)
|
||||
;; └ face pos = -origin
|
||||
;;
|
||||
;; world = T(center)·S(k(f))·T(-origin)
|
||||
;;
|
||||
;; which takes `origin` to `center` for EVERY k, with nothing keyed that
|
||||
;; was not keyed before. That is the same matrix the anchor produced —
|
||||
;; `node-test` asserts the identity — and it is reachable, keyable and
|
||||
;; selectable, which the anchor was not.
|
||||
;;
|
||||
;; THE PEG IS THE PLACEMENT: it carries WHERE (pos, scale) and WHEN
|
||||
;; (`:at`, `:span`), and the face under it carries only which drawing and
|
||||
;; the offset to its origin. The time map has to be the peg's, because
|
||||
;; `:scale` is read in the placement's own frames — that is what staggers
|
||||
;; the entrances' growth — and `:span` goes with it so that
|
||||
;; `node/placed-span` still answers where the placement sits on the stage.
|
||||
;; The face then reads its peg's frames as its own and shows whenever the
|
||||
;; peg does.
|
||||
nodes (into
|
||||
{: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
|
||||
(mapcat (fn [{:keys [uuid peg name z span at center origin drift phase]}]
|
||||
(let [origin (or origin default-origin)]
|
||||
[[peg {:id peg :name name :kind :group
|
||||
:parent :root :z z :span span
|
||||
:time {:mode :map :at at :in in :rate 1}
|
||||
:channels {[:xform :pos] (if drift
|
||||
(position-track center anchor drift phase frames)
|
||||
(ch/framed (mapv - center anchor)))
|
||||
[:xform :anchor] {:animated? false :value anchor}
|
||||
[:xform :scale] scale}}]))
|
||||
:time {:mode :map :at at :rate 1}
|
||||
:channels {[:xform :pos]
|
||||
(if drift
|
||||
(position-track center drift phase frames)
|
||||
(ch/framed center))
|
||||
[:xform :scale] scale}}]
|
||||
[uuid {:id uuid :name (str name " face") :kind :instance
|
||||
:parent peg :z "a1"
|
||||
:source {:symbol symbol}
|
||||
:channels {[:xform :pos]
|
||||
(ch/framed (mapv - origin))}}]]))
|
||||
instances))
|
||||
nodes (into nodes
|
||||
(map (fn [{:keys [uuid linked-to z source span at in gain pan]}]
|
||||
(map (fn [{:keys [uuid linked-to z source span at gain pan]}]
|
||||
[uuid {:id uuid :kind :audio :parent :root :z z
|
||||
:linked-to (uuid-of uuid linked-to)
|
||||
:source source :span span
|
||||
:time {:mode :map :at at :in in :rate 1}
|
||||
:time {:mode :map :at at :rate 1}
|
||||
:channels (cond-> {[:audio :gain] gain}
|
||||
pan (assoc [:audio :pan] pan))}])
|
||||
audio))]
|
||||
(assoc source :name name :width width :height height
|
||||
:timelines (assoc (:timelines source)
|
||||
:symbols (assoc (:symbols source)
|
||||
:main {:id :main :frames frames :nodes nodes}
|
||||
symbol (assoc original :id symbol)))))
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@
|
|||
:symbol :sym/face-8625
|
||||
:name "8625 stage study"
|
||||
:width 320 :height 200 :frames 280
|
||||
;; Each :center below places the source clip's center on the stage. A symbol
|
||||
;; can author :anchor to override that default for an off-center drawing.
|
||||
;; Each :center below places the source clip's center on the stage; :origin
|
||||
;; overrides which point of the source that is, for an off-centre drawing.
|
||||
;; :peg is the identity of the transform node the placement hangs off — it
|
||||
;; carries :center and :scale, the face below it carries -:origin, and that pair
|
||||
;; is what makes a KEYED scale happen about the pinned point. See demo/stage.
|
||||
;; Each placement reads this pulse in its own local time, so the staggered
|
||||
;; entrances start their growth at different moments on the master timeline.
|
||||
:scale {:animated? true :interp :linear
|
||||
|
|
@ -19,16 +22,18 @@
|
|||
:over []}
|
||||
;; Audio placements are ordinary timeline nodes with channel parameters.
|
||||
;; :linked-to is an editorial link; their spans and time maps are independent.
|
||||
;; A span is in the placement's OWN frames and :at is where its frame 0 lands on
|
||||
;; the stage, so every entrance below plays from its own start.
|
||||
:audio
|
||||
[{:id :voice-left :uuid #uuid "eeaa49c3-1238-469f-bf54-44929e379f6b"
|
||||
:linked-to :left :z "a3"
|
||||
:source {:footage "f8cace9e-4ad3-4796-973c-c62eeebe3d01"}
|
||||
:span [0 280] :at 0 :in 0
|
||||
:at 0 :span [0 280]
|
||||
:gain {:animated? false :value 1.0}}
|
||||
{:id :voice-right :uuid #uuid "468239dd-0e3a-4e5c-ac6f-1858430a0355"
|
||||
:linked-to :right :z "a4"
|
||||
:source {:footage "f8cace9e-4ad3-4796-973c-c62eeebe3d01"}
|
||||
:span [48 260] :at 48 :in 0
|
||||
:at 48 :span [0 212]
|
||||
:gain {:animated? true :interp :linear
|
||||
:keys {48 0.0, 60 1.0, 90 0.35, 115 0.9, 145 0.45,
|
||||
170 1.0, 195 0.4, 220 0.85, 245 1.0, 259 0.0}
|
||||
|
|
@ -48,30 +53,37 @@
|
|||
;; uuid. Nothing downstream of `compose` sees the authored id.
|
||||
:instances
|
||||
[{:id :left :uuid #uuid "ee7321c8-faf1-46d7-8029-37771898accb"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f01"
|
||||
:name "8625 left" :z "a1"
|
||||
:span [0 280] :at 0 :in 0
|
||||
:at 0 :span [0 280]
|
||||
:center [40 40] :drift [3 2] :phase 0}
|
||||
{:id :right :uuid #uuid "1aa0da78-b4ed-4bb6-8d70-b09a3ec5e2c3"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f02"
|
||||
:name "8625 right" :z "a2"
|
||||
:span [48 280] :at 48 :in 0
|
||||
:at 48 :span [0 232]
|
||||
:center [120 40] :drift [-3 2] :phase 17}
|
||||
{:id :top-third :uuid #uuid "23bb697d-eba7-4af6-a86c-606c50107088"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f03"
|
||||
:name "8625 top third" :z "a5"
|
||||
:span [24 280] :at 24 :in 0
|
||||
:at 24 :span [0 256]
|
||||
:center [200 40] :drift [2 -3] :phase 31}
|
||||
{:id :top-fourth :uuid #uuid "f4f0241d-026e-4e50-9bea-a4ccde896d8a"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f04"
|
||||
:name "8625 top fourth" :z "a6"
|
||||
:span [72 280] :at 72 :in 0
|
||||
:at 72 :span [0 208]
|
||||
:center [280 40] :drift [-2 -2] :phase 49}
|
||||
{:id :bottom-left :uuid #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f05"
|
||||
:name "8625 bottom left" :z "a7"
|
||||
:span [96 280] :at 96 :in 0
|
||||
:at 96 :span [0 184]
|
||||
:center [70 135] :drift [3 -2] :phase 63}
|
||||
{:id :bottom-middle :uuid #uuid "63f3fb32-9e94-4d68-a1c2-12e6de2d04b5"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f06"
|
||||
:name "8625 bottom middle" :z "a8"
|
||||
:span [120 280] :at 120 :in 0
|
||||
:at 120 :span [0 160]
|
||||
:center [160 135] :drift [-2 3] :phase 81}
|
||||
{:id :bottom-right :uuid #uuid "fa338701-cb21-4d45-89f1-a5e706f045ec"
|
||||
:peg #uuid "b1e0a7c4-5f3d-4a8e-9c21-70e4d1a90f07"
|
||||
:name "8625 bottom right" :z "a9"
|
||||
:span [144 280] :at 144 :in 0
|
||||
:at 144 :span [0 136]
|
||||
:center [250 135] :drift [2 2] :phase 107}]}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -95,4 +95,4 @@
|
|||
|
||||
(def locked
|
||||
"The same blocks, with `:head` held at measured frame zero."
|
||||
(delay (freeze/head-mode {:mode :anchored :anchors {0 0}} @frozen)))
|
||||
(delay (freeze/head-mode {:trace {:origin :start}} @frozen)))
|
||||
|
|
|
|||
151
frontend/src/arthur/domain/bring.cljs
Normal file
151
frontend/src/arthur/domain/bring.cljs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
(ns arthur.domain.bring
|
||||
"Bringing symbols into a clip from another: out of a saved project, or out of
|
||||
a freeze of new footage.
|
||||
|
||||
Copied, never linked. What comes in gets ids of its own where they are taken,
|
||||
and editing it here does not touch where it came from. Tracking identities and
|
||||
the analysis they were measured by come along only when the receiving clip can
|
||||
hold them; otherwise what comes in is drawing, which plays but does not re-tune.
|
||||
|
||||
Plain data in and out — documents, and in `placed` a document with its store —
|
||||
so the events that fetch them are only fetching."
|
||||
(:refer-clojure :exclude [take])
|
||||
(:require [arthur.domain.clip :as clip]
|
||||
[arthur.domain.feature :as feature]
|
||||
[arthur.domain.node :as node]
|
||||
[clojure.string :as string]))
|
||||
|
||||
(defn symbols
|
||||
"Copy symbols `roots` of clip `other`, and every symbol they place, into
|
||||
`clip`. Returns `{:clip :ids}`, where `:ids` maps each copied symbol's id in
|
||||
`other` to its id here.
|
||||
|
||||
AN ID THAT IS TAKEN IS RENAMED, never merged: two symbols that happen to share
|
||||
an id are two drawings, and a cel's `:source :symbol` inside the copy is
|
||||
rewritten to follow. `wanted` maps a root's id in `other` to the id it should preferably get,
|
||||
which is how a symbol made from footage is called what the person typed rather
|
||||
than `:main`.
|
||||
|
||||
Only symbols travel. What else `other` holds — tracking identities, an analysis
|
||||
— is the caller's decision, because whether it can come too depends on what
|
||||
`clip` already has."
|
||||
[clip other roots wanted]
|
||||
(let [;; A tree walk is safe because placing cannot make a cycle.
|
||||
reach (into #{} (mapcat #(tree-seq any? (partial clip/places other) %)) roots)
|
||||
ids (reduce (fn [ids sid]
|
||||
(let [taken? #(or (contains? (:symbols clip) %)
|
||||
(some #{%} (vals ids)))]
|
||||
(assoc ids sid (clip/free-id taken? (get wanted sid sid)))))
|
||||
{} (sort-by str reach))
|
||||
;; Cel identity and timing stay put; content references follow
|
||||
;; the symbol IDs assigned in the destination document.
|
||||
repoint (fn [n ids]
|
||||
(if (node/source n)
|
||||
(update-in n [:source :symbol] ids)
|
||||
n))
|
||||
copy (fn [sid]
|
||||
(-> (clip/symbol other sid)
|
||||
(assoc :id (ids sid) :fps (or (:fps (clip/symbol other sid)) (:fps other)))
|
||||
(update :nodes #(into {} (map (fn [[id n]] [id (repoint n ids)])) %))))]
|
||||
{:clip (reduce (fn [c sid] (assoc-in c [:symbols (ids sid)] (copy sid))) clip reach)
|
||||
:ids ids}))
|
||||
|
||||
(defn symbol-id
|
||||
"An id for a symbol a person has named: the name, lower-cased and hyphenated,
|
||||
or `:symbol` when nothing of it survives."
|
||||
[label]
|
||||
(let [slug (-> (str label) string/lower-case
|
||||
(string/replace #"[^a-z0-9]+" "-")
|
||||
(string/replace #"^-+|-+$" ""))]
|
||||
(keyword (if (seq slug) slug "symbol"))))
|
||||
|
||||
(defn take
|
||||
"Put `frozen`, a take, into `clip` as ONE symbol called `label`. Returns the
|
||||
changed clip, the imported symbol id and the old-to-scoped subject ids.
|
||||
|
||||
`frozen` is what `flow/freeze/clip` makes: a `:main` that places one symbol per
|
||||
tracked face. `:main` becomes the named symbol — it is what holds the faces in
|
||||
stage pixels, so it is the thing worth placing. Each generated face symbol gets
|
||||
an automatic association with the take's SOUND, source frames `range` of
|
||||
footage `footage-id`. Thus the take is heard through the faces it places, and
|
||||
a face subsequently placed by itself still brings its sound. This is the same
|
||||
shape a future manual symbol/sound association can write; dropping a face is
|
||||
not a special operation.
|
||||
|
||||
A multi-face take consequently reaches the same recording through several
|
||||
symbols. `nest/audio-tracks` collapses simultaneous copies carrying the same
|
||||
`:media-link`; placing those faces at different times still schedules each one.
|
||||
|
||||
The unique imported symbol id scopes every subject id. Thus two takes may both
|
||||
arrive with detector subject `:face-1` without colliding in the document."
|
||||
[clip frozen label footage-id range]
|
||||
(let [scope (fn [root subject] (keyword (str (name root) "." (name subject))))
|
||||
root (clip/free-id
|
||||
(fn [candidate]
|
||||
(or (contains? (:symbols clip) candidate)
|
||||
(some #(contains? (:symbols clip) (scope candidate %))
|
||||
(keys (:subjects frozen)))))
|
||||
(symbol-id label))
|
||||
scoped (partial scope root)
|
||||
wanted (into {:main root :footage (scoped :footage)}
|
||||
(map (fn [subject] [subject (scoped subject)]))
|
||||
(keys (:subjects frozen)))
|
||||
{c :clip ids :ids} (symbols clip frozen [:main] wanted)
|
||||
sid (ids :main)
|
||||
faces (mapv ids (sort-by str (keys (:subjects frozen))))
|
||||
sound {:id :sound :name "sound" :kind :audio :parent nil
|
||||
:z "z-sound" :source {:footage footage-id}
|
||||
;; All automatic copies name one recording. The audio walk
|
||||
;; uses this identity only to avoid mixing that recording once
|
||||
;; per detected face when the complete take is played.
|
||||
:media-link [:footage footage-id range]
|
||||
;; Source frame `start` plays on the symbol's 0.
|
||||
:span range
|
||||
:time {:mode :map :at (- (first range)) :rate 1}}
|
||||
feature-ids (into {}
|
||||
(map (fn [[id f]]
|
||||
[id (feature/owned (ids (:subject f)) (keyword (name id)))]))
|
||||
(:features frozen))
|
||||
subjects (into {}
|
||||
(map (fn [[id subject]]
|
||||
(let [new-id (ids id)]
|
||||
[new-id (assoc subject :id new-id
|
||||
:footage footage-id)])))
|
||||
(:subjects frozen))
|
||||
features (into {}
|
||||
(map (fn [[id f]]
|
||||
(let [new-id (feature-ids id)]
|
||||
[new-id (-> f
|
||||
(assoc :id new-id
|
||||
:subject (ids (:subject f))
|
||||
:symbol (ids (:symbol f))))])))
|
||||
(:features frozen))
|
||||
groups (into {}
|
||||
(map (fn [[id g]]
|
||||
(let [new-subject (ids (:subject g))
|
||||
new-id (feature/owned new-subject (keyword (name id)))]
|
||||
[new-id (-> g
|
||||
(assoc :id new-id :subject new-subject)
|
||||
(update :members #(mapv feature-ids %)))])))
|
||||
(:groups frozen))]
|
||||
{:sid sid
|
||||
:subject-ids (select-keys ids (keys (:subjects frozen)))
|
||||
:clip (-> (reduce (fn [document face]
|
||||
(assoc-in document [:symbols face :nodes :sound] sound))
|
||||
c faces)
|
||||
(assoc-in [:symbols sid :name] (str label))
|
||||
(update :analyses merge (:analyses frozen))
|
||||
(update :subjects merge subjects)
|
||||
(update :features merge features)
|
||||
(update :groups merge groups))}))
|
||||
|
||||
|
||||
(defn placed
|
||||
"`entry` — a document and its store — once `brought` holds the symbols brought
|
||||
in and `store` their blocks: the stores merged and an instance of `sid` placed
|
||||
in `host` at `frame`, its middle on stage pixel `point` or where it was drawn
|
||||
when there is none. See `clip/place-symbol`."
|
||||
[entry brought store sid host frame uuid point]
|
||||
(let [st (merge (:store entry) store)]
|
||||
(assoc entry :store st :clip (clip/place-symbol brought st host sid
|
||||
(* frame (:rate (clip/grid-time brought host))) uuid point))))
|
||||
25
frontend/src/arthur/domain/cadence.cljs
Normal file
25
frontend/src/arthur/domain/cadence.cljs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
(ns arthur.domain.cadence
|
||||
"Select integer content frames across frame grids. Stored frames never change.")
|
||||
|
||||
(defn ratio [grid native]
|
||||
(if (and grid native (pos? grid) (pos? native)) (/ native grid) 1))
|
||||
|
||||
(defn frame
|
||||
"Latest native frame at or before reader frame f. Never sample the future."
|
||||
[f grid native]
|
||||
(js/Math.floor (if (and grid native) (/ (* f native) grid) f)))
|
||||
|
||||
(defn frames
|
||||
"Reader frames covering a native length, including a partial final frame."
|
||||
[n grid native]
|
||||
(when n (max 1 (js/Math.ceil (/ n (ratio grid native))))))
|
||||
|
||||
(defn reader-frame
|
||||
"The earliest reader frame whose `frame` is at or after native frame n — the
|
||||
inverse of `frame`, as far as it has one.
|
||||
|
||||
`frame` is a floor, so several reader frames can show one native frame and a
|
||||
native frame between two of them is shown by none: this answers with the
|
||||
reader frame that first reaches it, which is what seeking to a mark means."
|
||||
[n grid native]
|
||||
(js/Math.ceil (if (and grid native) (/ (* n grid) native) n)))
|
||||
|
|
@ -68,8 +68,11 @@
|
|||
(defn framed [v] {:animated? false :value v})
|
||||
|
||||
(defn keyed
|
||||
([ks] (keyed ks :hold))
|
||||
([ks interp] {:animated? true :interp interp :keys ks :over []}))
|
||||
"A channel of keys, and how each one leads to the next. `interp` is an
|
||||
argument, never a default: `:hold` and `:linear` are the difference between a
|
||||
cut and a tween, which is the whole content of the channel."
|
||||
[ks interp]
|
||||
{:animated? true :interp interp :keys ks :over []})
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
|
|
@ -91,17 +94,203 @@
|
|||
(when-let [ks (:keys ch)]
|
||||
(vec (sort (keys ks)))))
|
||||
|
||||
(defn- check-unimplemented!
|
||||
"An override layer must fail LOUDLY rather than be ignored.
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; correction layers
|
||||
;;
|
||||
;; `:over` is an ORDERED STACK on top of whatever the channel already says.
|
||||
;; Generated motion stays the base; a hand correction is a layer above it, so
|
||||
;; regenerating replaces the base and the corrections survive. That is the whole
|
||||
;; reason the stack exists rather than the hand edit being written into the keys.
|
||||
;;
|
||||
;; A LAYER'S VALUES ARE A CHANNEL. A constant adjustment is a framed one, a ramp
|
||||
;; or a return motion is a keyed one, and neither needs a second way of saying
|
||||
;; what a value is over time: layers read through `value-at` and `cursor` like
|
||||
;; anything else, which is also what stops the fast path and the specification
|
||||
;; from being two implementations of blending.
|
||||
;;
|
||||
;; A LAYER HAS NO TIME SPACE OF ITS OWN. Its `:support` and its values' keys are
|
||||
;; in the frames the base channel's keys are in — the node's own. A correction on
|
||||
;; a lane is therefore in lane frames and crosses the drawing boundaries under
|
||||
;; it; a correction on one cel is in that cel's frames and travels
|
||||
;; with it when it moves. Ownership already answered the question, so there is no
|
||||
;; field to get wrong.
|
||||
|
||||
Silently dropping an :over layer would present as a hand
|
||||
correction that did not take — a correction the user made once, watched fail,
|
||||
and has no reason to trust again. Nothing can produce one yet, so this can
|
||||
only fire on a data shape that has run ahead of the code."
|
||||
(defn layer
|
||||
"One correction: `values` applied to the base wherever `support` covers the
|
||||
frame. `op` is `:offset` or `:replace`."
|
||||
[id support op values]
|
||||
{:id id :support support :op op :values values})
|
||||
|
||||
(defn- covers?
|
||||
"Half-open, as a span is: a correction over frames 10 to 12 is `[10 13)`."
|
||||
[[in out] f]
|
||||
(and (<= in f) (< f out)))
|
||||
|
||||
(defn- width
|
||||
"Components in a value, or nil for a number. A dense value is a typed-array
|
||||
view, an authored one a vector, and a correction has to add to either."
|
||||
[v]
|
||||
(cond (number? v) nil (vector? v) (count v) :else (.-length v)))
|
||||
|
||||
(defn- shape
|
||||
"What kind of value this is, for asking whether one can be added to another:
|
||||
`:scalar`, a component count, or `:opaque` for a value that is neither — a
|
||||
`[:vis]` boolean is opaque, and can be replaced but not offset."
|
||||
[v]
|
||||
(cond
|
||||
(number? v) :scalar
|
||||
(vector? v) (count v)
|
||||
(and (some? v) (number? (.-length v))) (.-length v)
|
||||
:else :opaque))
|
||||
|
||||
(defn value-shape
|
||||
"The shape of the values a channel yields, without sampling it, or nil where
|
||||
there is nothing to read it off — an empty key map says nothing about what its
|
||||
values would have been, and nil must not be taken for a scalar."
|
||||
[ch]
|
||||
(when (seq (:over ch))
|
||||
(throw (ex-info "channel has :over layers and the override layer is not built (port-plan step 2 scope)"
|
||||
{:over (:over ch) :channel (dissoc ch :dense)}))))
|
||||
(cond
|
||||
(not (:animated? ch)) (when (some? (:value ch)) (shape (:value ch)))
|
||||
(:dense ch) (if (= 1 (:stride (:dense ch))) :scalar (:stride (:dense ch)))
|
||||
(seq (:keys ch)) (shape (val (first (:keys ch))))
|
||||
:else nil))
|
||||
|
||||
(defn- shape-conflict [base-shape correction-shape]
|
||||
(cond
|
||||
(or (nil? base-shape) (nil? correction-shape)) nil
|
||||
(= :opaque base-shape) "the base is not a number or a row of components"
|
||||
(= :opaque correction-shape) "the correction is not a number or a row of components"
|
||||
(not= base-shape correction-shape)
|
||||
(str "the base has " (pr-str base-shape) " and the correction "
|
||||
(pr-str correction-shape) " — a correction cannot offset a value of"
|
||||
" a different shape")))
|
||||
|
||||
(defn conflict-with
|
||||
"Why correction `l` cannot apply to base channel `base`, or nil.
|
||||
|
||||
ONLY `:offset` can conflict. It adds component by component, so it needs the
|
||||
base to have the components it has — which is what a topology change takes
|
||||
away when a re-freeze gives a mouth a different number of points. `:replace`
|
||||
states a whole value and so has nothing to agree with.
|
||||
|
||||
Shapes that cannot be read yet do not conflict: an empty key map is not a
|
||||
disagreement, it is a channel with nothing in it."
|
||||
[base l]
|
||||
(when (= :offset (:op l))
|
||||
(shape-conflict (value-shape base) (value-shape (:values l)))))
|
||||
|
||||
(defn- support-of [l]
|
||||
(let [s (:support l)]
|
||||
(when (and (vector? s) (= 2 (count s))
|
||||
(every? number? s) (< (first s) (second s)))
|
||||
s)))
|
||||
|
||||
(defn stack-conflict
|
||||
"Why layer `i` can encounter a value of the wrong shape after the active
|
||||
layers before it, or nil.
|
||||
|
||||
Replacement coverage is considered at every interval boundary. This matters
|
||||
when adjacent replacements jointly cover an offset: neither covers its whole
|
||||
support, but the base can never reach it. A conflicted replacement is skipped,
|
||||
exactly as the evaluator skips it."
|
||||
[ch i]
|
||||
(let [l (nth (:over ch) i nil)]
|
||||
(when (and (= :offset (:op l)) (support-of l))
|
||||
(let [[a b] (support-of l)
|
||||
prior (take i (:over ch))
|
||||
cuts (->> prior
|
||||
(keep support-of)
|
||||
(mapcat identity)
|
||||
(filter #(< a % b))
|
||||
(into [a b])
|
||||
distinct sort)
|
||||
;; Shape at a point is the last active, nonempty replacement's
|
||||
;; shape, or the base shape when no replacement supplies a value.
|
||||
at (fn [f]
|
||||
(or (last (keep (fn [p]
|
||||
(let [s (support-of p)
|
||||
v (value-shape (:values p))]
|
||||
(when (and (= :replace (:op p))
|
||||
(not (:conflict p)) v s
|
||||
(covers? s f))
|
||||
v)))
|
||||
prior))
|
||||
(value-shape ch)))
|
||||
shapes (into #{} (map (fn [[x y]] (at (/ (+ x y) 2))))
|
||||
(partition 2 1 cuts))
|
||||
v (value-shape (:values l))]
|
||||
(some #(shape-conflict % v) shapes)))))
|
||||
|
||||
(defn reconcile
|
||||
"Recheck an ordered layer stack against this channel's base.
|
||||
|
||||
Old conflict marks are findings from an earlier base, so they are cleared and
|
||||
recomputed in order. A newly conflicted replacement is then invisible to the
|
||||
layers after it, matching evaluation. Nothing is dropped or reordered."
|
||||
[ch]
|
||||
(let [layers (mapv #(dissoc % :conflict) (:over ch))]
|
||||
(reduce (fn [out l]
|
||||
(let [candidate (assoc ch :over (conj out l))
|
||||
why (stack-conflict candidate (count out))]
|
||||
(conj out (cond-> l why (assoc :conflict why)))))
|
||||
[] layers)))
|
||||
|
||||
(defn conflicts
|
||||
"Corrections on `ch` that cannot apply to its base, as `[{:id :why}]`.
|
||||
|
||||
NOT `problems`. A conflict is a legitimate state for a document to be in: a
|
||||
regeneration changed the topology under a correction that was right when it was
|
||||
made, and resolving it is a person's decision, not a reason the document will
|
||||
not load. `flow/regenerate` records one on the layer, a conflicted layer is not
|
||||
applied, and this is how a view finds them to offer."
|
||||
[ch]
|
||||
(vec (for [[i l] (map-indexed vector (:over ch))
|
||||
:let [why (or (:conflict l) (stack-conflict ch i))]
|
||||
:when why]
|
||||
{:id (:id l) :why why})))
|
||||
|
||||
(defn- offset-onto
|
||||
"`base` plus `v`, component-wise. A vector, never a write into `base`, which
|
||||
for a dense channel is a view onto the block itself."
|
||||
[base v ch]
|
||||
(let [wb (width base) wv (width v)]
|
||||
(cond
|
||||
(and (nil? wb) (nil? wv)) (+ base v)
|
||||
(and wb wv (= wb wv))
|
||||
(mapv (fn [i] (+ (component base i) (component v i))) (range wb))
|
||||
:else
|
||||
(throw (ex-info "a correction cannot offset a value of a different shape"
|
||||
{:base wb :correction wv :channel (dissoc ch :dense)})))))
|
||||
|
||||
(defn- eye-opening-onto [points amount]
|
||||
(let [n (width points)
|
||||
ys (map #(component points %) (range 1 n 2))
|
||||
center (/ (+ (reduce min ys) (reduce max ys)) 2)]
|
||||
(mapv (fn [i] (let [v (component points i)]
|
||||
(if (odd? i) (+ center (* amount (- v center))) v)))
|
||||
(range n))))
|
||||
|
||||
(defn- over-at
|
||||
"Fold `ch`'s layers onto `base` at frame f. `read` samples one layer's values
|
||||
and is the only thing that differs between the specification and the cursor."
|
||||
[ch f base read]
|
||||
(reduce-kv
|
||||
(fn [v i {:keys [support op values conflict]}]
|
||||
;; A conflicted correction is neither applied nor forgotten: it stays in
|
||||
;; the document, `conflicts` reports it, and a person decides. Applying it
|
||||
;; would misapply it; removing it would throw away hand work.
|
||||
(if (or conflict (not (covers? support f)))
|
||||
v
|
||||
(let [x (read i values f)]
|
||||
(cond
|
||||
(nothing? x) v
|
||||
(= :replace op) x
|
||||
;; `replace` can supply a value over an absent base; `offset` has
|
||||
;; nothing to add to and says so rather than inventing a pose.
|
||||
(nothing? v) absent
|
||||
(= :eye-opening op) (eye-opening-onto v x)
|
||||
:else (offset-onto v x ch)))))
|
||||
base
|
||||
(vec (:over ch))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; dense blocks
|
||||
|
|
@ -147,9 +336,9 @@
|
|||
Decoding costs the view. `out` is a stride-sized destination the caller owns —
|
||||
`cursor` allocates one per channel — because a copy per node per frame is the
|
||||
allocation this whole model is arranged to avoid; passing nil allocates, which
|
||||
is what `value-at`, the specification, does."
|
||||
([blk f st] (dense-at blk f st nil))
|
||||
([{:keys [store offset stride scale] nf :frames} f st out]
|
||||
is what `value-at`, the specification, does — and it says so by passing nil,
|
||||
because there is no arity here that decides it for a caller."
|
||||
[{:keys [store offset stride scale] nf :frames} f st out]
|
||||
(let [{:keys [data state]} (get st store)]
|
||||
(when (nil? data)
|
||||
(throw (ex-info "dense channel's store key is not in the store"
|
||||
|
|
@ -164,7 +353,7 @@
|
|||
:else (let [dst (or out (js/Float64Array. stride))]
|
||||
(dotimes [k stride]
|
||||
(aset dst k (/ (aget data (+ o k)) scale)))
|
||||
dst))))))))
|
||||
dst)))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the specification
|
||||
|
|
@ -180,9 +369,19 @@
|
|||
(if (and (= :linear (segment-interp ch left)) right (>= f left) (> right left))
|
||||
(let [b (get (:keys ch) right)
|
||||
t (/ (- f left) (- right left))]
|
||||
(if (vector? a)
|
||||
(cond
|
||||
;; Palette-choice channels interpolate identities into a blend
|
||||
;; descriptor. The renderer keeps indexed geometry in the left
|
||||
;; palette's bank and blends that bank's ramp toward the right one.
|
||||
;; Palette ids are opaque identities. Built-ins happen to use
|
||||
;; keywords, while palettes made in the editor use UUIDs; treating
|
||||
;; the latter as numbers produces NaN and therefore the renderer's
|
||||
;; pink bad-data sentinel.
|
||||
(and (= :palette (:semantic ch)) (some? a) (some? b))
|
||||
{:from a :to b :t t}
|
||||
(vector? a)
|
||||
(mapv (fn [x y] (+ x (* t (- y x)))) a b)
|
||||
(+ a (* t (- b a)))))
|
||||
:else (+ a (* t (- b a)))))
|
||||
a)))
|
||||
|
||||
(defn- keyed-at
|
||||
|
|
@ -199,19 +398,39 @@
|
|||
right (first (drop-while #(<= % f) fr))]
|
||||
(interpolate ch f left right)))
|
||||
|
||||
(defn repair-frame
|
||||
"Map a damaged frame to a donor in the current base. Latest interval wins;
|
||||
donors are sampled directly, never recursively through other repairs."
|
||||
[ch f]
|
||||
(reduce (fn [frame {:keys [from through donor]}]
|
||||
(if (<= from f through) donor frame))
|
||||
f (:repairs ch)))
|
||||
|
||||
(defn value-at
|
||||
"Sample a channel at frame f. THE SPECIFICATION — correct, allocating, and
|
||||
O(n) in the keys. `cursor`/`sample!` is what playback uses."
|
||||
([ch f] (value-at ch f nil))
|
||||
([ch f store]
|
||||
(check-unimplemented! ch)
|
||||
(cond
|
||||
O(n) in the keys. `cursor`/`sample!` is what playback uses.
|
||||
|
||||
`store` IS AN ARGUMENT, NEVER A DEFAULT. A dense channel cannot be read
|
||||
without the tier-2 store it names, and an arity that filled in nil let a
|
||||
caller omit it, read correctly for every channel that happened not to be
|
||||
dense, and throw the first time a selection landed on one that was. That is
|
||||
how `gesture/values` took the stage down on an iris. A caller with no store
|
||||
says `nil` and means it."
|
||||
([ch f store] (value-at ch f f store))
|
||||
([ch base-f correction-f store]
|
||||
(let [base-f (repair-frame ch base-f)
|
||||
base (cond
|
||||
(not (:animated? ch)) (:value ch)
|
||||
(:dense ch) (dense-at (:dense ch) f store)
|
||||
(:dense ch) (dense-at (:dense ch) base-f store nil)
|
||||
(:keys ch) (let [ks (:keys ch)]
|
||||
(if (empty? ks) absent (keyed-at ch f)))
|
||||
(if (empty? ks) absent (keyed-at ch base-f)))
|
||||
:else
|
||||
(throw (ex-info "animated channel has neither :keys nor :dense" {:channel ch})))))
|
||||
(throw (ex-info "animated channel has neither :keys nor :dense"
|
||||
{:channel ch})))]
|
||||
(if (seq (:over ch))
|
||||
(over-at ch correction-f base
|
||||
(fn [_ values f] (value-at values f store)))
|
||||
base))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the playback path
|
||||
|
|
@ -233,7 +452,7 @@
|
|||
(recur (inc mid) hi mid)
|
||||
(recur lo (dec mid) best))))))
|
||||
|
||||
(deftype Cursor [ch ks store buf ^:mutable i]
|
||||
(deftype Cursor [ch ks store buf overs ^:mutable i]
|
||||
Object
|
||||
(toString [_] (str "#Cursor{" (pr-str (if ks :keyed (if (:dense ch) :dense :framed))) " i=" i "}")))
|
||||
|
||||
|
|
@ -244,26 +463,28 @@
|
|||
needs, for the same reason the resolver owns one point buffer per node.
|
||||
|
||||
Only a wide fixed-point block gets a buffer: a stride-1 block decodes to a
|
||||
number and a block with no `:scale` is handed back as a view."
|
||||
([ch] (cursor ch nil))
|
||||
([ch store]
|
||||
(check-unimplemented! ch)
|
||||
number and a block with no `:scale` is handed back as a view.
|
||||
|
||||
A correction layer gets a reading head of its own, because its values are a
|
||||
channel and this is how a channel is read fast. One level deep: a layer's
|
||||
values may not themselves carry layers, which `problems` refuses.
|
||||
|
||||
`store` is an argument for the reason it is one on `value-at`."
|
||||
[ch store]
|
||||
(let [d (:dense ch)]
|
||||
(->Cursor ch
|
||||
(when (and (:animated? ch) (not d) (seq (:keys ch))) (frames ch))
|
||||
store
|
||||
(when (and d (:scale d) (> (:stride d) 1))
|
||||
(js/Float64Array. (:stride d)))
|
||||
0))))
|
||||
(mapv #(cursor (:values %) store) (:over ch))
|
||||
0)))
|
||||
|
||||
(defn sample!
|
||||
"Value of the cursor's channel at f. O(1) when f is at or one key past where
|
||||
the cursor already sits — the playback case — and O(log n) otherwise, which is
|
||||
a seek. Advancing and seeking are deliberately different costs: a scrub can
|
||||
afford a binary search and a frame cannot."
|
||||
[^Cursor cur f]
|
||||
(let [ch (.-ch cur)
|
||||
ks (.-ks cur)]
|
||||
(defn- base-sample!
|
||||
"What the cursor's channel says at f BEFORE its corrections. Advancing the
|
||||
reading head is this function's whole job, and it is separate from blending so
|
||||
that a layer cannot accidentally be read through the base's index."
|
||||
[^Cursor cur ch ks f]
|
||||
(cond
|
||||
(not (:animated? ch)) (:value ch)
|
||||
(:dense ch) (dense-at (:dense ch) f (.-store cur) (.-buf cur))
|
||||
|
|
@ -285,7 +506,25 @@
|
|||
|
||||
:else (bsearch ks f))]
|
||||
(set! (.-i cur) i')
|
||||
(interpolate ch f (nth ks i') (when (< i' last) (nth ks (inc i'))))))))
|
||||
(interpolate ch f (nth ks i') (when (< i' last) (nth ks (inc i')))))))
|
||||
|
||||
(defn sample!
|
||||
"Value of the cursor's channel at f. O(1) when f is at or one key past where
|
||||
the cursor already sits — the playback case — and O(log n) otherwise, which is
|
||||
a seek. Advancing and seeking are deliberately different costs: a scrub can
|
||||
afford a binary search and a frame cannot.
|
||||
|
||||
A correction layer is sampled through its OWN cursor, so a stacked channel is
|
||||
still one reading head per key map and `value-at` stays the specification for
|
||||
the blending as well as for the base."
|
||||
([cur f] (sample! cur f f))
|
||||
([^Cursor cur base-f correction-f]
|
||||
(let [ch (.-ch cur)
|
||||
base (base-sample! cur ch (.-ks cur) (repair-frame ch base-f))]
|
||||
(if (seq (:over ch))
|
||||
(over-at ch correction-f base
|
||||
(fn [i _ f] (sample! (nth (.-overs cur) i) f)))
|
||||
base))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
|
|
@ -303,6 +542,7 @@
|
|||
(let [values (when (map? (:keys ch)) (vals (:keys ch)))
|
||||
first-value (first values)
|
||||
linear-values? (or (every? number? values)
|
||||
(and (= :palette (:semantic ch)) (every? some? values))
|
||||
(and (vector? first-value)
|
||||
(pos? (count first-value))
|
||||
(every? (fn [v] (and (vector? v)
|
||||
|
|
@ -312,6 +552,14 @@
|
|||
linear? (or (= :linear (:interp ch))
|
||||
(some #{:linear} (vals (:segments ch))))]
|
||||
(cond-> []
|
||||
(and (contains? ch :repairs)
|
||||
(not (and (vector? (:repairs ch))
|
||||
(every? (fn [{:keys [id from through donor]}]
|
||||
(and id (every? integer? [from through donor])
|
||||
(<= 0 from through) (<= 0 donor)))
|
||||
(:repairs ch)))))
|
||||
(conj "repairs require an ID and nonnegative whole donor and interval frames")
|
||||
|
||||
(not (map? ch))
|
||||
(conj "not a map")
|
||||
|
||||
|
|
@ -345,8 +593,45 @@
|
|||
(or (:dense ch) (not linear-values?)))
|
||||
(conj ":linear interpolation needs numeric keys of one shape")
|
||||
|
||||
(and (map? ch) (seq (:over ch)))
|
||||
(conj ":over layers are not implemented (port-plan step 2 scope)")
|
||||
(and (map? ch) (contains? ch :over) (not (vector? (:over ch))))
|
||||
(conj ":over is an ORDERED stack, so it is a vector")
|
||||
|
||||
(and (map? ch) (vector? (:over ch)))
|
||||
(into (for [{:keys [id support op values]} (:over ch)
|
||||
p (cond-> []
|
||||
(nil? id)
|
||||
(conj "needs an :id — a correction has an identity a regeneration can keep")
|
||||
|
||||
(not (and (vector? support) (= 2 (count support))
|
||||
(every? #(and (number? %) (js/Number.isFinite %)) support)
|
||||
(< (first support) (second support))))
|
||||
(conj (str ":support " (pr-str support)
|
||||
" must be a finite, increasing [in out)"))
|
||||
|
||||
(not (#{:offset :replace :eye-opening} op))
|
||||
(conj (str ":op " (pr-str op) " is not :offset, :replace or :eye-opening"))
|
||||
|
||||
;; One level. A layer over a layer is an ordering mechanism
|
||||
;; the stack already is, and it would make the read
|
||||
;; unbounded in depth for nothing.
|
||||
(seq (:over values))
|
||||
(conj "a layer's values cannot carry layers of their own")
|
||||
|
||||
(seq (problems (dissoc values :over)))
|
||||
(conj (str "values are not a channel: "
|
||||
(first (problems (dissoc values :over))))))]
|
||||
(str "correction " (pr-str id) " " p)))
|
||||
|
||||
;; A shape mismatch NOBODY HAS RECORDED is an authoring bug; one a
|
||||
;; regeneration recorded is a conflict awaiting a person, and `conflicts`
|
||||
;; reports those. The distinction is what keeps a topology change from
|
||||
;; making a document that will not load.
|
||||
(and (map? ch) (vector? (:over ch)))
|
||||
(into (for [[i l] (map-indexed vector (:over ch))
|
||||
:when (not (:conflict l))
|
||||
:let [why (stack-conflict ch i)]
|
||||
:when why]
|
||||
(str "correction " (pr-str (:id l)) " " why)))
|
||||
|
||||
;; A scale of zero divides every value in the block by zero, and a negative
|
||||
;; one mirrors the geometry. Both are authored-data bugs that present as a
|
||||
|
|
|
|||
|
|
@ -1,47 +1,49 @@
|
|||
(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 {...}
|
||||
:analyses {analysis-id {...}}
|
||||
:subjects {...} :features {...} :groups {...}
|
||||
:timelines {:main {:id :main :frames 229 :nodes {...}}}}
|
||||
:symbols {:main {:id :main :frames 229 :nodes {...}}}}
|
||||
|
||||
Every field here is a fact about the clip and NOT about a bag of nodes, which is
|
||||
the cut this namespace exists to make. Before it, one map carried both: `:fps`,
|
||||
the stage dimensions, the analysis record and the tracking identities sat beside
|
||||
`:nodes`, and `arthur.db` said of it — correctly — that they \"sit on the scene
|
||||
map only because there is one clip per scene today\". The cost of leaving them
|
||||
together was not untidiness. It was that a SYMBOL had nowhere to live: a library
|
||||
timeline is a bag of nodes with a frame space and nothing else, so under the old
|
||||
shape it would have had to be a clip with seven meaningless fields, or a second
|
||||
structure with the same `:nodes` key that every walk had to be taught about.
|
||||
`:nodes`. The cost of leaving them together was not untidiness. It was that a
|
||||
SYMBOL had nowhere to live: a symbol is a bag of nodes with a frame space and
|
||||
nothing else, so under the old shape it would have had to be a clip with seven
|
||||
meaningless fields.
|
||||
|
||||
Now there is one node-holding type — `arthur.domain.timeline` — and a clip holds
|
||||
a MAP of them. A `:kind :symbol` instance names a timeline in `:timelines`,
|
||||
and the clip resolver gives each placement its own reading heads.
|
||||
Now there is one node-holding type — `arthur.domain.symbol` — and a clip holds
|
||||
a MAP of them. A `:kind :instance` node places one symbol inside another, and
|
||||
the clip resolver gives each instance its own reading heads.
|
||||
|
||||
THE ROOT TIMELINE HAS A RESERVED ID, `:main`, rather than the clip carrying a
|
||||
pointer to it. A pointer is a field that can be wrong — it can name a timeline
|
||||
that is not there, and then every reader needs a fallback — where a reserved name
|
||||
can only be absent, which `problems` reports once. Flash reserves `_root` the
|
||||
same way and for the same reason. Nothing else about `:main` is special: it is an
|
||||
ordinary entry in the map, and a symbol is another one.
|
||||
WHAT IS NOT HERE: how nested symbols' frames and coordinates relate, and
|
||||
moving nodes between them, are `arthur.domain.nest`; bringing symbols in from
|
||||
another clip is `arthur.domain.bring`. This namespace is the document and the
|
||||
operations that only need the document.
|
||||
|
||||
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
|
||||
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."
|
||||
(:require [arthur.domain.feature :as feature]
|
||||
NO SYMBOL IS SPECIAL. There is no reserved root id: 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 or placed inside another symbol like any of them. What the document
|
||||
does say is `:root`, which symbol it opens on: a pointer, not a kind of
|
||||
symbol, the way a Flash file names its scene. See `opens-on` for why that
|
||||
cannot be worked out instead.
|
||||
|
||||
:fps is the output grid. A symbol's optional :fps names the native grid its
|
||||
frames were authored or measured on; absent means the document's grid."
|
||||
(:refer-clojure :exclude [symbol])
|
||||
(:require [arthur.domain.cadence :as cadence]
|
||||
[arthur.domain.channel :as ch]
|
||||
[arthur.domain.feature :as feature]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.pose :as pose]
|
||||
[arthur.domain.timeline :as timeline]))
|
||||
|
||||
(def ^:const root-id
|
||||
"The reserved id of the timeline a clip plays. See the namespace docstring."
|
||||
:main)
|
||||
[arthur.domain.symbol :as symbol]))
|
||||
|
||||
(def clip-keys
|
||||
"Every top-level field of a clip, and the reason `arthur.domain.leaf` refuses
|
||||
|
|
@ -50,46 +52,287 @@
|
|||
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 :analyses :subjects :features :groups :width :height :symbols
|
||||
:palettes :default-palette :root})
|
||||
|
||||
(defn timeline
|
||||
"One of the clip's timelines, by id."
|
||||
[clip id]
|
||||
(get-in clip [:timelines id]))
|
||||
(defn symbol
|
||||
"One of the clip's symbols, by id."
|
||||
[clip sid]
|
||||
(get-in clip [:symbols sid]))
|
||||
|
||||
(defn root
|
||||
"The timeline the clip plays."
|
||||
[clip]
|
||||
(timeline clip root-id))
|
||||
(defn trace?
|
||||
"Is `sym` a tracing symbol: footage or a still to draw over, which is placed and
|
||||
moved like any symbol and never drawn into the picture? See `trace-op`."
|
||||
[sym]
|
||||
(= :trace (:type sym)))
|
||||
|
||||
(defn symbol-name
|
||||
"What to call a symbol: its `:name`, or its id when it has none."
|
||||
[clip sid]
|
||||
(or (:name (symbol clip sid)) (name sid)))
|
||||
|
||||
(defn node-label
|
||||
"What to call node `n` on screen.
|
||||
|
||||
A NAME A PERSON TYPED WINS, and for an instance that is the ONLY thing `:name`
|
||||
now means: `place-symbol` deliberately does not copy the symbol's name onto the
|
||||
node it makes. Two instances of one symbol are told apart by what somebody
|
||||
called them — `8625 left` and `8625 right` of one `face` — and reading through
|
||||
in front of that would collapse them to the same word.
|
||||
|
||||
OTHERWISE AN INSTANCE IS LABELLED BY WHAT IT PLACES, read through on every
|
||||
render. A name copied at creation goes stale the moment the symbol is renamed,
|
||||
and then the document shows one thing under two names: the symbol reads `bg` in
|
||||
its tab while an instance of it still reads `symbol-18`, which is how a person
|
||||
comes to paste a symbol into itself without being able to see that is what they
|
||||
are doing. `problems` refuses that cycle; this is why it stops looking like a
|
||||
reasonable thing to try.
|
||||
|
||||
An id is a uuid for a placement and a keyword for an authored node, and neither
|
||||
reads as a name, so the last resort is a legible stand-in rather than `(str
|
||||
id)` — `:face-1` keeps its colon and a uuid pushes a column open."
|
||||
[clip id n]
|
||||
(or (:name n)
|
||||
(some->> (node/source n) (symbol-name clip))
|
||||
(if (keyword? id) (subs (str id) 1) (subs (str id) 0 8))))
|
||||
|
||||
(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 fps [clip sid] (or (:fps (symbol clip sid)) (:fps clip)))
|
||||
|
||||
(defn set-fps
|
||||
"Change the output grid without rewriting authored content's frames.
|
||||
|
||||
A new document's empty symbol is the one exception: it has no native rate yet,
|
||||
so it follows the project grid and its empty extent is rescaled to preserve its
|
||||
duration. Once a symbol contains anything, changing the project rate records
|
||||
the old effective rate on it before changing the output grid."
|
||||
[clip rate]
|
||||
(let [old (:fps clip)]
|
||||
(-> clip
|
||||
(update :symbols
|
||||
#(into {}
|
||||
(map (fn [[sid sym]]
|
||||
[sid (cond
|
||||
(:fps sym) sym
|
||||
(empty? (:nodes sym))
|
||||
(update sym :frames cadence/frames rate old)
|
||||
:else (assoc sym :fps old))]))
|
||||
%))
|
||||
(assoc :fps rate))))
|
||||
|
||||
(defn output-frames [clip sid]
|
||||
(cadence/frames (frames clip sid) (:fps clip) (fps clip sid)))
|
||||
|
||||
(defn grid-time [clip sid]
|
||||
{:at 0 :rate (cadence/ratio (:fps clip) (fps clip sid))})
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the only crossing between the output grid and a symbol's own frames
|
||||
;;
|
||||
;; Everything authored is in a symbol's own frames and every editing gesture is
|
||||
;; too — see `docs/one-grid-plan.md`. The output grid belongs to playback: the
|
||||
;; clock, the audio mix, export, and the frame number the stage is drawing. These
|
||||
;; two functions are the whole of the way between, so a caller that has a
|
||||
;; playhead and needs a document coordinate says so in one visible call instead
|
||||
;; of multiplying by a rate it had to know about.
|
||||
|
||||
(defn shown-frame
|
||||
"Which of symbol `sid`'s own frames the output frame `f` shows."
|
||||
[clip sid f]
|
||||
(cadence/frame f (:fps clip) (fps clip sid)))
|
||||
|
||||
(defn first-output-frame
|
||||
"The output frame that first shows symbol `sid`'s own frame `n`: what to seek
|
||||
to to put the playhead on a mark of `sid`'s ruler."
|
||||
[clip sid n]
|
||||
(cadence/reader-frame n (:fps clip) (fps clip sid)))
|
||||
|
||||
(defn source-time
|
||||
"Derived cel-to-content map. Frame-rate units never enter stored retimes."
|
||||
[clip host n]
|
||||
(when-let [t (node/source-time n)]
|
||||
(let [r (cadence/ratio (fps clip host) (fps clip (node/source n)))]
|
||||
(-> t (update :rate * r) (update :at / r)))))
|
||||
|
||||
(defn placed-frame [clip host n f]
|
||||
(let [child (node/source n)
|
||||
r (cadence/ratio (fps clip host) (fps clip child))]
|
||||
(some-> (node/placed-frame (update-in n [:playback :speed] #(* (or % 1) r))
|
||||
f (frames clip child))
|
||||
(update :frame js/Math.floor))))
|
||||
|
||||
(defn stage
|
||||
"A symbol's stage as `[width height]`: its own, or the clip's where it has none.
|
||||
Absent rather than copied in at creation, so a symbol nobody has sized follows
|
||||
the project's size when that changes."
|
||||
[clip sid]
|
||||
(let [sym (symbol clip sid)]
|
||||
[(or (:width sym) (:width clip)) (or (:height sym) (:height clip))]))
|
||||
|
||||
(defn update-symbol
|
||||
"Apply f to one symbol in place."
|
||||
[clip sid f & args]
|
||||
(apply update-in clip [:symbols sid] f args))
|
||||
|
||||
(defn places
|
||||
"The ids of the symbols `sid` places, directly."
|
||||
[clip sid]
|
||||
(into #{} (mapcat node/sources) (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- longest-unplaced
|
||||
"The longest symbol nothing else places, ties broken by id, and never a
|
||||
tracing symbol: that is footage nobody has placed yet, as long as its take."
|
||||
[clip]
|
||||
(:nodes (root clip)))
|
||||
(first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)])
|
||||
(remove #(trace? (symbol clip %)) (unplaced clip)))))
|
||||
|
||||
(defn opens-on
|
||||
"The symbol a document opens on: its `:root`.
|
||||
|
||||
IT IS STORED, NOT WORKED OUT. It used to be the longest unplaced symbol, on
|
||||
the theory that the one containing everything else is always that. A take
|
||||
disproves it: imported footage is a symbol a thousand frames long, and the
|
||||
moment its instance is deleted, or the drop lands somewhere other than the
|
||||
root, nothing places it and it outranks a 120-frame `:main`. The document then
|
||||
opened on the take, and `set-root-fps` rewrote the take's rate to the
|
||||
project's on the way in.
|
||||
|
||||
A document with no `:root` — a demo, a fixture — still gets the old answer."
|
||||
[clip]
|
||||
(let [root (:root clip)]
|
||||
(if (contains? (:symbols clip) root) root (longest-unplaced clip))))
|
||||
|
||||
(defn pin-root
|
||||
"Give a document saved before `:root` existed the root it was made with.
|
||||
Every such document started as `blank`, whose root is `:main`, and ids never
|
||||
change, so `:main` is the answer wherever it survives; the old rule is only
|
||||
for documents that never had one."
|
||||
[clip]
|
||||
(cond-> clip
|
||||
(not (:root clip))
|
||||
(assoc :root (if (contains? (:symbols clip) :main) :main (longest-unplaced clip)))))
|
||||
|
||||
(defn set-root-fps
|
||||
"Set the document/output rate and the root symbol's editing rate together.
|
||||
|
||||
Project FPS is the root timeline's clock. Nested symbols keep their own native
|
||||
rates and are sampled when placed across that boundary; only the root changes
|
||||
here. Frame numbers are authored positions, so changing the rate does not
|
||||
rewrite them or silently move cuts and keys."
|
||||
[clip rate]
|
||||
(let [root (opens-on clip)]
|
||||
(cond-> (assoc clip :fps rate)
|
||||
root (assoc-in [:symbols root :fps] rate))))
|
||||
|
||||
(def ^:const blank-frames
|
||||
"How long a new document is before anything says otherwise. Four seconds at 30,
|
||||
which is long enough to key something into and short enough to scrub by hand."
|
||||
120)
|
||||
|
||||
(defn blank
|
||||
"A new, empty document: one symbol, and nothing in it.
|
||||
|
||||
A SYMBOL IS BORN EMPTY. It used to be born holding a lane, because a lane was
|
||||
the only place temporal content could go; now the symbol itself is the
|
||||
container — see `symbol/children` — so there is nothing to invent and the
|
||||
first drop into a symbol is the same operation as the second.
|
||||
|
||||
The tracking maps are ABSENT rather than empty, because `leaf/leaves` writes no
|
||||
leaf for an empty one and so cannot bring it back: a blank document that opened
|
||||
as a different map than it saved from is exactly the round trip that namespace
|
||||
promises not to have. Nothing drawn by hand has them either — the demo scene
|
||||
and the swarm carry no `:subjects` — so every reader already reads absence as
|
||||
none, and `clip-keys` says which fields MAY be here, not which must."
|
||||
[]
|
||||
{:name "untitled"
|
||||
:fps 30
|
||||
:width 320 :height 200
|
||||
:palettes {pal/default-id pal/default-palette}
|
||||
:default-palette pal/default-id
|
||||
:root :main
|
||||
;; No native fps yet: an untouched canvas follows the project grid. Imported
|
||||
;; and generated symbols carry their own rate explicitly.
|
||||
:symbols {:main {:id :main :frames blank-frames :nodes {}}}})
|
||||
|
||||
(defn- op-path [op] (let [n (:node op)] (if (vector? n) n [n])))
|
||||
|
||||
(defn- own-span
|
||||
"Where in `ops` the symbol at row path `path` is drawn: the end of its layer
|
||||
when it has one, and the first and last of its own ops."
|
||||
[ops path]
|
||||
(let [own? #(and (< (count path) (count (op-path %)))
|
||||
(= path (subvec (op-path %) 0 (count path))))
|
||||
begin (first (keep-indexed #(when (and (= :begin (:kind %2)) (= path (op-path %2))) %1) ops))
|
||||
end (when begin
|
||||
(reduce (fn [depth i]
|
||||
(case (:kind (ops i))
|
||||
:begin (inc depth)
|
||||
:end (if (= 1 depth) (reduced i) (dec depth))
|
||||
depth))
|
||||
0 (range begin (count ops))))
|
||||
mine (keep-indexed #(when (own? %2) %1) ops)]
|
||||
{:end (when (integer? end) end) :from (first mine) :to (last mine)}))
|
||||
|
||||
(defn- insert-at [ops i & more] (into (into (subvec ops 0 i) more) (subvec ops i)))
|
||||
|
||||
(defn atop
|
||||
"`ops` with `op` drawn on top of what the symbol at row path `path` draws —
|
||||
in ITS stacking context, where a shape added to it would land, so what is
|
||||
above that symbol stays above. At the end when it drew nothing."
|
||||
[ops path op]
|
||||
(let [ops (vec ops)
|
||||
{:keys [end to]} (own-span ops path)]
|
||||
(cond end (insert-at ops end op)
|
||||
to (insert-at ops (inc to) op)
|
||||
:else (conj ops op))))
|
||||
|
||||
(defn in-layer
|
||||
"`ops` with `op` drawn last INSIDE the symbol at row path `path` — in its
|
||||
layer, so a knockout clears only what that symbol drew, as one saved there
|
||||
would. A symbol that has no layer yet is given one around its ops. `ops`
|
||||
unchanged when that symbol drew nothing."
|
||||
[ops path op]
|
||||
(let [ops (vec ops)
|
||||
{:keys [end from to]} (own-span ops path)]
|
||||
(cond
|
||||
end (insert-at ops end op)
|
||||
from (-> ops
|
||||
(insert-at (inc to) op {:kind :end :node path})
|
||||
(insert-at from {:kind :begin :node path}))
|
||||
:else ops)))
|
||||
|
||||
(defn- transform-op
|
||||
"Put a symbol's already resolved mark into its instance's parent space."
|
||||
"Put a symbol's already resolved mark into its instance's parent space. Its
|
||||
name becomes its path of instances down to it, the path its timeline row has."
|
||||
[op m path]
|
||||
(let [at (fn [x y] [(+ (* (aget m 0) x) (* (aget m 2) y) (aget m 4))
|
||||
(+ (* (aget m 1) x) (* (aget m 3) y) (aget m 5))])
|
||||
scale (node/mean-scale m)
|
||||
op (assoc op :node (conj path (:node op)))]
|
||||
n (:node op)
|
||||
op (assoc op :node (if (vector? n) (into path n) (conj path n)))]
|
||||
(case (:kind op)
|
||||
:poly (let [out (js/Float64Array. (.-length (:pts op)))]
|
||||
(dotimes [i (:n op)]
|
||||
|
|
@ -102,60 +345,394 @@
|
|||
(assoc op :cx x :cy y :r (* scale (:r op))))
|
||||
:rect (let [[x y] (at (:cx op) (:cy op))]
|
||||
(assoc op :cx x :cy y :size (* scale (:size op))))
|
||||
:trace (assoc op :m (node/mul! (node/mat) m (:m op)))
|
||||
op)))
|
||||
|
||||
(defn- trace-op
|
||||
"The one op an instance of tracing symbol `sym` makes, showing its frame `frame`
|
||||
at world `m`, from node `id` of symbol `sid`.
|
||||
|
||||
NOT A PICTURE OP. Nothing indexed can show a photo, so the raster refuses this
|
||||
kind and the player hands it to `ui/tracing` instead; and the resolver makes one
|
||||
only when asked with `:tracing?`, which only the stage does. An export, a
|
||||
symbol's centre and a thumbnail never ask, so a reference cannot reach the
|
||||
picture by any path that forgets to filter it.
|
||||
|
||||
`:layer` is what the on/off switch for one layer is keyed by: the symbol the
|
||||
placement is in and its id, so a face's plate is one layer wherever the face
|
||||
is placed."
|
||||
[sym frame m sid id]
|
||||
{:kind :trace
|
||||
:node id
|
||||
:layer [sid id]
|
||||
:media (:media sym)
|
||||
:frame frame
|
||||
:size [(:width sym) (:height sym)]
|
||||
:m (js/Float64Array.from m)})
|
||||
|
||||
(defprotocol IActivePalette
|
||||
(active-palette [this]
|
||||
"The palette selected by this resolver's most recently resolved frame."))
|
||||
|
||||
(defn- channel-value [store selection frame]
|
||||
(cond
|
||||
(nil? selection) nil
|
||||
(and (map? selection) (contains? selection :animated?))
|
||||
(ch/value-at selection frame store)
|
||||
:else selection))
|
||||
|
||||
(defn palette-at
|
||||
"The palette symbol `owner` draws in at `frame`, as the stage's root does: a
|
||||
palette id, or `{:from :to :t}` while its palette lane blends from one to the
|
||||
next. `inherited` is what an unset palette falls back to, then `default`."
|
||||
[clip store default owner frame inherited]
|
||||
;; A palette track has meaningful uncovered time. Ordinary held
|
||||
;; channels clamp to their first key before it, but doing that
|
||||
;; here would erase the gap before the first palette segment.
|
||||
(let [channel-value (partial channel-value store)
|
||||
fallback (or (channel-value (:palette owner) frame)
|
||||
inherited default)
|
||||
materialize (fn [choice]
|
||||
(cond
|
||||
(= pal/inherit choice) fallback
|
||||
(map? choice) (-> choice
|
||||
(update :from #(if (= pal/inherit %) fallback %))
|
||||
(update :to #(if (= pal/inherit %) fallback %)))
|
||||
:else choice))
|
||||
track (:palette-channel owner)
|
||||
track-value (if-let [ks (:keys track)]
|
||||
(some->> (keys ks)
|
||||
(filter #(<= % frame))
|
||||
sort last
|
||||
(get ks))
|
||||
(channel-value track frame))]
|
||||
(or (when-let [track-sid (and (keyword? (:palette-track owner))
|
||||
(:palette-track owner))]
|
||||
(let [track-symbol (symbol clip track-sid)
|
||||
palette-clip (first
|
||||
(filter (fn [n]
|
||||
(let [[a b] (node/placed-span n)]
|
||||
(and a (<= a frame) (< frame b))))
|
||||
(symbol/children (:nodes track-symbol))))
|
||||
palette-symbol (some-> palette-clip node/source
|
||||
(#(symbol clip %)))
|
||||
fallback (when (= :palette (:type palette-symbol))
|
||||
(:palette-ref palette-symbol))
|
||||
choice (get-in palette-clip [:channels [:palette]])
|
||||
start (some-> palette-clip node/placed-span first)]
|
||||
(or (when (and choice start)
|
||||
(materialize (channel-value choice (- frame start))))
|
||||
fallback)))
|
||||
track-value
|
||||
fallback)))
|
||||
|
||||
(defn resolver
|
||||
"Resolve a clip, including each library timeline placed by a symbol instance.
|
||||
|
||||
Each instance owns its own timeline 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.
|
||||
|
||||
`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))
|
||||
"Resolve an output frame, selecting native content at each symbol boundary.
|
||||
Every instance owns its cursors and buffers. The IResolver queries return
|
||||
native node frames and world matrices for the last rendered output frame."
|
||||
[clip sid store palette opts]
|
||||
(let [context? (and (map? palette) (:palettes palette) (:offsets palette))
|
||||
active-palette-state (atom (:default palette))]
|
||||
(letfn [(root-selection-at [owner frame inherited]
|
||||
(palette-at clip store (:default palette) owner frame inherited))
|
||||
(selection-at [owner frame inherited]
|
||||
(let [selection (:palette owner)
|
||||
chosen (cond
|
||||
(nil? selection) nil
|
||||
(and (map? selection) (contains? selection :animated?))
|
||||
(ch/value-at selection frame store)
|
||||
:else selection)]
|
||||
(or chosen inherited (:default palette))))
|
||||
(build [sid chain pose-tracks root?]
|
||||
(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})))
|
||||
active (volatile! (when context? (:default palette)))
|
||||
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
|
||||
(assoc opts :source-fps (:fps clip)))
|
||||
own (symbol/resolver sym store (if context?
|
||||
#(pal/render-index palette @active %)
|
||||
palette)
|
||||
(assoc opts :pose-tracks pose-tracks))
|
||||
;; Each cel owns its source resolver and mutable buffers. A
|
||||
;; tracing symbol has nothing to resolve: it is one op.
|
||||
children (into {}
|
||||
(for [[id n] nodes :when (= :symbol (:kind n))]
|
||||
[id (build (:of n) (conj chain tid)
|
||||
(get-in n [:playback :tracks]))]))]
|
||||
(fn [f]
|
||||
(let [by-id (into {} (map (juxt :node identity)) (own f))]
|
||||
(into []
|
||||
(for [[id n] nodes
|
||||
:when (= :instance (:kind n))
|
||||
child (sort-by str (node/sources n))
|
||||
:when (not (trace? (symbol clip child)))]
|
||||
[[id child] (build child (conj chain sid)
|
||||
(get-in n [:playback :tracks]) false)]))
|
||||
;; The instances that were on the last frame, and WHICH
|
||||
;; drawing each was showing — a row path is read back through
|
||||
;; the child that was actually resolved, not the only one
|
||||
;; there used to be. Their resolvers still hold the frame
|
||||
;; before whenever they were not on.
|
||||
entered (volatile! {})
|
||||
;; A symbol that knocks out is drawn into a layer of its own,
|
||||
;; so what it clears is only ever its own.
|
||||
layered? (symbol/knocks? nodes)
|
||||
step (fn [f pre inherited forced]
|
||||
(when context?
|
||||
(vreset! active (or forced
|
||||
(if root?
|
||||
(root-selection-at sym (js/Math.floor f) inherited)
|
||||
inherited)
|
||||
(:default palette))))
|
||||
;; The same decision that maps local drawing slots to
|
||||
;; the active bank also names the clear colour.
|
||||
(when root? (reset! active-palette-state @active))
|
||||
(vreset! entered {})
|
||||
(let [by-id (into {} (map (juxt :node identity))
|
||||
(own (js/Math.floor f) (js/Math.floor pre)))]
|
||||
(cond-> (into (if layered? [{:kind :begin :node []}] [])
|
||||
(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))
|
||||
length (:frames target)
|
||||
frame (when (and m (number? local))
|
||||
(if (get-in n [:time :loop?])
|
||||
(mod local length)
|
||||
local))]
|
||||
(if (and frame (<= 0 frame) (< frame length))
|
||||
(map #(transform-op % m [id]) ((get children id) frame))
|
||||
[]))
|
||||
(if (= :instance (:kind n))
|
||||
(let [m (symbol/world-of own id)
|
||||
local (symbol/frame-of own id)
|
||||
prior (symbol/pre-frame-of own id)
|
||||
length (frames clip (node/source n))
|
||||
shown (when (and m (number? local))
|
||||
(placed-frame clip sid n local))
|
||||
frame (:frame shown)]
|
||||
(cond
|
||||
(not (and frame (<= 0 frame) (< frame length)))
|
||||
[]
|
||||
|
||||
(trace? (symbol clip (:symbol shown)))
|
||||
(when (:tracing? opts)
|
||||
[(trace-op (symbol clip (:symbol shown)) frame m sid id)])
|
||||
|
||||
:else
|
||||
(do (vswap! entered assoc id (:symbol shown))
|
||||
(map #(transform-op % m [id])
|
||||
((get children [id (:symbol shown)])
|
||||
frame
|
||||
;; The slot's interval crosses the
|
||||
;; boundary by being MAPPED, not
|
||||
;; carried: the previous slot's
|
||||
;; frame goes through the same
|
||||
;; placement and retime as this
|
||||
;; one, so the gap comes out in the
|
||||
;; child's frames and at the child's
|
||||
;; rate. An instance appearing for
|
||||
;; the first time on this slot has no
|
||||
;; previous frame, and so no gap.
|
||||
(or (:frame (when (number? prior)
|
||||
(placed-frame clip sid n prior)))
|
||||
(dec frame))
|
||||
@active
|
||||
(when (and context? (:palette n))
|
||||
(selection-at n frame nil)))))))
|
||||
(when-let [op (get by-id id)] [op]))))
|
||||
ids))))))]
|
||||
(build root [] nil))))
|
||||
ids))
|
||||
layered? (conj {:kind :end :node []}))))]
|
||||
(reify
|
||||
IFn
|
||||
(-invoke [_ f] (step f (dec f) nil nil))
|
||||
(-invoke [_ f pre] (step f pre nil nil))
|
||||
(-invoke [_ f pre inherited forced] (step f pre inherited forced))
|
||||
symbol/IResolver
|
||||
(world-of [_ [id & more]]
|
||||
(if more
|
||||
(when-let [w (and (contains? @entered id)
|
||||
(symbol/world-of (get children [id (get @entered id)])
|
||||
(vec more)))]
|
||||
(node/mul! (node/mat) (symbol/world-of own id) w))
|
||||
(symbol/world-of own id)))
|
||||
(frame-of [_ [id & more]]
|
||||
(if more
|
||||
(when (contains? @entered id)
|
||||
(symbol/frame-of (get children [id (get @entered id)]) (vec more)))
|
||||
(symbol/frame-of own id)))
|
||||
(pre-frame-of [_ [id & more]]
|
||||
(if more
|
||||
(when (contains? @entered id)
|
||||
(symbol/pre-frame-of (get children [id (get @entered id)]) (vec more)))
|
||||
(symbol/pre-frame-of own id))))))]
|
||||
(let [r (build sid [] nil true)
|
||||
grid (or (:grid-fps opts) (:fps clip))
|
||||
native (fps clip sid)]
|
||||
(reify
|
||||
IFn
|
||||
(-invoke [_ f]
|
||||
(r (cadence/frame f grid native)
|
||||
;; `d(k-1)`: the native frame the slot BEFORE this one selected, which
|
||||
;; with `d(k)` is the interval `(d(k-1), d(k)]` the preserve-snap may
|
||||
;; reach back into — the frames this slot is the first to cover, and so
|
||||
;; the ones the grid would otherwise show to nobody. Slot 0 has no slot
|
||||
;; before it, so its interval is its own frame alone.
|
||||
;;
|
||||
;; THE SNAP DOES NOT HAPPEN HERE, although the interval is born here and
|
||||
;; nothing would need threading. One native frame per output frame means
|
||||
;; the WHOLE PICTURE reading 13 instead of 15 — a head going two frames
|
||||
;; stale, a 67ms hitch at 12fps, to fix one group's mouth. It is per
|
||||
;; group, so it is seated where groups exist.
|
||||
(if (pos? f) (cadence/frame (dec f) grid native) -1)
|
||||
(when context? (:default palette))
|
||||
nil))
|
||||
symbol/IResolver
|
||||
(world-of [_ path] (symbol/world-of r path))
|
||||
(frame-of [_ path] (symbol/frame-of r path))
|
||||
(pre-frame-of [_ path] (symbol/pre-frame-of r path))
|
||||
IActivePalette
|
||||
(active-palette [_] @active-palette-state))))))
|
||||
|
||||
(defn center
|
||||
"The middle of everything symbol `sid` draws, over all its frames, in its own
|
||||
coordinates. ALL frames rather than the first, so a symbol whose drawing
|
||||
enters late, or travels, still has its middle where the drawing is. A symbol
|
||||
that draws nothing gets the STAGE's middle, which is where a drawing made into
|
||||
it will be, because drawings are made on the stage.
|
||||
|
||||
WHERE A DROP LANDS, AND WHAT THE INSTANCE TURNS ABOUT. `place-symbol` puts this
|
||||
point under the pointer and stores it as the instance's `[:xform :pivot]`, and
|
||||
`ui/drag`'s ghost draws the cross there so a drop lands where it was aimed —
|
||||
one point, one meaning, three uses.
|
||||
|
||||
IT IS A DEFAULT AND NOT A CACHE, which is the distinction the stored anchor got
|
||||
wrong. A pivot written here is a CHOICE made on the instance's behalf at the
|
||||
moment it is placed, the same way `paint/centred` chooses a drawing's origin
|
||||
when it is drawn; editing the symbol afterwards does not revise either, and the
|
||||
cross is draggable so neither is a trap. What the anchor got wrong was being
|
||||
invisible and unmovable, not being stored."
|
||||
[clip store sid]
|
||||
(let [resolve (resolver clip sid store pal/index-of {:grid-fps (fps clip sid)})
|
||||
bounds (fn [[x0 y0 x1 y1 :as b] x y]
|
||||
(if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y]))
|
||||
[x0 y0 x1 y1]
|
||||
(reduce
|
||||
(fn [b {:keys [kind pts n cx cy r size]}]
|
||||
(case kind
|
||||
:poly (reduce (fn [b i] (bounds b (aget pts (* 2 i)) (aget pts (inc (* 2 i)))))
|
||||
b (range n))
|
||||
:disc (-> b (bounds (- cx r) (- cy r)) (bounds (+ cx r) (+ cy r)))
|
||||
:rect (let [h (/ size 2)] (-> b (bounds (- cx h) (- cy h)) (bounds (+ cx h) (+ cy h))))
|
||||
b))
|
||||
nil
|
||||
(mapcat resolve (range (frames clip sid))))]
|
||||
(if x0
|
||||
[(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)]
|
||||
(mapv #(/ % 2) (stage clip sid)))))
|
||||
|
||||
(defn place-symbol
|
||||
"An instance of symbol `sid`, inside symbol `host`, at `frame` of `host`.
|
||||
|
||||
THE MIDDLE GOES UNDER THE POINTER, AND IS WHAT THE INSTANCE TURNS ABOUT.
|
||||
`center` says where the symbol's drawing sits in its own coordinates; `pos` is
|
||||
set so that point lands on `point`, a stage pixel — without one, a drop on the
|
||||
timeline, the drawing stays where it was drawn — and the same point is stored as
|
||||
the instance's `[:xform :pivot]`, so a turn or a scale happens about the middle
|
||||
of the drawing rather than about the symbol's origin.
|
||||
|
||||
WITHOUT THAT PIVOT AN INSTANCE TURNS ABOUT THE CORNER OF THE STAGE. A symbol's
|
||||
origin is the stage's, because that is where its contents were drawn, so the
|
||||
middle of a drawing inside one is typically a hundred-odd pixels away from it on
|
||||
a 320x200 stage. `node/local!` composes about the pivot, so this one stored
|
||||
point is the difference between spinning in place and orbiting the top-left
|
||||
corner. See `domain/gesture`.
|
||||
|
||||
THE UUID IS AN ARGUMENT. An instance's identity is the key it has in the node
|
||||
map — it is what `:linked-to`, an export target and a saved leaf all name — so
|
||||
generating one in here would make this function's result depend on when it was
|
||||
called, and this namespace is the pure one.
|
||||
|
||||
The instance's own time starts where it was dropped: `:at frame` means frame 0
|
||||
of the symbol plays on `frame` of `host`, which is what dragging something onto
|
||||
a playhead is asking for. Its `:span` is in its OWN frames — the whole symbol,
|
||||
0 to its length — wherever it was dropped; see `node/placed-span`.
|
||||
|
||||
Refused, returning the clip unchanged, when it would make a cycle: a symbol
|
||||
cannot be placed inside itself or inside anything it places."
|
||||
[clip store host sid frame uuid point]
|
||||
(let [target (symbol clip sid)
|
||||
end (frames clip host)]
|
||||
(if (or (nil? target) (nil? end) (nil? frame) (neg? frame) (>= frame end)
|
||||
(contains-symbol? clip sid host))
|
||||
clip
|
||||
(let [middle (center clip store sid)]
|
||||
(update-symbol
|
||||
clip host assoc-in [:nodes uuid]
|
||||
{:id uuid
|
||||
:kind :instance
|
||||
:parent nil
|
||||
;; Lexicographic draw order, as `domain/paint` does it: an instance made
|
||||
;; later sits above one made earlier, and neither has to renumber.
|
||||
:z (str "z" (js/Date.now) "-" (name sid))
|
||||
:span [0 (cadence/frames (:frames target) (fps clip host) (fps clip sid))]
|
||||
:time {:mode :map :at frame :rate 1}
|
||||
:source {:symbol sid}
|
||||
:playback {:in 0 :speed 1 :end :stop}
|
||||
:channels {[:xform :pos] {:animated? false
|
||||
:value (if point (mapv - point middle) [0 0])}
|
||||
[:xform :pivot] {:animated? false :value middle}}})))))
|
||||
|
||||
(defn place-sound
|
||||
"Place a sound at host frame `frame`. Its span uses `source`'s fps when
|
||||
supplied, otherwise the host's. `rate` is a deliberate playback speed."
|
||||
[clip host source label length rate frame uuid]
|
||||
(let [end (frames clip host)]
|
||||
(if (or (nil? end) (nil? frame) (neg? frame) (>= frame end))
|
||||
clip
|
||||
(update-symbol
|
||||
clip host assoc-in [:nodes uuid]
|
||||
{:id uuid
|
||||
:name label
|
||||
:kind :audio
|
||||
:parent nil
|
||||
:z (str "z" (js/Date.now) "-sound")
|
||||
:source source
|
||||
:span [0 (max 1 length)]
|
||||
:time {:mode :map :at frame :rate rate}}))))
|
||||
|
||||
(defn fresh-id
|
||||
"The first `:symbol-N` the clip does not already hold. Readable because an id
|
||||
shows up in saved leaf paths, and deterministic because this namespace is pure."
|
||||
[clip]
|
||||
(first (remove (:symbols clip) (map #(keyword (str "symbol-" %)) (iterate inc 1)))))
|
||||
|
||||
(defn new-symbol
|
||||
"A new, empty symbol `sid`, placed inside `host` at `frame` and running to the
|
||||
end of it. Placed at the origin, so whatever is drawn into it lands where it was
|
||||
drawn until the instance is moved."
|
||||
[clip host sid frame uuid]
|
||||
(let [end (frames clip host)]
|
||||
(if (or (nil? end) (symbol clip sid) (nil? frame) (neg? frame) (>= frame end))
|
||||
clip
|
||||
(-> clip
|
||||
(assoc-in [:symbols sid] {:id sid :name (name sid) :fps (fps clip host)
|
||||
:frames (- end frame) :nodes {}})
|
||||
(place-symbol nil host sid frame uuid nil)))))
|
||||
|
||||
(defn free-id
|
||||
"`wanted`, or the first `wanted-2`, `wanted-3`… `taken?` does not claim.
|
||||
Keeps the namespace, so `:sym/face` becomes `:sym/face-2`."
|
||||
[taken? wanted]
|
||||
(first (remove taken?
|
||||
(cons wanted
|
||||
(map #(keyword (namespace wanted) (str (name wanted) "-" %))
|
||||
(iterate inc 2))))))
|
||||
|
||||
(defn conflicts
|
||||
"Every hand correction in the document that its base has outgrown, as
|
||||
`[{:symbol :node :channel :id :why}]`.
|
||||
|
||||
SEPARATE FROM `problems` on purpose. A conflict is a document a person still
|
||||
has to make a decision about — a regeneration changed the topology under a
|
||||
correction that was right when it was made — and not a reason the document
|
||||
will not load. Nothing is dropped and nothing is misapplied meanwhile: the
|
||||
layer stays where it is, the picture is the base, and this is the list a view
|
||||
offers to resolve."
|
||||
[clip]
|
||||
(vec (for [[sid sym] (:symbols clip)
|
||||
[id n] (:nodes sym)
|
||||
[prop c] (:channels n)
|
||||
{:keys [why] :as x} (ch/conflicts c)]
|
||||
(assoc (select-keys x [:id]) :symbol sid :node id :channel prop :why why))))
|
||||
|
||||
(defn problems
|
||||
"Human-readable reasons this clip will not evaluate or save."
|
||||
|
|
@ -164,41 +741,80 @@
|
|||
(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 (and (contains? clip :analyses) (not (map? (:analyses clip))))
|
||||
[":analyses must be a map of analysis id -> analysis"])
|
||||
(for [[id analysis] (:analyses clip)
|
||||
:when (not= id (:id analysis))]
|
||||
(str "analysis under key " (pr-str id) " has :id " (pr-str (:id analysis))))
|
||||
(for [[id subject] (:subjects clip)
|
||||
:when (not (contains? (:analyses clip) (:analysis subject)))]
|
||||
(str "subject " (pr-str id) " names missing analysis "
|
||||
(pr-str (:analysis subject))))
|
||||
(for [[id subject] (:subjects clip)
|
||||
:when (not (keyword? (:source-subject subject)))]
|
||||
(str "subject " (pr-str id) " has no source subject"))
|
||||
(when (and (contains? clip :palettes) (not (map? (:palettes clip))))
|
||||
[":palettes must be a map of id -> palette"])
|
||||
(when (and (contains? clip :default-palette) (map? (:palettes clip))
|
||||
(not (contains? (:palettes clip) (:default-palette clip))))
|
||||
[":default-palette must name a project palette"])
|
||||
(when (and (contains? clip :root) (map? (:symbols clip))
|
||||
(not (contains? (:symbols clip) (:root clip))))
|
||||
[(str ":root names missing symbol " (pr-str (:root clip)))])
|
||||
(for [[id p] (:palettes clip)
|
||||
:when (or (not= id (:id p)) (not (pal/valid-palette? p)))]
|
||||
(str "palette " (pr-str id) " is invalid or has a different :id"))
|
||||
(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 (= :instance (:kind n))
|
||||
missing (remove (:symbols clip) (node/sources n))]
|
||||
(str "symbol " (pr-str sid) " instance " (pr-str id)
|
||||
" names missing symbol " (pr-str missing)))
|
||||
;; THE INVARIANT `place-symbol` AND `ui/drag` ALREADY ENFORCE, stated here so
|
||||
;; that every command is checked against it rather than the two that remember
|
||||
;; to ask. A symbol placed inside itself, or inside anything it places, has no
|
||||
;; finite expansion: `build` above and `nest/audio-tracks` both walk instances
|
||||
;; and both throw on the way round. Paste reached this function without it and
|
||||
;; wrote a document that saved, loaded, and only then threw — which is the one
|
||||
;; outcome `problems` exists to make impossible.
|
||||
(for [[sid sym] (:symbols clip)
|
||||
[id n] (:nodes sym)
|
||||
:when (= :instance (:kind n))
|
||||
src (node/sources n)
|
||||
;; A source that does not exist is the rule above's to report, not this
|
||||
;; one's, so it does not get named twice.
|
||||
:when (and (contains? (:symbols clip) src)
|
||||
(contains-symbol? clip src sid))]
|
||||
(str "symbol " (pr-str sid) " instance " (pr-str id) " places "
|
||||
(pr-str src) (if (= src sid) ", which is itself" ", which contains it")))
|
||||
;; Pose tracks belong to this cel's single source symbol.
|
||||
(for [[sid sym] (:symbols clip)
|
||||
[id n] (:nodes sym)
|
||||
:when (= :instance (:kind n))
|
||||
:let [targets (keep #(get-in clip [:symbols %]) (node/sources n))
|
||||
active (filter (fn [node]
|
||||
(some :pose-sampled? (vals (:channels node))))
|
||||
(vals (:nodes target)))
|
||||
(mapcat #(vals (:nodes %)) targets))
|
||||
groups (set (concat
|
||||
(map #(or (:pose-group %) (:id %)) active)
|
||||
(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)
|
||||
(apply max 0 (keep :frames targets)) groups)]
|
||||
(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))))
|
||||
|
|
|
|||
321
frontend/src/arthur/domain/clipboard.cljs
Normal file
321
frontend/src/arthur/domain/clipboard.cljs
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
(ns arthur.domain.clipboard
|
||||
"Pure multi-node clipboard commands.
|
||||
|
||||
A clipboard value is a detached forest of node maps. Normal copies keep symbol
|
||||
references; `duplicate` with `:unique?` copies the complete referenced symbol
|
||||
graph once for the whole forest. UI state, playhead conversion, and history
|
||||
stay in events.ui. See docs/clipboard-plan.md."
|
||||
(:require [arthur.domain.bring :as bring]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.nest :as nest]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.span :as span]
|
||||
[arthur.domain.symbol :as symbol]))
|
||||
|
||||
(defn- prefix? [a b]
|
||||
(and (<= (count a) (count b)) (= a (subvec b 0 (count a)))))
|
||||
|
||||
(defn- node-selections [clip selections]
|
||||
(->> selections
|
||||
(keep (fn [[kind sid id path :as address]]
|
||||
(when (and (= :node kind) id (get-in clip [:symbols sid :nodes id]))
|
||||
{:address address :sid sid :id id :path (vec (or path [id]))})))
|
||||
;; One owned node reached through two shared occurrences is still one edit.
|
||||
(reduce (fn [{:keys [seen out] :as acc} {:keys [sid id] :as x}]
|
||||
(if (contains? seen [sid id]) acc
|
||||
{:seen (conj seen [sid id]) :out (conj out x)}))
|
||||
{:seen #{} :out []})
|
||||
:out))
|
||||
|
||||
(defn canonical
|
||||
"Valid selected node occurrences, with anything visibly below another selected
|
||||
occurrence omitted. Order is selection order and therefore keeps the primary
|
||||
member last in the ordinary case."
|
||||
[clip selections]
|
||||
(let [xs (node-selections clip selections)]
|
||||
(filterv (fn [{p :path}]
|
||||
(not-any? (fn [{q :path}]
|
||||
(and (< (count q) (count p)) (prefix? q p)))
|
||||
xs))
|
||||
xs)))
|
||||
|
||||
(defn- subtree [nodes root]
|
||||
(into {} (filter (fn [[id _]] (some #{root} (symbol/lineage nodes id)))) nodes))
|
||||
|
||||
(defn snapshot
|
||||
"Snapshot the canonical selected forest, or `{:refused why}`."
|
||||
[clip selections]
|
||||
(let [roots (canonical clip selections)]
|
||||
(if (empty? roots)
|
||||
{:refused "select something to copy"}
|
||||
{:clipboard
|
||||
{:items
|
||||
(mapv (fn [{:keys [sid id path]}]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])]
|
||||
{:sid sid :root id :path path :parent (:parent (get nodes id))
|
||||
:nodes (subtree nodes id)}))
|
||||
roots)}})))
|
||||
|
||||
(defn cut
|
||||
"Delete a previously snapshotted forest. Snapshotting first is what makes cut
|
||||
retain data even though its source nodes are gone."
|
||||
[clip {:keys [items]}]
|
||||
(let [after (reduce (fn [c {:keys [sid root]}] (nest/delete-node c sid root)) clip items)]
|
||||
(if-let [why (first (clip/problems after))]
|
||||
{:refused why}
|
||||
{:clip after :selections []})))
|
||||
|
||||
(defn- fresh
|
||||
[taken fresh-id]
|
||||
(loop [id (fresh-id)]
|
||||
(if (contains? taken id) (recur (fresh-id)) id)))
|
||||
|
||||
(defn- allocate
|
||||
[clip items fresh-id]
|
||||
(loop [pending (vec (mapcat (fn [[i item]] (map #(vector i %) (keys (:nodes item))))
|
||||
(map-indexed vector items)))
|
||||
taken (into #{} (mapcat (comp keys :nodes val) (:symbols clip)))
|
||||
ids {}]
|
||||
(if-let [k (first pending)]
|
||||
(let [id (fresh taken fresh-id)]
|
||||
(recur (subvec pending 1) (conj taken id) (assoc ids k id)))
|
||||
ids)))
|
||||
|
||||
(defn- unique-content
|
||||
[clip items unique?]
|
||||
(let [roots (into #{} (comp (mapcat #(vals (:nodes %))) (keep node/source)) items)]
|
||||
(if (and unique? (seq roots))
|
||||
(let [{c :clip ids :ids} (bring/symbols clip clip roots {})]
|
||||
[c ids])
|
||||
[clip {}])))
|
||||
|
||||
(defn- materialize
|
||||
[clip items fresh-id unique? paste?]
|
||||
(let [[clip source-ids] (unique-content clip items unique?)
|
||||
ids (allocate clip items fresh-id)
|
||||
made
|
||||
(mapv
|
||||
(fn [[i {:keys [sid root path parent nodes]}]]
|
||||
(let [id-of #(get ids [i %])
|
||||
copied (into {}
|
||||
(map (fn [[old n]]
|
||||
(let [id (id-of old)]
|
||||
[id (cond-> (assoc n :id id)
|
||||
(:parent n) (assoc :parent (id-of (:parent n)))
|
||||
(:stencil n) (assoc :stencil (id-of (:stencil n)))
|
||||
(node/source n)
|
||||
(assoc-in [:source :symbol]
|
||||
(get source-ids (node/source n)
|
||||
(node/source n))))])))
|
||||
nodes)
|
||||
new-root (id-of root)
|
||||
;; Paste reparents roots into one explicit destination.
|
||||
;; Duplicate leaves them beside their originals.
|
||||
copied (assoc-in copied [new-root :parent]
|
||||
(when-not paste? parent))]
|
||||
{:source-sid sid :old-root root :path path :root new-root
|
||||
:nodes copied}))
|
||||
(map-indexed vector items))]
|
||||
{:clip clip :items made}))
|
||||
|
||||
(defn- shifted [n delta]
|
||||
(if (node/placed-span n)
|
||||
(update-in n [:time :at] (fnil + 0) delta)
|
||||
n))
|
||||
|
||||
(defn- top-zs [nodes n]
|
||||
(let [base (or (last (sort (map #(or (:z %) "") (vals nodes)))) "")]
|
||||
(map #(str base (apply str (repeat % "m"))) (range 1 (inc n)))))
|
||||
|
||||
(defn- add-composition
|
||||
[clip sid items at]
|
||||
(let [starts (keep #(some-> (get-in % [:nodes (:root %)]) node/placed-span first) items)
|
||||
anchor (when (seq starts) (apply min starts))
|
||||
delta (if anchor (- at anchor) 0)
|
||||
existing (get-in clip [:symbols sid :nodes])
|
||||
zs (top-zs existing (count items))
|
||||
nodes (reduce (fn [nodes [{:keys [root] copied :nodes} z]]
|
||||
(into nodes (assoc-in copied [root]
|
||||
(-> (get copied root)
|
||||
(shifted delta)
|
||||
(assoc :z z)))))
|
||||
existing (map vector items zs))]
|
||||
(assoc-in clip [:symbols sid :nodes] nodes)))
|
||||
|
||||
(defn- add-lane
|
||||
[clip sid items at fresh-id]
|
||||
(let [roots (map #(get-in % [:nodes (:root %)]) items)
|
||||
starts (map #(some-> % node/placed-span first) roots)
|
||||
anchor (when (every? some? starts) (apply min starts))
|
||||
intervals (when anchor
|
||||
(sort-by first
|
||||
(map (fn [n]
|
||||
(let [[lo hi] (node/placed-span n)]
|
||||
[(+ at (- lo anchor)) (+ at (- hi anchor))]))
|
||||
roots)))
|
||||
overlaps? (some (fn [[[a b] [c d]]] (and (< a d) (< c b)))
|
||||
(partition 2 1 intervals))]
|
||||
(if (some nil? starts)
|
||||
{:refused "a lane accepts copied things only when they have a finite span"}
|
||||
(if overlaps?
|
||||
{:refused "overlapping copied things cannot be pasted into one lane"}
|
||||
(reduce
|
||||
(fn [result item]
|
||||
(if (:refused result)
|
||||
(reduced result)
|
||||
(let [c (:clip result)
|
||||
root (:root item)
|
||||
n (get-in item [:nodes root])
|
||||
desired (+ at (- (first (node/placed-span n)) anchor))
|
||||
r (span/place-node c sid n desired
|
||||
{:extent :grow-symbol
|
||||
:remainder-id (fresh (into #{} (keys (get-in c [:symbols sid :nodes])))
|
||||
fresh-id)})]
|
||||
(if-let [made (:clip r)]
|
||||
{:clip (update-in made [:symbols sid :nodes]
|
||||
into (dissoc (:nodes item) root))}
|
||||
r))))
|
||||
{:clip clip} items)))))
|
||||
|
||||
(defn paste
|
||||
"Paste `clipboard` into `sid`, anchoring its first finite start at `at`.
|
||||
`fresh-id` is supplied by the event so this domain command remains testable."
|
||||
[clip clipboard sid at {:keys [fresh-id] :or {fresh-id random-uuid}}]
|
||||
(cond
|
||||
(nil? (clip/symbol clip sid)) {:refused "the paste target no longer exists"}
|
||||
(not (and (integer? at) (not (neg? at))))
|
||||
{:refused "the playhead is not on one frame of the paste target"}
|
||||
(empty? (:items clipboard)) {:refused "there is nothing to paste"}
|
||||
:else
|
||||
(let [{base :clip items :items} (materialize clip (:items clipboard) fresh-id false true)
|
||||
r (if (symbol/lane? (clip/symbol base sid))
|
||||
(add-lane base sid items at fresh-id)
|
||||
{:clip (add-composition base sid items at)})
|
||||
made (:clip r)
|
||||
why (when made (first (clip/problems made)))]
|
||||
(cond
|
||||
(:refused r) r
|
||||
why {:refused why}
|
||||
:else {:clip made :roots (mapv :root items)}))))
|
||||
|
||||
(defn- add-duplicate-composition [clip sid items]
|
||||
(let [existing (get-in clip [:symbols sid :nodes])
|
||||
zs (top-zs existing (count items))]
|
||||
(assoc-in clip [:symbols sid :nodes]
|
||||
(reduce (fn [nodes [{:keys [root] copied :nodes} z]]
|
||||
(into nodes (assoc-in copied [root :z] z)))
|
||||
existing (map vector items zs)))))
|
||||
|
||||
(defn- add-duplicate-lane
|
||||
[clip sid items fresh-id]
|
||||
(let [old-roots (map #(get-in clip [:symbols sid :nodes (:old-root %)]) items)
|
||||
spans (map node/placed-span old-roots)
|
||||
start (apply min (map first spans))
|
||||
end (apply max (map second spans))
|
||||
duration (- end start)
|
||||
selected (set (map :old-root items))
|
||||
shifted-clip
|
||||
(update-in clip [:symbols sid :nodes]
|
||||
(fn [nodes]
|
||||
(reduce (fn [ns n]
|
||||
(let [lo (some-> (node/placed-span n) first)]
|
||||
(if (and (nil? (:parent n))
|
||||
(not (contains? selected (:id n)))
|
||||
lo (>= lo end))
|
||||
(update-in ns [(:id n) :time :at] (fnil + 0) duration)
|
||||
ns)))
|
||||
nodes (vals nodes))))]
|
||||
(reduce
|
||||
(fn [result item]
|
||||
(if (:refused result)
|
||||
(reduced result)
|
||||
(let [c (:clip result)
|
||||
root (:root item)
|
||||
n (get-in item [:nodes root])
|
||||
old (get-in clip [:symbols sid :nodes (:old-root item)])
|
||||
desired (+ (first (node/placed-span old)) duration)
|
||||
r (span/place-node c sid n desired
|
||||
{:extent :grow-symbol
|
||||
:remainder-id (fresh (into #{} (keys (get-in c [:symbols sid :nodes])))
|
||||
fresh-id)})]
|
||||
(if-let [made (:clip r)]
|
||||
{:clip (update-in made [:symbols sid :nodes]
|
||||
into (dissoc (:nodes item) root))}
|
||||
r))))
|
||||
{:clip shifted-clip}
|
||||
(sort-by #(first (node/placed-span
|
||||
(get-in clip [:symbols sid :nodes (:old-root %)]))) items))))
|
||||
|
||||
(defn duplicate
|
||||
"Duplicate a snapshot beside its sources. Direct finite children of lane
|
||||
symbols repeat forward and ripple later cels; everything else copies in place.
|
||||
With `:unique?`, referenced symbol graphs are deep-copied once for the batch."
|
||||
[clip clipboard {:keys [fresh-id unique?] :or {fresh-id random-uuid}}]
|
||||
(if (empty? (:items clipboard))
|
||||
{:refused "select something to duplicate"}
|
||||
(let [{base :clip items :items}
|
||||
(materialize clip (:items clipboard) fresh-id unique? false)
|
||||
groups (vals (group-by :source-sid items))
|
||||
result
|
||||
(reduce
|
||||
(fn [result group]
|
||||
(if (:refused result)
|
||||
(reduced result)
|
||||
(let [c (:clip result)
|
||||
sid (:source-sid (first group))
|
||||
lane? (symbol/lane? (clip/symbol c sid))
|
||||
[lane-items other]
|
||||
((juxt filter remove)
|
||||
#(let [old (get-in clip [:symbols sid :nodes (:old-root %)])]
|
||||
(and lane? (nil? (:parent old)) (node/placed-span old)))
|
||||
group)
|
||||
c (if (seq other) (add-duplicate-composition c sid other) c)
|
||||
r (if (seq lane-items)
|
||||
(add-duplicate-lane c sid lane-items fresh-id)
|
||||
{:clip c})]
|
||||
r)))
|
||||
{:clip base} groups)
|
||||
made (:clip result)
|
||||
why (when made (first (clip/problems made)))]
|
||||
(cond
|
||||
(:refused result) result
|
||||
why {:refused why}
|
||||
:else {:clip made
|
||||
:roots (mapv (fn [{:keys [source-sid root path]}]
|
||||
{:sid source-sid :id root
|
||||
:path (conj (vec (butlast path)) root)})
|
||||
items)}))))
|
||||
|
||||
(defn move-many
|
||||
"Reparent the selected roots atomically, preserving their world transforms and
|
||||
clocks. Optional delta places the forest later on the open ruler."
|
||||
[document store open selections to frame delta]
|
||||
(let [roots (canonical document selections)
|
||||
under? (fn [path] (prefix? path (vec to)))]
|
||||
(cond
|
||||
(empty? roots) {:refused "select something to move"}
|
||||
(some #(under? (:path %)) roots) {:refused "a selection cannot go inside itself"}
|
||||
:else
|
||||
(let [moved
|
||||
(reduce (fn [result {:keys [path]}]
|
||||
(if (:refused result) (reduced result)
|
||||
(let [r (nest/move-node (:clip result) store open path to frame)]
|
||||
(if (:refused r) (reduced r)
|
||||
{:clip (:clip r)
|
||||
:selections (conj (:selections result)
|
||||
[:node (:sid r) (:id r) (conj (vec to) (:id r))])}))))
|
||||
{:clip document :selections []} roots)]
|
||||
(if (:refused moved) moved
|
||||
(let [shifted (nest/slide-many (:clip moved) open
|
||||
(mapv #(nth % 3) (:selections moved)) (or delta 0))
|
||||
checked (if (:refused shifted) shifted
|
||||
(reduce (fn [result sid]
|
||||
(if (:refused result) (reduced result)
|
||||
(span/finish (:clip result) sid
|
||||
(get-in (:clip result) [:symbols sid :nodes])
|
||||
nil :grow-symbol)))
|
||||
shifted (distinct (map :sid roots))))]
|
||||
(if (:refused checked) checked
|
||||
(if-let [why (first (clip/problems (:clip checked)))]
|
||||
{:refused why}
|
||||
(assoc checked :selections (:selections moved))))))))))
|
||||
193
frontend/src/arthur/domain/correction.cljs
Normal file
193
frontend/src/arthur/domain/correction.cljs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
(ns arthur.domain.correction
|
||||
"Pure commands that author and resolve correction layers.
|
||||
|
||||
Evaluation belongs to `channel`; this namespace only constructs a layer,
|
||||
places it on its owning node, and refuses a document that would not be valid."
|
||||
(:require [arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.node :as node]))
|
||||
|
||||
(def ^:private supported-paths #{[:xform :rot] [:xform :pos]})
|
||||
(def ^:private motions #{:constant :ramp :return})
|
||||
|
||||
(defn- finite? [x] (and (number? x) (js/Number.isFinite x)))
|
||||
|
||||
(defn- numeric-value? [v]
|
||||
(or (finite? v)
|
||||
(and (vector? v) (pos? (count v)) (every? finite? v))))
|
||||
|
||||
(defn- same-shape? [a b]
|
||||
(or (and (number? a) (number? b))
|
||||
(and (vector? a) (vector? b) (= (count a) (count b)))))
|
||||
|
||||
(defn- expected-value? [path v]
|
||||
(case path
|
||||
[:xform :rot] (finite? v)
|
||||
[:xform :pos] (and (vector? v) (= 2 (count v)) (every? finite? v))
|
||||
false))
|
||||
|
||||
(defn- values-channel
|
||||
[{:keys [motion support delta start end peak peak-frame]}]
|
||||
(let [[a b] support]
|
||||
(case motion
|
||||
:constant (ch/framed delta)
|
||||
:ramp (ch/keyed {a start, (dec b) end} :linear)
|
||||
:return (ch/keyed {a start, peak-frame peak, (dec b) start} :linear)
|
||||
nil)))
|
||||
|
||||
(defn- invalid
|
||||
[path {:keys [id support motion delta start end peak peak-frame]} existing]
|
||||
(let [[a b] (when (and (vector? support) (= 2 (count support))) support)
|
||||
samples (case motion :constant [delta] :ramp [start end]
|
||||
:return [start peak] [])]
|
||||
(cond
|
||||
(nil? id) "a correction needs an ID"
|
||||
(some #(= id (:id %)) existing) "the correction ID is already used on this channel"
|
||||
(not (contains? supported-paths path)) "that property does not support correction authoring"
|
||||
(not (contains? motions motion)) "choose constant, ramp, or return motion"
|
||||
(not (and (integer? a) (integer? b) (< a b)))
|
||||
"support must be an increasing [in out) of whole owner frames"
|
||||
(not-every? numeric-value? samples) "correction values must be finite numbers"
|
||||
(not-every? #(expected-value? path %) samples)
|
||||
"correction values do not have the property's shape"
|
||||
(and (= :ramp motion) (< (- b a) 2)) "a ramp needs at least two samples"
|
||||
(and (= :return motion) (< (- b a) 3)) "return motion needs at least three samples"
|
||||
(and (= :return motion)
|
||||
(not (and (integer? peak-frame) (< a peak-frame (dec b)))))
|
||||
"the return peak must be a whole owner frame inside both endpoints"
|
||||
(and (#{:ramp :return} motion) (not (same-shape? start (if (= :ramp motion) end peak))))
|
||||
"motion endpoints must have the same shape")))
|
||||
|
||||
(defn- finish [candidate selection]
|
||||
(if-let [why (first (clip/problems candidate))]
|
||||
{:refused why}
|
||||
{:clip candidate :selection selection}))
|
||||
|
||||
(defn add
|
||||
"Append one offset correction to a node channel.
|
||||
|
||||
Support and value keys are in the selected node's own frames. Defaults are
|
||||
materialized through `node/channels`, so correcting an unkeyed transform does
|
||||
not need a special representation."
|
||||
[document sid node-id path spec]
|
||||
(let [n (get-in document [:symbols sid :nodes node-id])
|
||||
base (when n (get (node/channels n) path))
|
||||
existing (:over base)
|
||||
why (cond
|
||||
(nil? (clip/symbol document sid)) "the owning symbol does not exist"
|
||||
(nil? n) "the correction target does not exist"
|
||||
(nil? base) "the correction target has no such channel"
|
||||
:else (invalid path spec existing))]
|
||||
(if why
|
||||
{:refused why}
|
||||
(let [layer (ch/layer (:id spec) (:support spec) :offset (values-channel spec))
|
||||
corrected (update base :over (fnil conj []) layer)
|
||||
candidate (assoc-in document [:symbols sid :nodes node-id :channels path] corrected)]
|
||||
(finish candidate node-id)))))
|
||||
|
||||
(defn remove-layer
|
||||
"Remove one named layer, refusing when a later layer depended on its shape."
|
||||
[document sid node-id path layer-id]
|
||||
(let [at [:symbols sid :nodes node-id :channels path]
|
||||
c (get-in document at)
|
||||
layers (:over c)]
|
||||
(cond
|
||||
(nil? c) {:refused "the correction channel does not exist"}
|
||||
(not-any? #(= layer-id (:id %)) layers) {:refused "the correction does not exist"}
|
||||
:else (finish (assoc-in document at
|
||||
(assoc c :over (vec (remove #(= layer-id (:id %)) layers))))
|
||||
node-id))))
|
||||
|
||||
(defn retry-layer
|
||||
"Clear one recorded conflict when the complete resulting stack is valid."
|
||||
[document sid node-id path layer-id]
|
||||
(let [at [:symbols sid :nodes node-id :channels path]
|
||||
c (get-in document at)
|
||||
found (some #(when (= layer-id (:id %)) %) (:over c))]
|
||||
(cond
|
||||
(nil? found) {:refused "the correction does not exist"}
|
||||
(nil? (:conflict found)) {:refused "the correction has no recorded conflict"}
|
||||
:else
|
||||
(finish (update-in document (conj at :over)
|
||||
(fn [layers]
|
||||
(mapv #(if (= layer-id (:id %)) (dissoc % :conflict) %) layers)))
|
||||
node-id))))
|
||||
|
||||
(defn borrow-pose [document sid {:keys [from through donor head?] :as spec} store]
|
||||
(let [frames (get-in document [:symbols sid :frames])
|
||||
ids (into #{} (mapcat :nodes)
|
||||
(filter #(= sid (:symbol %)) (vals (:features document))))
|
||||
ids (cond-> ids head? (conj :head))
|
||||
paths (for [id ids [path c] (get-in document [:symbols sid :nodes id :channels])]
|
||||
[id path c])]
|
||||
(cond
|
||||
(not (and (integer? frames) (every? integer? [from through donor])
|
||||
(<= 0 from through (dec frames)) (<= 0 donor (dec frames))))
|
||||
{:refused "choose whole face frames within this symbol"}
|
||||
(<= from donor through) {:refused "choose a clean donor outside the repair interval"}
|
||||
(empty? paths) {:refused "this symbol has no tracked face features"}
|
||||
(not-any? (fn [[_ path c]]
|
||||
(and (= path [:geom :pts])
|
||||
(not (ch/nothing? (ch/value-at (dissoc c :repairs :over) donor store)))))
|
||||
paths)
|
||||
{:refused "the donor has no face pose; choose another frame"}
|
||||
:else
|
||||
(finish (reduce (fn [doc [node path _]]
|
||||
(update-in doc [:symbols sid :nodes node :channels path :repairs]
|
||||
(fnil conj []) (select-keys spec [:id :from :through :donor])))
|
||||
document paths) nil))))
|
||||
|
||||
(declare remove-eye-keys)
|
||||
|
||||
(defn remove-repair [document sid repair-id]
|
||||
{:clip (reduce (fn [doc [id path]]
|
||||
(update-in doc [:symbols sid :nodes id :channels path :repairs]
|
||||
#(vec (remove (fn [r] (= repair-id (:id r))) %))))
|
||||
(-> document
|
||||
(remove-eye-keys sid repair-id :l) :clip
|
||||
(remove-eye-keys sid repair-id :r) :clip)
|
||||
(for [[id n] (get-in document [:symbols sid :nodes])
|
||||
[path c] (:channels n) :when (:repairs c)] [id path]))})
|
||||
|
||||
(defn eye-key
|
||||
"Key a procedural lid adjustment and gaze offset over one repair interval."
|
||||
[document sid repair-id side frame {:keys [opening gaze-x gaze-y]}]
|
||||
(let [outer (keyword (str "eye-" (name side)))
|
||||
inner (keyword (str "eye-" (name side) "-in"))
|
||||
iris (keyword (str "iris-" (name side)))
|
||||
repair (some #(when (= repair-id (:id %)) %)
|
||||
(get-in document [:symbols sid :nodes outer :channels [:geom :pts] :repairs]))
|
||||
{:keys [from through]} repair
|
||||
layer-id (str repair-id "/eye/" (name side))
|
||||
edits [[outer [:geom :pts] :eye-opening opening 1]
|
||||
[inner [:geom :pts] :eye-opening opening 1]
|
||||
[iris [:xform :pos] :offset [gaze-x gaze-y] [0 0]]]]
|
||||
(cond
|
||||
(nil? repair) {:refused "this eye has no such repair interval"}
|
||||
(not (and (integer? frame) (<= from frame through)))
|
||||
{:refused "move the playhead inside this repair interval"}
|
||||
(not (and (every? finite? [opening gaze-x gaze-y]) (<= 0 opening 3)))
|
||||
{:refused "eye opening must be between 0 and 3; gaze offsets must be finite"}
|
||||
:else
|
||||
(finish
|
||||
(reduce
|
||||
(fn [doc [id path op value neutral]]
|
||||
(update-in doc [:symbols sid :nodes id :channels path :over]
|
||||
(fn [layers]
|
||||
(let [existing (some #(when (= layer-id (:id %)) %) layers)
|
||||
values (or (:values existing)
|
||||
(ch/keyed {from neutral through neutral} :linear))
|
||||
layer (ch/layer layer-id [from (inc through)] op
|
||||
(assoc-in values [:keys frame] value))]
|
||||
(conj (vec (remove #(= layer-id (:id %)) layers)) layer)))))
|
||||
document edits)
|
||||
nil))))
|
||||
|
||||
(defn remove-eye-keys [document sid repair-id side]
|
||||
(let [layer-id (str repair-id "/eye/" (name side))]
|
||||
{:clip (reduce (fn [doc [id path]]
|
||||
(update-in doc [:symbols sid :nodes id :channels path :over]
|
||||
#(vec (remove (fn [l] (= layer-id (:id l))) %))))
|
||||
document
|
||||
(for [[id n] (get-in document [:symbols sid :nodes])
|
||||
[path c] (:channels n) :when (:over c)] [id path]))}))
|
||||
67
frontend/src/arthur/domain/creation.cljs
Normal file
67
frontend/src/arthur/domain/creation.cljs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
(ns arthur.domain.creation
|
||||
"Resolve where a new thing goes from the primary selection and playhead.
|
||||
|
||||
This namespace owns no editor state. A row address is a preference; walking
|
||||
the occurrence path at one frame answers which preferred or enclosing symbol
|
||||
is actually available. See `docs/creating-in.md`."
|
||||
(:require [arthur.domain.clip :as clip]
|
||||
[arthur.domain.nest :as nest]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.symbol :as symbol]))
|
||||
|
||||
(defn- path-node
|
||||
"The node at the end of an occurrence `path`, walked from `open`.
|
||||
|
||||
Selection also carries an owner sid and node id for commands that edit the
|
||||
node directly. Those are deliberately not used here: the path is the address
|
||||
of the row as seen from the open symbol, and is the only part that describes
|
||||
every enclosing occurrence at arbitrary depth."
|
||||
[document open path]
|
||||
(loop [sid open [id & more] (seq path) found nil]
|
||||
(if-not id
|
||||
found
|
||||
(when-let [n (get-in document [:symbols sid :nodes id])]
|
||||
(if (seq more)
|
||||
(when-let [inner (and (= :instance (:kind n)) (node/source n))]
|
||||
(recur inner more n))
|
||||
n)))))
|
||||
|
||||
(defn preferred-path
|
||||
"The container path structurally implied by `selection`.
|
||||
|
||||
Selecting an instance means inside it. Selecting any other node means its
|
||||
containing symbol. An empty or non-node selection means the open symbol."
|
||||
[document open selection]
|
||||
(let [[kind _sid id selected-path] selection
|
||||
path (when (= :node kind)
|
||||
(vec (or (seq selected-path) (when id [id]))))
|
||||
selected (path-node document open path)]
|
||||
(cond
|
||||
(empty? path) []
|
||||
(= :instance (:kind selected)) path
|
||||
:else (vec (butlast path)))))
|
||||
|
||||
(defn target
|
||||
"The nearest creation context available at `frame` of `open`.
|
||||
|
||||
Every non-empty candidate is validated by `nest/inside`, so every enclosing
|
||||
symbol occurrence must be under the playhead in the current context. Walking
|
||||
outward stops at the first valid symbol. A lane needs no cel at that frame,
|
||||
but the occurrence chain that reaches the lane must still be valid. The empty
|
||||
path always resolves to the open symbol.
|
||||
|
||||
A tracing symbol is never one: it holds a picture to draw over and no nodes,
|
||||
so selecting a tracing layer creates beside it, as selecting a shape does.
|
||||
|
||||
Returns `{:kind :lane|:symbol :sid :path :frame :matrix :time}`."
|
||||
[document store open selection frame]
|
||||
(let [preferred (preferred-path document open selection)]
|
||||
(some (fn [path]
|
||||
(when-let [inside (nest/inside document store open path frame)]
|
||||
(when-let [sid (and (:sid inside)
|
||||
(not (clip/trace? (clip/symbol document (:sid inside))))
|
||||
(:sid inside))]
|
||||
(assoc inside :path path
|
||||
:kind (if (symbol/lane? (clip/symbol document sid))
|
||||
:lane :symbol)))))
|
||||
(take (inc (count preferred)) (iterate pop preferred)))))
|
||||
88
frontend/src/arthur/domain/cut.cljs
Normal file
88
frontend/src/arthur/domain/cut.cljs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
(ns arthur.domain.cut
|
||||
"The eraser's cut: a shape's ring with a stroke taken out of it.
|
||||
|
||||
Illustrator's and Flash's eraser, not a raster one: what is left is the
|
||||
SHAPE, with the cut edge made of its own points — points the pen moves, keys
|
||||
and tweens like any others. A cut through the middle leaves two pieces, and
|
||||
a cut inside it leaves a hole, which is bridged into the one ring as a brush
|
||||
stroke's is (see `outline`).
|
||||
|
||||
In stage pixels, on both sides: the preview cuts the rings it is about to
|
||||
draw, and the saved cut cuts the same rings and takes the answer back into
|
||||
the shape's own coordinates — one function, so the preview is the result."
|
||||
(:require ["polygon-clipping" :as clipping]
|
||||
[arthur.domain.channel :as channel]
|
||||
[arthur.domain.nest :as nest]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.outline :as outline]
|
||||
[arthur.domain.paint :as paint]))
|
||||
|
||||
(defn- area [ring]
|
||||
(let [ps (vec (partition 2 ring)) n (count ps)]
|
||||
(js/Math.abs (/ (reduce + (map (fn [i] (let [[ax ay] (ps i) [bx by] (ps (mod (inc i) n))]
|
||||
(- (* ax by) (* bx ay))))
|
||||
(range n)))
|
||||
2))))
|
||||
|
||||
(defn cut
|
||||
"Ring `ring` with `cutters` — each `[outer & holes]` — taken out of it: one
|
||||
ring per piece left, biggest first, an empty vector when nothing is left, or
|
||||
nil when the cut does not touch it."
|
||||
[ring cutters]
|
||||
(let [shape #js [(outline/->js ring)]
|
||||
knife (into-array (map #(into-array (map outline/->js %)) cutters))]
|
||||
(when (seq (array-seq (clipping/intersection shape knife)))
|
||||
(->> (array-seq (clipping/difference shape knife))
|
||||
(map (fn [^js poly] (mapv outline/->ring (array-seq poly))))
|
||||
(sort-by (comp - area first))
|
||||
(mapv outline/join)))))
|
||||
|
||||
(defn- through [m pts]
|
||||
(let [out (js/Float64Array. 2)]
|
||||
(into [] (mapcat (fn [[x y]] (node/apply-pt! out 0 m x y) [(aget out 0) (aget out 1)]))
|
||||
(partition 2 pts))))
|
||||
|
||||
(defn erase
|
||||
"`clip` with `cutters`, in the stage pixels of symbol `open` at frame `f`,
|
||||
cut out of each shape at row path in `paths`, on the frame each is showing.
|
||||
|
||||
The shape keeps the biggest piece, on a key at that frame — made there if
|
||||
the frame had none, so the keys either side keep their points. Every other
|
||||
piece is a new shape of the same colour, its id the next of `ids`. A shape
|
||||
cut away entirely is deleted.
|
||||
|
||||
TWO SPACES COME BACK OUT, and they are not the same one. The piece the shape
|
||||
KEEPS is written into that shape's own geometry, so it comes back through
|
||||
`world⁻¹`, the node's own coordinates. Every other piece becomes a NEW node
|
||||
beside it, whose points are read against its own fresh transform, so those come
|
||||
back through `parent⁻¹` — the space a node's `pos` lives in, which is what
|
||||
`paint/new-shape` takes and centres.
|
||||
|
||||
Both were `world⁻¹` before, and that was wrong for the new pieces by exactly
|
||||
the cut shape's own transform. It could not be seen while every drawing had an
|
||||
identity transform, which was true of all of them for as long as a stroke was
|
||||
stored exactly as drawn: the two spaces coincided, so erasing a shape nobody had
|
||||
moved worked, and erasing one somebody had moved scattered the offcuts."
|
||||
[clip store open f paths cutters ids]
|
||||
(first
|
||||
(reduce
|
||||
(fn [[clip ids] path]
|
||||
(let [{:keys [sid id frame world parent]} (nest/placement clip store open path f)
|
||||
n (get-in clip [:symbols sid :nodes id])
|
||||
geom (get-in n [:channels paint/geometry])
|
||||
inv (when world (node/invert world))
|
||||
up (when parent (node/invert parent))
|
||||
left (when (and inv up geom)
|
||||
(cut (through world (channel/value-at geom frame store)) cutters))]
|
||||
(cond
|
||||
(nil? left) [clip ids]
|
||||
(empty? left) [(nest/delete-node clip sid id) ids]
|
||||
:else
|
||||
(let [[keep & more] left
|
||||
colour (channel/value-at (get-in n [:channels [:style :color]]) frame store)
|
||||
kept (-> (if (contains? (:keys geom) frame) clip (paint/add-key clip sid id frame))
|
||||
(paint/set-points sid id frame (through inv keep)))]
|
||||
[(reduce (fn [c [nid pts]] (paint/new-shape c sid nid frame (through up pts) colour))
|
||||
kept (map vector ids more))
|
||||
(drop (count more) ids)]))))
|
||||
[clip ids] paths)))
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
384
frontend/src/arthur/domain/gesture.cljs
Normal file
384
frontend/src/arthur/domain/gesture.cljs
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
(ns arthur.domain.gesture
|
||||
"Moving, turning and scaling a node by hand on the stage, as channel values.
|
||||
|
||||
A drag says where the pointer went in stage pixels; this says what that makes
|
||||
the node's `[:xform :pos]`, `[:xform :rot]`, `[:xform :scale]` or
|
||||
`[:xform :pivot]`, given its `nest/placement`. Whatever is above the node —
|
||||
instances, parents, a `:pinv` — is in the placement's matrices, so a shape five
|
||||
symbols down moves under the pointer like one on top.
|
||||
|
||||
A GESTURE TURNS AND SCALES ABOUT THE NODE'S OWN PIVOT, and nothing here solves
|
||||
for a position to fake one with. `node/local!` composes the rotation and the
|
||||
scale about `[:xform :pivot]`, so a turn is `rot` alone, ALWAYS — one channel,
|
||||
one key, and the pivot held exactly on every frame between two keys because the
|
||||
matrix is built about it on every frame. This is Toon Boom's layer pivot and
|
||||
Flash's transformation point, and the reason both store one.
|
||||
|
||||
A PIVOT NOBODY HAS CHOSEN IS THE MIDDLE OF WHAT THE NODE DRAWS, and the first
|
||||
turn or scale WRITES IT DOWN — `pivot` derives it from `pick/bounds-of` on the
|
||||
frame the drag starts, and `with-pivot` turns that into a `[:xform :pivot]` and
|
||||
the `[:xform :pos]` that leaves the picture exactly where it is. After that it
|
||||
is an ordinary stored, keyable, draggable channel, and the derived value is
|
||||
never consulted again: a pivot is a CHOICE, and re-deriving it per drag means
|
||||
editing a symbol silently moves what its instances turn about.
|
||||
|
||||
WHAT THIS REPLACES, because it was a deletion that cost a feature. There used
|
||||
to be no pivot in the decomposition at all: a drag derived the middle of the
|
||||
drawing, and `about` solved for the `pos` that holds that point still under the
|
||||
new angle. That solution is an ARC in the angle while `pos` interpolates along
|
||||
the CHORD, so it is exact on the frame it is written and WRONG EVERYWHERE
|
||||
BETWEEN TWO KEYS. A drawing escaped it, because `paint/centred` puts a shape's
|
||||
origin on the middle of what it draws and the correction is then nil — but a
|
||||
SYMBOL INSTANCE cannot: its origin is its symbol's, and a symbol is drawn on the
|
||||
stage, so its origin is the stage's top-left corner. One keyed turn of an
|
||||
instance therefore swung its drawing round the corner of the stage on an orbit
|
||||
the size of the stage. The answer at the time was a peg, and a peg is a real
|
||||
thing — it is how a pivot is SHARED, or put over a measured transform — but it
|
||||
is not something anybody should have to make in order to spin a drawing.
|
||||
|
||||
`about` is still here and is still that equation, for the one gesture that
|
||||
genuinely has a pivot belonging to no node: a MULTI-SELECTION scaling about the
|
||||
middle of its shared box. Nobody keys that.
|
||||
|
||||
The normal keying rule is `node/set-channel`, the inspector's: a channel with
|
||||
keys gets one on the node's own frame, and one without has its one value
|
||||
changed. Auto-key deliberately replaces that rule with `set-keyed-channel`,
|
||||
so touching an otherwise static transform starts its animation at this frame."
|
||||
(:require [arthur.domain.channel :as ch]
|
||||
[arthur.domain.node :as node]))
|
||||
|
||||
(defn values
|
||||
"Node `n`'s transform on its own frame `f`, as vectors.
|
||||
|
||||
`store` IS NOT OPTIONAL, though `ch/value-at` would let it be. A measured
|
||||
transform is a dense channel, and a dense channel read without the tier-2
|
||||
store it names throws — so leaving it off read correctly for every hand-placed
|
||||
node and crashed the stage the moment a selection landed on an iris, a brow or
|
||||
a head. Those are not hard to land on: `pick/choose` keeps a selection at the
|
||||
depth it already has, so once anything inside a face is selected, an ordinary
|
||||
click beside it selects its neighbour — which near the eyes is an iris."
|
||||
[n f store]
|
||||
(let [at #(ch/value-at (get (node/channels n) [:xform %]) f store)
|
||||
xy #(let [v (at %)] [(ch/component v 0) (ch/component v 1)])]
|
||||
{:pos (xy :pos) :pivot (xy :pivot) :rot (at :rot)
|
||||
:scale (xy :scale) :skew (xy :skew)
|
||||
;; WHETHER THE NODE HAS A PIVOT OF ITS OWN, and not what it is: a node
|
||||
;; with no `[:xform :pivot]` channel reads `[0 0]` off `node/defaults`,
|
||||
;; which is a real pivot — a drawing's own middle — and also what a node
|
||||
;; nobody has pivoted yet looks like. The two have to be told apart
|
||||
;; exactly once, when a drag decides whether to write the derived middle
|
||||
;; down; see `pivot` and `with-pivot`.
|
||||
:chosen? (contains? (:channels n) [:xform :pivot])}))
|
||||
|
||||
(defn- measured-channel? [n path]
|
||||
(let [c (get-in n [:channels path])]
|
||||
(boolean (or (:dense c) (:generated c)))))
|
||||
|
||||
(defn refusal
|
||||
"Why `kind` cannot transform node `n`, or nil. Measured position and rotation
|
||||
accept authored correction layers; measured scale cannot yet be decomposed
|
||||
safely. The one-argument form asks whether any stage gesture is possible."
|
||||
([n] (when (node/measured? n)
|
||||
"its transform is measured — use a correction, or put a peg over it"))
|
||||
([n kind]
|
||||
(when (and (= :scale kind) (measured-channel? n [:xform :scale]))
|
||||
"its scale is measured — put a peg over it and scale that")))
|
||||
|
||||
(defn- through [m [x y]]
|
||||
(let [out (js/Float64Array. 2)]
|
||||
(node/apply-pt! out 0 m x y)
|
||||
[(aget out 0) (aget out 1)]))
|
||||
|
||||
(defn linear
|
||||
"The node's own linear part, `R(rot)·K(skew)·S(scale)`, as a 2x3 whose
|
||||
translation is zero — so putting a point through it applies the rotation, skew
|
||||
and scale and nothing else. `node/local!` with a zero `pos` rather than a
|
||||
second closed form, so there is one place the decomposition is written out."
|
||||
[{:keys [rot scale skew]}]
|
||||
(node/local! (node/mat) [0 0] [0 0] rot scale skew))
|
||||
|
||||
(defn local-of
|
||||
"The node's whole local transform, `T(pos)·T(piv)·R·K·S·T(-piv)` — what takes a
|
||||
point in the node's own coordinates to its parent's."
|
||||
[{:keys [pos pivot rot scale skew]}]
|
||||
(node/local! (node/mat) pos pivot rot scale skew))
|
||||
|
||||
(defn about
|
||||
"The `pos` that keeps parent-space point `c` still while the node's linear part
|
||||
changes from `v`'s to `v'`'s. Nil when `v`'s is singular — a node scaled to
|
||||
nothing has no point under `c` to hold.
|
||||
|
||||
FOR A PIVOT THAT IS NO NODE'S, which since the pivot went into the
|
||||
decomposition is one gesture and only one: a multi-selection scaling about the
|
||||
middle of its shared box, where every member has to move to keep the
|
||||
arrangement and none of them owns the point. `turn` and `scale` do not call
|
||||
this, and the namespace docstring says why — the position it solves for is an
|
||||
ARC in the angle and `pos` tweens along the CHORD, so it is exact on the frame
|
||||
it is written and wrong between two keys.
|
||||
|
||||
Local is `T(t)·M` with `t = pos + a − M·a`, the composed translation. The
|
||||
material point sitting under `c` is `q = M⁻¹(c − t)`, holding it there under the
|
||||
new `M'` wants `t' = c − M'·q`, and the `pos` that composes to that `t'` is
|
||||
|
||||
p' = t' − a + M'·a = c − M'·(q − a) − a
|
||||
|
||||
Checkable at both ends it has to be right at: with `c` the node's own pivot
|
||||
point, `c = pos + a`, so `q = a` and `p' = c − a = pos` — turning about your own
|
||||
pivot never moves you. And with no pivot at all, `a = 0`, it is the familiar
|
||||
`p' = c − M'·M⁻¹(c − p)`."
|
||||
[v v' c]
|
||||
(let [m (linear v)
|
||||
l (local-of v)
|
||||
t [(aget l 4) (aget l 5)]
|
||||
a (:pivot v)]
|
||||
(when-let [inv (node/invert m)]
|
||||
(let [q (through inv (mapv - c t))]
|
||||
(mapv - (mapv - c (through (linear v') (mapv - q a))) a)))))
|
||||
|
||||
(defn middle
|
||||
"The middle of `bounds` — what the node draws, in its own coordinates — through
|
||||
its own transform, so a parent-space point; or its own origin when it draws
|
||||
nothing. What a node nobody has pivoted yet turns about."
|
||||
[v bounds]
|
||||
(let [[x0 y0 x1 y1] bounds]
|
||||
(through (local-of v)
|
||||
(if bounds [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)] [0 0]))))
|
||||
|
||||
(defn pivot
|
||||
"The parent-space point a drag on this node turns and scales about: ITS OWN
|
||||
PIVOT, `pos + piv` — or, for a node nobody has pivoted yet, the middle of
|
||||
`bounds`, what it draws in its own coordinates, through its own transform.
|
||||
|
||||
THE CHOSEN ONE WINS, and that is the whole of the rule. A pivot is where
|
||||
somebody put it: it does not follow the drawing afterwards, any more than
|
||||
Flash's transformation point or a Harmony layer's pivot does, because a turn
|
||||
that quietly changes its centre when a symbol is edited is worse than one
|
||||
sitting somewhere a hand can see and move it. `ui/stage` draws the cross here
|
||||
and `::ui/repivot` drags it.
|
||||
|
||||
THE DERIVED ONE IS A DEFAULT AND NOT A BEHAVIOUR. `pick/bounds-of` on the same
|
||||
frame is where the bounds come from, so the cross and the selection box start
|
||||
out as one computation — and the first turn or scale WRITES IT DOWN, which is
|
||||
`with-pivot`. A `:group` draws nothing, so `bounds` is nil and this is its own
|
||||
origin, which is where a peg was put.
|
||||
|
||||
`middle` is the derived half on its own, for `centred` — which is the way back
|
||||
to it once a pivot HAS been chosen."
|
||||
[v bounds]
|
||||
(if (:chosen? v) (mapv + (:pos v) (:pivot v)) (middle v bounds)))
|
||||
|
||||
(defn at-pivot?
|
||||
"Is parent-space point `c` where the node already pivots?
|
||||
|
||||
WITHIN A MILLIONTH OF A PIXEL, because this asks a question about intent and
|
||||
gets an answer in floating point: `paint/centred` subtracts the middle of a
|
||||
ring from its own points, so re-deriving that middle from the result lands on
|
||||
zero to within the rounding of the subtraction, a part in 1e14 of the
|
||||
coordinates. A point that close to the pivot IS the pivot — there is no gesture
|
||||
in which a millionth of a pixel is a pivot somewhere else — and the whole point
|
||||
of asking is to leave a drawing's channels alone: a pivot of `[0 0]` written
|
||||
onto a shape that already turns about its own middle is a key nobody asked for
|
||||
on a value that was already right."
|
||||
[v c]
|
||||
(let [[dx dy] (mapv - c (mapv + (:pos v) (:pivot v)))]
|
||||
(< (js/Math.hypot dx dy) 1e-6)))
|
||||
|
||||
(defn repivot
|
||||
"The channel values that put the node's pivot on parent-space point `c` WITHOUT
|
||||
MOVING THE PICTURE, or nil when its linear part is singular.
|
||||
|
||||
Local is `T(pos)·T(a)·M·T(-a)`, so the new pivot has to be the material point
|
||||
that is under `c` now, and the new position has to put it there:
|
||||
|
||||
a' = a + M⁻¹(c − (pos + a)) the point under c, in the node's own space
|
||||
p' = c − a' so that p' + a' = c
|
||||
|
||||
and then `p' + a' + M(x − a')` is `pos + a + M(x − a)` for EVERY x, which is
|
||||
the \"moving nothing\" in the first line, exact and not to a tolerance.
|
||||
|
||||
WITH NO ROTATION OR SCALE IT DOES NOT TOUCH `pos` AT ALL — `M = I` gives
|
||||
`a' = c − pos` and `p' = pos` — which is the ordinary case of setting a pivot up
|
||||
before animating, and is why this can be done quietly inside a first turn
|
||||
without starting a position channel nobody asked for.
|
||||
|
||||
EXACT ON THE FRAME IT IS WRITTEN. `M` is this frame's, so on a node whose
|
||||
rotation or scale is already keyed, the compensation that holds the picture
|
||||
still here is not the one that would hold it still three frames later. That is
|
||||
not an artefact of the arithmetic: moving a pivot genuinely changes what the
|
||||
keyed angles mean. Flash and Harmony both let you do it and both move the
|
||||
in-betweens; the alternative is refusing to repivot anything already animated,
|
||||
which is the node a pivot is most often wrong on."
|
||||
[v c]
|
||||
(when-let [inv (node/invert (linear v))]
|
||||
(let [a' (mapv + (:pivot v) (through inv (mapv - c (mapv + (:pos v) (:pivot v)))))]
|
||||
{[:xform :pivot] a'
|
||||
[:xform :pos] (mapv - c a')})))
|
||||
|
||||
(defn centred
|
||||
"The channel values that put the node's pivot back on the middle of what it
|
||||
draws NOW, moving nothing. Nil when its linear part is singular.
|
||||
|
||||
THE WAY BACK, and the thing a stored pivot needs in order to be safe to store.
|
||||
A pivot does not follow the drawing — that is the point of storing it, since a
|
||||
keyed spin must not be re-aimed by someone drawing one more shape inside the
|
||||
symbol — but a drawing does grow, and \"put it back in the middle of what is
|
||||
there now\" is then an obvious thing to want and an unobvious thing to do by
|
||||
hand. It is `repivot` at the point `pivot` would have derived, so the button
|
||||
and the default cannot disagree: this is exactly where an untouched node's
|
||||
cross already is."
|
||||
[v bounds]
|
||||
(repivot v (middle v bounds)))
|
||||
|
||||
(defn- with-pivot
|
||||
"`[v vs]`: the node's transform with its pivot on parent-space `c`, and the
|
||||
channel values that put it there — or `v` untouched and nil, when that is where
|
||||
it pivots already or when it has a pivot of its own.
|
||||
|
||||
THE ONE PLACE A DERIVED PIVOT BECOMES A STORED ONE. `turn` and `scale` both
|
||||
start here, so the first drag on a node nobody has pivoted writes the middle of
|
||||
what it draws down with the gesture, in the same edit, and every drag after it
|
||||
turns about the stored one. The returned `v` carries the new pivot, because the
|
||||
gesture itself is measured about it: scaling about the pivot it is in the act of
|
||||
choosing is one answer, not two."
|
||||
[v c]
|
||||
(if (and c (not (:chosen? v)) (not (at-pivot? v c)))
|
||||
(if-let [vs (repivot v c)]
|
||||
[(assoc v :pivot (get vs [:xform :pivot]) :pos (get vs [:xform :pos])) vs]
|
||||
[v nil])
|
||||
[v nil]))
|
||||
|
||||
(defn move
|
||||
"The node's position with the drag carried from stage point `p0` to `p1`.
|
||||
|
||||
ONE CHANNEL, AND IT NEVER DISTURBS THE PIVOT: `[:xform :pivot]` is in the
|
||||
node's own coordinates, so it travels with the node and a move is `pos` alone,
|
||||
exactly as it was before there was a pivot at all."
|
||||
[{:keys [parent]} {:keys [pos]} p0 p1]
|
||||
(when-let [inv (node/invert parent)]
|
||||
{[:xform :pos] (mapv + pos (mapv - (through inv p1) (through inv p0)))}))
|
||||
|
||||
(defn angle
|
||||
"The angle of stage point `p` about parent-space pivot `c`, in the space the
|
||||
node's rotation is in."
|
||||
[{:keys [parent]} c p]
|
||||
(when-let [inv (node/invert parent)]
|
||||
(let [[x y] (mapv - (through inv p) c)]
|
||||
(js/Math.atan2 y x))))
|
||||
|
||||
(defn turn
|
||||
"The node turned by `da` radians about parent-space point `c`: the rotation, and
|
||||
— only on the first turn of a node nobody has pivoted — the pivot and position
|
||||
that put its pivot on `c` without moving it.
|
||||
|
||||
ONE CHANNEL, ONCE THE PIVOT IS ITS OWN, and that is the whole reason the pivot
|
||||
is in `node/local!` rather than solved for here. `rot` is the only thing a turn
|
||||
changes, so a keyed turn interpolates ONE number and the matrix is composed
|
||||
about the pivot on every frame of it: the pivot is held exactly between two keys
|
||||
and not merely at them. A `pos` written beside the rotation — which is what
|
||||
`about` solves for, and what this used to do for anything whose origin was not
|
||||
its middle — tweens along the chord of an arc it has no way to know about, and a
|
||||
360° turn keyed that way leaves the stage in the middle and comes back.
|
||||
|
||||
`c` IS A DEFAULT, NOT A TARGET. A node with its own pivot ignores it and turns
|
||||
about what it has, which is what makes a pivot something a hand can place and
|
||||
rely on; `pivot` is where `c` comes from either way."
|
||||
[v c da]
|
||||
(let [[v vs] (with-pivot v c)]
|
||||
(merge vs {[:xform :rot] (+ (:rot v) da)})))
|
||||
|
||||
(defn scale
|
||||
"The node's scale with the point under stage `p0` taken to `p1`, about its own
|
||||
pivot, along the node's own axes — or by the same factor on both when
|
||||
`uniform?` — and, on the first scale of a node nobody has pivoted, the pivot and
|
||||
position that put its pivot on `c` without moving it.
|
||||
|
||||
The factors are measured in the node's OWN coordinates, which is what makes a
|
||||
corner drag track the pointer on a node that has been turned, and they are
|
||||
measured FROM THE PIVOT `q`: scaling by `k` takes `q + d` to `q + k·d`, so the
|
||||
factor a corner wants is the ratio of its offsets from the pivot before and
|
||||
after. `with-pivot` runs first because a pivot being chosen by this very drag is
|
||||
the pivot the drag has to be measured about."
|
||||
[{:keys [world]} v c p0 p1 uniform?]
|
||||
(let [[v vs] (with-pivot v c)]
|
||||
(when-let [winv (node/invert world)]
|
||||
(let [q (:pivot v)
|
||||
a (mapv - (through winv p0) q)
|
||||
b (mapv - (through winv p1) q)
|
||||
k (fn [a b] (if (< (js/Math.abs a) 1e-6) 1 (/ b a)))
|
||||
r (when uniform?
|
||||
(let [aa (reduce + (map * a a))]
|
||||
(if (< aa 1e-9) 1 (/ (reduce + (map * a b)) aa))))
|
||||
s (:scale v)
|
||||
s' (if r (mapv #(* r %) s) (mapv * s (map k a b)))]
|
||||
(merge vs {[:xform :scale] s'})))))
|
||||
|
||||
(defn scale-by
|
||||
"The node scaled by factor `k` on both axes about parent-space point `c`, and
|
||||
the position that holds `c` still.
|
||||
|
||||
What a MULTI-SELECTION scales by: one factor for everything about the shared
|
||||
box, so a group of shapes keeps its arrangement instead of each member solving
|
||||
for its own factors. The point belongs to the box and not to any node in it, so
|
||||
this is the one gesture that still writes a position to hold a pivot still —
|
||||
`about`, with everything its docstring says that costs. `scale` is the
|
||||
single-node form, where the factors come out of the pointer in the node's own
|
||||
axes and the pivot is the node's own."
|
||||
[v c k]
|
||||
(let [v' (update v :scale #(mapv (partial * k) %))]
|
||||
(cond-> {[:xform :scale] (:scale v')}
|
||||
c (into (when-let [p (about v v' c)] {[:xform :pos] p})))))
|
||||
|
||||
(def ^:private manual-layer ::manual-transform)
|
||||
|
||||
(defn- plus [a b]
|
||||
(if (number? a) (+ a b) (mapv + (vec a) (vec b))))
|
||||
|
||||
(defn- minus [a b]
|
||||
(if (number? a) (- a b) (mapv - (vec a) (vec b))))
|
||||
|
||||
(defn- corrected-channel
|
||||
"Set the effective value of a measured channel without replacing its base."
|
||||
[channel f target store extent auto-key?]
|
||||
(let [current (ch/value-at channel f store)
|
||||
layers (vec (:over channel))
|
||||
i (first (keep-indexed #(when (= manual-layer (:id %2)) %1) layers))
|
||||
old (when i (ch/value-at (get-in layers [i :values]) f store))
|
||||
zero (if (number? target) 0 (vec (repeat (count target) 0)))
|
||||
value (plus (or old zero) (minus target current))
|
||||
values (or (when i (get-in layers [i :values])) (ch/framed zero))
|
||||
values ((if auto-key? node/set-keyed-channel node/set-channel)
|
||||
{:kind :group :channels {[:xform :pos] values}}
|
||||
[:xform :pos] f value)
|
||||
values (get-in values [:channels [:xform :pos]])
|
||||
layer (ch/layer manual-layer [0 extent] :offset values)]
|
||||
(assoc channel :over (if i (assoc layers i layer) (conj layers layer)))))
|
||||
|
||||
(defn apply-values
|
||||
"Clip with channel values `vs`, `{path value}`, written into node `id` of
|
||||
symbol `sid` on the node's own frame `f`, by the keying rule above. The sixth
|
||||
argument arms auto-key; the five-argument form retains the normal rule."
|
||||
([clip sid id f vs] (apply-values clip sid id f vs false nil))
|
||||
([clip sid id f vs auto-key?] (apply-values clip sid id f vs auto-key? nil))
|
||||
([clip sid id f vs auto-key? store]
|
||||
(let [put (if auto-key? node/set-keyed-channel node/set-channel)]
|
||||
(update-in clip [:symbols sid :nodes id]
|
||||
#(reduce-kv
|
||||
(fn [n path v]
|
||||
(if (measured-channel? n path)
|
||||
(update-in n [:channels path] corrected-channel f v store
|
||||
(get-in clip [:symbols sid :frames]) auto-key?)
|
||||
(put n path f v)))
|
||||
% vs)))))
|
||||
|
||||
(defn apply-take
|
||||
"Apply a buffered performance take. `take` is keyed by `[symbol node]`, then
|
||||
local frame, then channel path. It becomes ordinary authored keys in one
|
||||
document edit rather than making the edit pipeline run for every sample."
|
||||
([clip take] (apply-take clip take nil))
|
||||
([clip take store]
|
||||
(reduce-kv
|
||||
(fn [c [sid id] frames]
|
||||
(reduce-kv (fn [c f values]
|
||||
(apply-values c sid id f values true store))
|
||||
c frames))
|
||||
clip take)))
|
||||
130
frontend/src/arthur/domain/history.cljs
Normal file
130
frontend/src/arthur/domain/history.cljs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
(ns arthur.domain.history
|
||||
"Undo, per person, as leaf writes. docs/architecture.md, \"Undo is per-user\".
|
||||
|
||||
A step is the leaves one edit changed: what they held before, and what they
|
||||
held after. Undoing writes the befores back as an ordinary edit, which the
|
||||
next save sends like any other — so undo needs nothing from the server, and
|
||||
nothing about it is shared.
|
||||
|
||||
ONLY YOUR OWN CHANGES. A step undoes only if every leaf it touched still holds
|
||||
what the step left there. Somebody else's write to one of them since — their
|
||||
edit to the shape you made — refuses the step rather than taking their work
|
||||
with it; it is dropped, and the next undo is the step before. With nobody else
|
||||
in the document the values always match, and this is ordinary undo.
|
||||
|
||||
A nil value is an absent leaf: a step that made a node has nil befores for its
|
||||
leaves, so undoing it removes them."
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
(def gap-ms
|
||||
"Edits to the same leaves closer together than this are one step: a drag
|
||||
writes a vertex per pointermove, and is one thing to undo. Only when each
|
||||
starts where the last left off — anything landing between them, a
|
||||
collaborator's write included, makes the next edit a step of its own."
|
||||
1000)
|
||||
|
||||
(def depth 200)
|
||||
|
||||
(defn- changes
|
||||
"`[before after]`, restricted to the paths that differ."
|
||||
[before after]
|
||||
(reduce (fn [[b a :as acc] path]
|
||||
(let [x (get before path)
|
||||
y (get after path)]
|
||||
(if (= x y) acc [(assoc b path x) (assoc a path y)])))
|
||||
[{} {}]
|
||||
(distinct (concat (keys before) (keys after)))))
|
||||
|
||||
(defn- node-name [leaves path]
|
||||
(let [[_ _ _ sid _ nid] (str/split path #"/")
|
||||
node (get leaves (str/join "/" ["clip" "u" "symbol" sid "node" nid]))]
|
||||
(or (:name node) (str/replace nid "~" "/"))))
|
||||
|
||||
(defn- said
|
||||
"What one changed leaf was, in words, and how much it outranks the others:
|
||||
making or deleting a thing names the step before editing it does."
|
||||
[before after path]
|
||||
(let [[_ _ kind a b] (str/split path #"/")
|
||||
leaves (merge before after)]
|
||||
(case [kind b]
|
||||
["symbol" nil] [1 (str "symbol " (or (:name (get leaves path)) (str/replace a "~" "/")))]
|
||||
["symbol" "node"]
|
||||
(cond (nil? (get before path)) [0 (str "add " (node-name leaves path))]
|
||||
(nil? (get after path)) [0 (str "delete " (node-name leaves path))]
|
||||
:else [1 (str "edit " (node-name leaves path))])
|
||||
(if (#{"channel" "measured"} b)
|
||||
[1 (str "edit " (node-name leaves path))]
|
||||
[2 (case kind
|
||||
("timing" "stage" "name") "project settings"
|
||||
("subject" "feature" "group") "tracking settings"
|
||||
kind)]))))
|
||||
|
||||
(defn label
|
||||
"A step in words: \"add shape 3\", \"edit mouth, brow-l\"."
|
||||
[before after paths]
|
||||
(let [said (->> paths (map #(said before after %)) distinct sort)
|
||||
top (first (first said))
|
||||
words (distinct (map second (filter #(= top (first %)) said)))]
|
||||
(str (str/join ", " (take 2 words)) (when (< 2 (count words)) " …"))))
|
||||
|
||||
(defn record
|
||||
"History `h` with an edit from leaves `before` to `after` at time `now`."
|
||||
[{:keys [done held?] :as h} before after now]
|
||||
(let [[b a] (changes before after)
|
||||
top (peek done)]
|
||||
(cond
|
||||
(empty? a) h
|
||||
(and top (not (:closed? top)) (= b (:after top))
|
||||
(or held? (< (- now (:at top)) gap-ms)))
|
||||
(assoc h :done (conj (pop done) (assoc top :after a :at now)) :undone [])
|
||||
:else
|
||||
(assoc h
|
||||
:done (conj (vec (take-last (dec depth) done))
|
||||
{:before b :after a :at now :label (label before after (keys a))})
|
||||
:undone []))))
|
||||
|
||||
(defn- close [{:keys [done] :as h}]
|
||||
(cond-> h (seq done) (assoc :done (conj (pop done) (assoc (peek done) :closed? true)))))
|
||||
|
||||
(defn hold
|
||||
"While a field has focus, everything typed into it is one step, however slowly
|
||||
— the digits of 45 are seen as 4 and then 45, and undone as one. It starts a
|
||||
step of its own rather than joining whatever came before."
|
||||
[h]
|
||||
(assoc (close h) :held? true))
|
||||
|
||||
(defn settle
|
||||
"The field is done with: its step is finished, and nothing joins it."
|
||||
[h]
|
||||
(dissoc (close h) :held?))
|
||||
|
||||
(defn steps
|
||||
"The labels, newest first: `:done` is what undo would take off, `:undone`
|
||||
what redo would put back."
|
||||
[h]
|
||||
{:done (mapv :label (rseq (or (:done h) [])))
|
||||
:undone (mapv :label (rseq (or (:undone h) [])))})
|
||||
|
||||
(defn- holds? [leaves m]
|
||||
(every? (fn [[path v]] (= v (get leaves path))) m))
|
||||
|
||||
(defn- put-all [leaves m]
|
||||
(reduce-kv (fn [ls path v] (if (nil? v) (dissoc ls path) (assoc ls path v))) leaves m))
|
||||
|
||||
(defn- move
|
||||
"One step from `from` to `to`, if `leaves` still hold what it expects."
|
||||
[h leaves from to expect write]
|
||||
(when-let [step (peek (get h from))]
|
||||
(let [h (update h from pop)]
|
||||
(if (holds? leaves (expect step))
|
||||
{:leaves (put-all leaves (write step)) :history (update h to (fnil conj []) step)}
|
||||
{:blocked step :history h}))))
|
||||
|
||||
(defn undo
|
||||
"`{:leaves :history}`, `{:blocked :history}` when somebody else has since
|
||||
changed what the step touched, or nil with nothing to undo."
|
||||
[h leaves]
|
||||
(move h leaves :done :undone :after :before))
|
||||
|
||||
(defn redo [h leaves]
|
||||
(move h leaves :undone :done :before :after))
|
||||
37
frontend/src/arthur/domain/keyframes.cljs
Normal file
37
frontend/src/arthur/domain/keyframes.cljs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
(ns arthur.domain.keyframes
|
||||
(:require [arthur.domain.channel :as ch]))
|
||||
|
||||
(defn identity-of [k] (select-keys k [:sid :id :channel :frame]))
|
||||
|
||||
(defn shifted [items delta]
|
||||
(let [delta (max delta (reduce max js/Number.NEGATIVE_INFINITY (map #(max (- (:at %)) (- (* (:frame %) (:scale %)))) items)))]
|
||||
(mapv (fn [k]
|
||||
(let [f (max 0 (js/Math.round (+ (:frame k) (/ delta (:scale k)))))]
|
||||
(assoc k :frame f :at (+ (:at k) (* (:scale k) (- f (:frame k))))))) items)))
|
||||
|
||||
(defn edit-keys [document items delta]
|
||||
(let [targets-by-id (when (some? delta)
|
||||
(into {} (map vector (map identity-of items) (shifted items delta))))]
|
||||
(reduce
|
||||
(fn [doc [[sid id channel] selected]]
|
||||
(let [path [:symbols sid :nodes id :channels channel]
|
||||
c (get-in doc path)
|
||||
selected (filter #(contains? (:keys c) (:frame %)) selected)
|
||||
targets (when (some? delta) (mapv #(get targets-by-id (identity-of %)) selected))
|
||||
remaining (apply dissoc (:keys c) (map :frame selected))
|
||||
ks (if targets
|
||||
(reduce (fn [ks [old new]] (assoc ks (:frame new) (get (:keys c) (:frame old))))
|
||||
remaining (map vector selected targets))
|
||||
remaining)
|
||||
segments (apply dissoc (:segments c) (concat (map :frame selected) (map :frame targets)))
|
||||
segments (if targets
|
||||
(reduce (fn [s [old new]]
|
||||
(if (contains? (:segments c) (:frame old))
|
||||
(assoc s (:frame new) (get (:segments c) (:frame old))) s))
|
||||
segments (map vector selected targets)) segments)]
|
||||
(if (empty? selected) doc
|
||||
(assoc-in doc path
|
||||
(if (seq ks)
|
||||
(cond-> (assoc c :keys ks) (:segments c) (assoc :segments segments))
|
||||
(ch/framed (get (:keys c) (:frame (last selected)))))))))
|
||||
document (group-by (juxt :sid :id :channel) (vals (into {} (map (juxt identity-of identity) items)))))))
|
||||
|
|
@ -10,22 +10,24 @@
|
|||
clip/<cid>/name a label
|
||||
clip/<cid>/timing fps
|
||||
clip/<cid>/stage width, height
|
||||
clip/<cid>/palette/<pid> a named indexed palette asset
|
||||
clip/<cid>/palette-default the project fallback palette id
|
||||
clip/<cid>/root the symbol the document opens on
|
||||
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> native frames and fps, optional palette
|
||||
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
|
||||
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.
|
||||
The timing leaf holds output fps. Each symbol leaf holds its native fps
|
||||
and frame count, so changing the output grid leaves content untouched.
|
||||
|
||||
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
|
||||
|
|
@ -44,9 +46,10 @@
|
|||
|
||||
WHY `measured` IS ONE LEAF AND CHANNELS ARE NOT. `:head`'s measured channels are
|
||||
not authored: they are written together by a freeze and replaced together by a
|
||||
re-freeze, and `head-mode` exposes them through `:channels`. The optional
|
||||
`:anchors` map on the head node chooses which measured frame those channels
|
||||
read. A leaf per measured
|
||||
re-freeze, and `head-mode` exposes them through `:channels`. The same is true
|
||||
of a face's `:plate`, whose measured channels register its footage. Which
|
||||
measured frame they read is the node's `:reads` and `:time :holds`, which
|
||||
save with the node. A leaf per measured
|
||||
channel would offer a write nobody can make. The authored channels beside them
|
||||
are one leaf each, because a hand writes one at a time.
|
||||
|
||||
|
|
@ -56,7 +59,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,46 +127,51 @@
|
|||
(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
|
||||
(some-leaf (at "name") (select-keys clip [:name]))
|
||||
(some-leaf (at "timing") (select-keys clip [:fps]))
|
||||
(some-leaf (at "stage") (select-keys clip [:width :height]))
|
||||
(some-leaf (at "source") (:analysis clip))
|
||||
(some-leaf (at "palette-default") (select-keys clip [:default-palette]))
|
||||
(some-leaf (at "root") (select-keys clip [:root]))
|
||||
(some-leaf (at "analyses") (:analyses clip))
|
||||
(concat
|
||||
(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
|
||||
(for [[id v] (:palettes clip)] {(at "palette" (segment id)) v})
|
||||
;; The symbol's own facts. `:id` is the path segment, so writing it
|
||||
;; into the value as well would be the one field a rename could
|
||||
;; disagree with itself about; `clip` puts it back.
|
||||
(for [[tid tl] (:timelines clip)]
|
||||
{(at "timeline" (segment tid))
|
||||
(select-keys tl [:frames :palette])})
|
||||
(for [[tid tl] (:timelines clip)
|
||||
[id n] (:nodes tl)]
|
||||
{(at "timeline" (segment tid) "node" (segment id))
|
||||
(for [[sid sym] (:symbols clip)]
|
||||
{(at "symbol" (segment sid))
|
||||
(select-keys sym [:name :frames :fps :width :height :palette
|
||||
:palette-track :palette-channel :type :palette-ref :display
|
||||
:media])})
|
||||
(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 +181,16 @@
|
|||
(let [[_ found kind a b c] (str/split path #"/")]
|
||||
(if-not (= want found)
|
||||
acc
|
||||
(if (= "timeline" kind)
|
||||
(let [tid (unsegment a)
|
||||
acc (assoc-in acc [:timelines tid :id] tid)]
|
||||
(if (= "symbol" kind)
|
||||
(let [sid (unsegment a)
|
||||
acc (assoc-in acc [:symbols sid :id] sid)]
|
||||
(case b
|
||||
nil (update-in acc [:timelines tid] merge v)
|
||||
"node" (update-in acc [:timelines tid :nodes (unsegment c)] merge v)
|
||||
"measured" (assoc-in acc [:timelines tid :nodes (unsegment c) :measured] v)
|
||||
"channel" (assoc-in acc [:timelines tid :nodes (unsegment c)
|
||||
;; `:nodes` is there before any node leaf is: an empty
|
||||
;; symbol has none, and is still a symbol.
|
||||
nil (update-in acc [:symbols sid] #(merge {:nodes {}} % v))
|
||||
"node" (update-in acc [:symbols sid :nodes (unsegment c)] merge v)
|
||||
"measured" (assoc-in acc [:symbols sid :nodes (unsegment c) :measured] v)
|
||||
"channel" (assoc-in acc [:symbols sid :nodes (unsegment c)
|
||||
:channels (path->prop (nth (str/split path #"/") 6))]
|
||||
v)
|
||||
(throw (ex-info "not a leaf path" {:path path}))))
|
||||
|
|
@ -188,7 +198,10 @@
|
|||
"name" (merge acc v)
|
||||
"timing" (merge acc v)
|
||||
"stage" (merge acc v)
|
||||
"source" (assoc acc :analysis v)
|
||||
"analyses" (assoc acc :analyses v)
|
||||
"palette-default" (merge acc v)
|
||||
"root" (merge acc v)
|
||||
"palette" (assoc-in acc [:palettes (unsegment a)] v)
|
||||
"subject" (assoc-in acc [:subjects (unsegment a)] v)
|
||||
"feature" (assoc-in acc [:features (unsegment a)] v)
|
||||
"group" (assoc-in acc [:groups (unsegment a)] v)
|
||||
|
|
@ -210,27 +223,27 @@
|
|||
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)
|
||||
(case (count p)
|
||||
;; The clip's own facts carry no id.
|
||||
3 (#{"name" "timing" "stage" "source"} (nth p 2))
|
||||
4 (#{"subject" "feature" "group"} (nth p 2))
|
||||
3 (#{"name" "timing" "stage" "analyses" "palette-default" "root"} (nth p 2))
|
||||
4 (#{"subject" "feature" "group" "palette"} (nth p 2))
|
||||
false))))]
|
||||
(vec
|
||||
(concat
|
||||
|
|
@ -238,13 +251,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"))))))
|
||||
|
|
|
|||
718
frontend/src/arthur/domain/nest.cljs
Normal file
718
frontend/src/arthur/domain/nest.cljs
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
(ns arthur.domain.nest
|
||||
"How nested symbols relate, and moving things between them.
|
||||
|
||||
A row path — the ids from the open symbol down through instances, as the
|
||||
timeline names a row — says where something is. Walking one answers three
|
||||
questions at once, which is why there is one walk: what frame is showing down
|
||||
there, what matrix takes its coordinates up to the open symbol's, and what
|
||||
time map takes the open symbol's frames down to its own.
|
||||
|
||||
ONE NESTING, AS FAR AS A PERSON IS CONCERNED. Putting a node inside another
|
||||
symbol is how things are grouped: the symbol is a shared timeline, and its
|
||||
instance is the handle that moves, retimes and transforms everything in it
|
||||
together. Parent pointers inside a symbol stay — the roto rig is built on them
|
||||
— but they are not something the timeline hands out.
|
||||
|
||||
A MOVE CHANGES NEITHER THE PICTURE NOR THE TIMING. Every node has the same two
|
||||
maps into its parent — the matrix of its transform, and `node/time-of` — and a
|
||||
move keeps a node's world maps and re-expresses them under the new parent: the
|
||||
matrix becomes a `:pinv`, Blender's parent-inverse, and the time a new `:at`
|
||||
and `:rate`. Its channels, keys and span are untouched."
|
||||
(:require [arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.gesture :as gesture]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.pick :as pick]
|
||||
[arthur.domain.span :as span]
|
||||
[arthur.domain.symbol :as symbol]))
|
||||
|
||||
(defn- resolved
|
||||
"Node `id` of symbol `sid`, resolved at `frame`: the resolver, which then
|
||||
answers `symbol/world-of` and `symbol/frame-of` for it on that frame.
|
||||
|
||||
Only its lineage is resolved, because where a node is depends on its parents
|
||||
and nothing else in the symbol — and the whole symbol costs more than a frame
|
||||
of the stage, which the editor asks for on every frame."
|
||||
[clip store sid frame id]
|
||||
(let [sym (clip/symbol clip sid)
|
||||
sym (update sym :nodes select-keys (symbol/lineage (:nodes sym) id))
|
||||
r (symbol/resolver sym store pal/index-of nil)]
|
||||
(r frame)
|
||||
r))
|
||||
|
||||
(defn inside
|
||||
"Walk row path `path` down from symbol `sid`, whose OWN frame `f` is showing,
|
||||
into the node it ends at. Returns `{:sid :frame :matrix :time}`: the symbol that
|
||||
node places (nil for one that places none), the frame of its own it is
|
||||
showing, the matrix from its coordinates to `sid`'s, and the time map from
|
||||
`sid`'s frames to its own — or nil when a node on the way is not on screen at
|
||||
that frame, where there is no inside to be in.
|
||||
|
||||
`f` IS ONE OF `sid`'S OWN FRAMES, which is the coordinate everything authored
|
||||
and every editing gesture is in — see `docs/one-grid-plan.md`. It used to be an
|
||||
OUTPUT frame, multiplied into `sid`'s space here, which made the units of the
|
||||
argument something each caller had to know without being told and put a
|
||||
2.5-frame step between what the ruler offered and what the document could hold.
|
||||
A caller holding the playhead converts with `clip/shown-frame`.
|
||||
|
||||
THE SAME STEP FOR EVERY NODE. Inside an instance is the symbol it places;
|
||||
inside a shape is where its points and keys are. Either way it is the node's
|
||||
own coordinates and frames, so a shape any depth down is edited through the
|
||||
maps it is drawn with.
|
||||
|
||||
The frame and the matrix come from RESOLVING each level, so they are the ones
|
||||
the stage draws with, floors included. The time map is the affine part, floors
|
||||
aside, and is nil through a looping node, whose frames come round again and do
|
||||
not map one to one."
|
||||
[clip store sid path f]
|
||||
(reduce (fn [{:keys [sid frame matrix time]} id]
|
||||
(let [r (resolved clip store sid frame id)
|
||||
nodes (:nodes (clip/symbol clip sid))
|
||||
chain (map #(get nodes %) (rseq (symbol/lineage nodes id)))
|
||||
m (symbol/world-of r id)
|
||||
local (symbol/frame-of r id)
|
||||
inst? (= :instance (:kind (get nodes id)))
|
||||
;; WHICH symbol, and which frame of it, are both read off the
|
||||
;; cel: inside a held cel is its drawing on the frame
|
||||
;; the hold pins, not on `local`, and inside a playing insert
|
||||
;; is its animation at its own in-point and speed.
|
||||
shown (when (number? local)
|
||||
(clip/placed-frame clip sid (get nodes id) local))
|
||||
inner (:symbol shown)
|
||||
lf (if shown (:frame shown) local)]
|
||||
(if (and m (number? local)
|
||||
;; A clip over a gap has no inside to be in.
|
||||
(or (not inst?) shown)
|
||||
(or (nil? inner) (< -1 lf (clip/frames clip inner))))
|
||||
{:sid inner :frame (js/Math.floor lf)
|
||||
:matrix (node/mul! (node/mat) matrix m)
|
||||
;; Forward sampling above works for holds too. :time is the
|
||||
;; invertible edit map; source in-points and speeds belong in it.
|
||||
:time (when (and time (not-any? #(get-in % [:time :loop?]) chain)
|
||||
(or (not inst?) (clip/source-time clip sid (get nodes id))))
|
||||
(cond-> (reduce node/then-time time (map node/time-of chain))
|
||||
inst? (node/then-time (clip/source-time clip sid (get nodes id)))))}
|
||||
(reduced nil))))
|
||||
{:sid sid :frame (js/Math.floor f)
|
||||
:matrix (node/mat) :time node/same-time}
|
||||
path))
|
||||
|
||||
(defn own-time
|
||||
"The time map from symbol `sid`'s frames to the OWN frames of the node at row
|
||||
path `path` from it, with `sid` showing frame `f`: the frames its keys and
|
||||
holds are written in. Nil where no affine map exists — through a loop or a
|
||||
floor above the node, or where it is not on screen.
|
||||
|
||||
It STOPS AT THE NODE, where `inside` goes into what the node places, and it
|
||||
leaves out the node's own floors: a hold is added on a frame the node's holds
|
||||
would otherwise floor away."
|
||||
[clip store sid path f]
|
||||
(when-let [{inner :sid t :time} (inside clip store sid (pop path) f)]
|
||||
(let [nodes (:nodes (clip/symbol clip inner))
|
||||
n (get nodes (peek path))
|
||||
up (symbol/frame-map nodes (:parent n))]
|
||||
(when (and n t up)
|
||||
(-> t (node/then-time up) (node/then-time (node/time-of n)))))))
|
||||
|
||||
(defn placement
|
||||
"Where the node at row path `path` is, from symbol `sid` showing frame `f`, as
|
||||
a transform would change it: `{:sid :id :frame :parent :world}` — the symbol it
|
||||
lives in, its own frame, the matrix from the space its `[:xform :pos]` is in to
|
||||
`sid`'s, and the one from its own coordinates. Nil when it is not on screen.
|
||||
|
||||
`:parent` is everything above the node's own transform, `world = parent ·
|
||||
local`: its symbol's way to the stage, its parents there, and its `:pinv`."
|
||||
[clip store sid path f]
|
||||
(when-let [{:keys [sid frame matrix]} (inside clip store sid (pop path) f)]
|
||||
(let [id (peek path)
|
||||
n (get-in clip [:symbols sid :nodes id])
|
||||
r (when n (resolved clip store sid frame id))
|
||||
w (when r (symbol/world-of r id))]
|
||||
(when w
|
||||
{:sid sid :id id
|
||||
:frame (js/Math.floor (symbol/frame-of r id))
|
||||
:parent (reduce #(node/mul! (node/mat) %1 %2) matrix
|
||||
(keep identity [(some->> (:parent n) (symbol/world-of r))
|
||||
(node/pinv n)]))
|
||||
:world (node/mul! (node/mat) matrix w)}))))
|
||||
|
||||
(defn drawn-inside
|
||||
"Flat points drawn on symbol `sid`'s stage at frame `f`, re-expressed inside the
|
||||
symbol `path` leads to, so a shape added there lands exactly where it was drawn.
|
||||
`{:sid :frame :pts}`, or nil where `inside` finds nothing to be inside."
|
||||
[clip store sid path f pts]
|
||||
(when-let [{:keys [matrix] :as at} (inside clip store sid path f)]
|
||||
(when-let [inv (node/invert matrix)]
|
||||
(let [out (js/Float64Array. 2)]
|
||||
(assoc (select-keys at [:sid :frame])
|
||||
:pts (into [] (mapcat (fn [[x y]]
|
||||
(node/apply-pt! out 0 inv x y)
|
||||
[(aget out 0) (aget out 1)]))
|
||||
(partition 2 pts)))))))
|
||||
|
||||
(defn audio-tracks
|
||||
"Flatten audible source intervals through cel and parent clocks.
|
||||
A held visual source is silent. Every returned track carries a source offset,
|
||||
an output interval, and automation mapped into the open symbol's time.
|
||||
|
||||
Automatically associated face audio can be reached more than once in a
|
||||
multi-face take. Equal `:media-link`s at the same source and output interval
|
||||
are one recording, not a louder mix; at different placements they remain
|
||||
separate scheduled clips."
|
||||
[clip sid]
|
||||
(letfn [(to-local [m f] (* (:rate m) (- f (:at m))))
|
||||
(to-outer [m f] (+ (:at m) (/ f (:rate m))))
|
||||
(window [m span bounds]
|
||||
(if span
|
||||
[(max (first bounds) (to-outer m (first span)))
|
||||
(min (second bounds) (to-outer m (second span)))]
|
||||
bounds))
|
||||
(channels [chs m]
|
||||
(into {}
|
||||
(map (fn [[p c]]
|
||||
[p (cond-> c
|
||||
(:keys c) (update :keys #(into {} (map (fn [[f v]] [(to-outer m f) v])) %))
|
||||
(:segments c) (update :segments #(into {} (map (fn [[f v]] [(to-outer m f) v])) %))
|
||||
(:dense c) (assoc :sample-time m))]))
|
||||
chs))
|
||||
(walk [sid outer bounds path seen]
|
||||
(when (contains? seen sid)
|
||||
(throw (ex-info "symbol cycle in audio" {:symbol sid})))
|
||||
(let [sym (clip/symbol clip sid)
|
||||
nodes (:nodes sym)
|
||||
bounds (window outer [0 (:frames sym)] bounds)
|
||||
positions (reduce
|
||||
(fn [acc id]
|
||||
(let [n (get nodes id)
|
||||
parent (if-let [pid (:parent n)] (get acc pid)
|
||||
{:time outer :bounds bounds})
|
||||
m (node/then-time (:time parent) (node/time-of n))
|
||||
m (if (= :audio (:kind n))
|
||||
(update m :rate * (/ (or (get-in n [:source :fps]) (clip/fps clip sid))
|
||||
(clip/fps clip sid))) m)]
|
||||
(assoc acc id {:time m :bounds (window m (:span n) (:bounds parent))})))
|
||||
{} (symbol/order nodes))]
|
||||
(mapcat
|
||||
(fn [[id n]]
|
||||
(let [{m :time [lo hi] :bounds} (get positions id)]
|
||||
(when (< lo hi)
|
||||
(case (:kind n)
|
||||
:audio [(-> n
|
||||
(assoc :parent nil :path (conj path id) :owner sid
|
||||
:fps (or (get-in n [:source :fps]) (clip/fps clip sid))
|
||||
:span [(to-local m lo) (to-local m hi)]
|
||||
:time (merge (:time n) {:mode :map :at (:at m) :rate (:rate m) :offset 0})
|
||||
:channels (channels (:channels n) m)))]
|
||||
:instance
|
||||
(let [{:keys [in speed end]} (node/playback-of n)
|
||||
child (node/source n)
|
||||
length (clip/frames clip child)]
|
||||
;; A visual freeze does not emit a sustained audio sample.
|
||||
(when (and child length (pos? speed))
|
||||
(let [source (node/then-time m (clip/source-time clip sid
|
||||
(-> n
|
||||
(assoc-in [:playback :end] :stop)
|
||||
(update :time dissoc :loop?))))
|
||||
loop? (or (= end :loop) (get-in n [:time :loop?]))
|
||||
periods (if loop?
|
||||
(range (js/Math.floor (/ (to-local source lo) length))
|
||||
(js/Math.ceil (/ (to-local source hi) length)))
|
||||
[0])]
|
||||
(mapcat (fn [period]
|
||||
(let [cycle (update source :at + (/ (* period length) (:rate source)))]
|
||||
(walk child cycle [lo hi] (conj path id) (conj seen sid))))
|
||||
periods))))
|
||||
nil))))
|
||||
(sort-by (comp str key) nodes))))]
|
||||
(let [tracks (walk sid (clip/grid-time clip sid)
|
||||
[0 (clip/output-frames clip sid)] [] #{})]
|
||||
(second
|
||||
(reduce (fn [[seen out] track]
|
||||
(let [link (:media-link track)
|
||||
k (when link [link (:source track) (:span track)
|
||||
(select-keys (:time track) [:at :rate :offset])])]
|
||||
(if (and k (contains? seen k))
|
||||
[seen out]
|
||||
[(cond-> seen k (conj k)) (conj out track)])))
|
||||
[#{} []] tracks)))))
|
||||
|
||||
(defn- retime
|
||||
"Node `n` with its own time map replaced by `m`, and nothing else touched: its
|
||||
span and keys are in its own frames, which a move does not change."
|
||||
[n {:keys [at rate]}]
|
||||
(assoc n :time (merge (:time n) {:mode :map :at at :rate rate :offset 0})))
|
||||
|
||||
(defn- subtree
|
||||
"`id` and every node whose parent chain reaches it."
|
||||
[nodes id]
|
||||
(into #{} (filter #(some #{id} (symbol/lineage nodes %))) (keys nodes)))
|
||||
|
||||
(defn delete-node
|
||||
"Take node `id` out of symbol `sid`, with everything hanging off it."
|
||||
[clip sid id]
|
||||
(clip/update-symbol clip sid update :nodes #(apply dissoc % (subtree % id))))
|
||||
|
||||
(defn- transplant
|
||||
"Move node `id` from symbol `host`, where frame `frame` is showing, into symbol
|
||||
`target`, keeping where it is on screen and when. `carry` is the matrix from
|
||||
`host`'s coordinates to `target`'s, and `back` the time map from `target`'s
|
||||
frames to `host`'s.
|
||||
|
||||
THE ONE RULE, for space and time alike: the node's new map is its old one
|
||||
under what it leaves — its parents here, and the way from here to there — so
|
||||
the picture and the timing through it do not change. For space that is a
|
||||
`:pinv`, Blender's parent-inverse; for time it is a new `:at` and `:rate`. Its
|
||||
channels, keys and span are untouched, and its children keep their parent
|
||||
pointers and come with it. `{:clip}` or `{:refused why}`."
|
||||
[clip store host frame id target carry back]
|
||||
(let [nodes (:nodes (clip/symbol clip host))
|
||||
n (get nodes id)
|
||||
moving (subtree nodes id)
|
||||
;; Its parents in this symbol, outermost first.
|
||||
chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id))))
|
||||
parent (when-let [p (:parent n)]
|
||||
(some-> (symbol/world-of (resolved clip store host frame p) p)
|
||||
js/Float64Array.from))]
|
||||
(cond
|
||||
(= host target) {:refused "it is already there"}
|
||||
(and (= :instance (:kind n))
|
||||
(some #(clip/contains-symbol? clip % target) (node/sources n)))
|
||||
{:refused "a symbol cannot go inside itself"}
|
||||
(some (fn [m] (or (:measured (get nodes m))
|
||||
(some #(or (:dense %) (:generated %)) (vals (:channels (get nodes m))))))
|
||||
moving)
|
||||
{:refused "generated parts stay with their take — move the instance that places it"}
|
||||
(some (fn [[k m]] (and (:stencil m)
|
||||
(not= (contains? moving k) (contains? moving (:stencil m)))))
|
||||
nodes)
|
||||
{:refused "a stencil and what it clips have to move together"}
|
||||
(and (:parent n) (nil? parent))
|
||||
{:refused "its parent is not on screen at this frame"}
|
||||
(some #(get-in % [:time :loop?]) chain)
|
||||
{:refused "a looping parent is in the way"}
|
||||
:else
|
||||
(let [taken (:nodes (clip/symbol clip target))
|
||||
ids (into {} (map (fn [m] [m (clip/free-id #(contains? taken %) m)])) moving)
|
||||
pinv (reduce #(node/mul! (node/mat) %1 %2) carry (keep identity [parent (node/pinv n)]))
|
||||
;; back · parents · own: target frames to the node's own.
|
||||
time (reduce node/then-time back (concat (map node/time-of chain)
|
||||
[(node/time-of n)]))
|
||||
moved (for [m moving
|
||||
:let [x (get nodes m)]]
|
||||
(cond-> (-> x
|
||||
(assoc :id (ids m))
|
||||
(update :parent #(get ids %)))
|
||||
(:stencil x) (update :stencil ids)
|
||||
(= m id) (-> (retime time)
|
||||
(assoc :pinv (vec (array-seq pinv))
|
||||
:z (str "z" (js/Date.now) "-" (ids m))))))]
|
||||
{:id (ids id)
|
||||
:sid target
|
||||
:clip (-> clip
|
||||
(clip/update-symbol host update :nodes #(apply dissoc % moving))
|
||||
(clip/update-symbol target update :nodes (fnil into {})
|
||||
(map (juxt :id identity)) moved))}))))
|
||||
|
||||
(defn move-refusal
|
||||
"Why `move-node` would refuse to move `from` into `to` at frame `f`, or nil.
|
||||
|
||||
SAID BEFORE THE DROP, not after it. A drag that reparents has to tell the
|
||||
person what it would do while they can still change their mind, and the only
|
||||
honest source for that is the check the command itself makes. Hence one
|
||||
function, asked by the gesture on the way past and by `move-node` on the way
|
||||
in.
|
||||
|
||||
The one that surprises: BOTH have to be on screen at this frame, because the
|
||||
move keeps the picture and there is no common frame to keep it at otherwise.
|
||||
Two clips of one lane never overlap, so nesting one into another there can
|
||||
never be done — it is a thing to do between symbols, with the playhead
|
||||
somewhere both of them are showing.
|
||||
|
||||
The deeper refusals — generated parts, a stencil parted from what it clips —
|
||||
belong to the transplant and are only known when it runs."
|
||||
[clip store open from to f]
|
||||
(let [here (inside clip store open (pop from) f)
|
||||
there (inside clip store open to f)
|
||||
n (get-in clip [:symbols (:sid here) :nodes (peek from)])]
|
||||
(cond
|
||||
(nil? n) "nothing to move"
|
||||
(or (nil? here) (nil? there)) "both have to be on screen at this frame"
|
||||
(nil? (:sid there)) "only a symbol can take it"
|
||||
(clip/trace? (clip/symbol clip (:sid there)))
|
||||
"a tracing layer is a picture to draw over — nothing goes inside it"
|
||||
(span/placement-refusal clip (:sid there) n)
|
||||
(span/placement-refusal clip (:sid there) n)
|
||||
(not (and (:time here) (:time there)))
|
||||
"a held or looping clip has no clock to move through"
|
||||
(nil? (some-> there :matrix node/invert)) "the target is scaled to nothing"
|
||||
(= (:sid here) (:sid there)) "it is already there"
|
||||
(and (= :instance (:kind n))
|
||||
(some #(clip/contains-symbol? clip % (:sid there)) (node/sources n)))
|
||||
"a symbol cannot go inside itself")))
|
||||
|
||||
(defn move-node
|
||||
"Move the node at row path `from` — its last id is the node, the rest the
|
||||
instances down to where it lives — into the symbol placed by the instance at
|
||||
row path `to`, or to the top of `open` when `to` is empty. Row paths start at
|
||||
`open`, and `f` is its current frame, at which both have to be on screen.
|
||||
`{:clip :sid :id}` — the symbol it landed in and its id there, renamed only if
|
||||
that one was taken — or `{:refused why}`."
|
||||
[clip store open from to f]
|
||||
(let [here (inside clip store open (pop from) f)
|
||||
there (inside clip store open to f)
|
||||
a (:time here)
|
||||
b (:time there)
|
||||
inv (some-> there :matrix node/invert)]
|
||||
(if-let [why (move-refusal clip store open from to f)]
|
||||
{:refused why}
|
||||
(transplant clip store (:sid here) (:frame here) (peek from) (:sid there)
|
||||
(node/mul! (node/mat) inv (:matrix here))
|
||||
(node/then-time (node/invert-time b) a)))))
|
||||
|
||||
(defn- down
|
||||
"Walk row path `path` down from symbol `sid` by structure alone: `{:sid
|
||||
:time}`, the symbol it leads to and the time map from `sid`'s frames to that
|
||||
symbol's own, nil through a loop.
|
||||
|
||||
`inside` without the frame. Which symbol a row is in and how fast it runs
|
||||
there are the same on every frame, so asking needs nothing to be on screen;
|
||||
only a move that keeps the PICTURE needs a frame, for the matrix."
|
||||
[clip sid path]
|
||||
(let [;; Structurally, a row leads into a symbol only where it names one:
|
||||
;; a clip does, and a group holding one does not, so the walk stops at
|
||||
;; the group rather than picking the drawing showing now — which
|
||||
;; would make where a row lives depend on the playhead.
|
||||
only (fn [sid id]
|
||||
(when sid (node/source (get-in clip [:symbols sid :nodes id]))))
|
||||
sids (reductions only sid path)
|
||||
;; Every node on the way, outermost first: each instance, after its
|
||||
;; parents in the symbol it is in.
|
||||
maps (mapcat (fn [sid id]
|
||||
(let [nodes (:nodes (clip/symbol clip sid))
|
||||
chain (map #(get nodes %) (rseq (symbol/lineage nodes id)))]
|
||||
(mapcat (fn [n]
|
||||
(if (get-in n [:time :loop?]) [nil]
|
||||
(cond-> [(node/time-of n)]
|
||||
(= :instance (:kind n)) (conj (clip/source-time clip sid n)))))
|
||||
chain)))
|
||||
sids path)]
|
||||
{:sid (last sids)
|
||||
:time (when (every? some? maps)
|
||||
(reduce node/then-time node/same-time maps))}))
|
||||
|
||||
(defn- dragged
|
||||
"Frame `from` of node `id`'s own space, dragged `df` frames of the open
|
||||
symbol: ONE WHOLE FRAME of that space.
|
||||
|
||||
THE ONE PLACE A RULER GESTURE BECOMES A FRAME NUMBER, and the whole of it.
|
||||
`df` counts the open symbol's own frames, which is what the ruler is drawn in,
|
||||
so for a row of the open symbol itself this is `from + df` and nothing happens
|
||||
here at all. It earns its keep for a row reached THROUGH a retimed instance,
|
||||
where one frame of the ruler is a fraction of the node's own: a frame number is
|
||||
the one thing in a document that cannot fall between two frames —
|
||||
`span/resize-out`, `resize-in` and `roll` refuse a fractional edge outright,
|
||||
and `slide` would have let it into `:time :at` and refused every later edge
|
||||
edit on that clip for ever.
|
||||
|
||||
Rounding, not flooring: a drag is a gesture at a position, and the frame it
|
||||
means is the nearer one in both directions. Nothing else belongs here."
|
||||
[here nodes id from df]
|
||||
(let [chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id))))
|
||||
rate (:rate (reduce node/then-time (:time here) (map node/time-of chain)))]
|
||||
(js/Math.round (+ from (* df rate)))))
|
||||
|
||||
(defn slide
|
||||
"Move the node at row path `path` along its symbol's time by `df` frames of
|
||||
`open`. `{:clip}` or `{:refused why}`.
|
||||
|
||||
ONE WRITE TO `:at`, for every node alike: its span, keys and children are in
|
||||
its own frames and come with it. `df` is carried down into the frames `:at` is
|
||||
in — the symbol's, through each instance on the way, and its parents' there."
|
||||
[clip open path df]
|
||||
(let [here (down clip open (pop path))
|
||||
id (peek path)
|
||||
nodes (:nodes (clip/symbol clip (:sid here)))]
|
||||
(cond
|
||||
(nil? (get nodes id)) {:refused "nothing to move"}
|
||||
(nil? (:time here)) {:refused "a looping instance is in the way"}
|
||||
:else
|
||||
(let [n (get nodes id)
|
||||
;; Measured from where the node STARTS, so what lands on a whole
|
||||
;; frame is the thing you can see moving. A node with no span is
|
||||
;; on screen throughout and only its `:at` moves.
|
||||
from (or (first (node/placed-span n)) (get-in n [:time :at] 0))
|
||||
d (- (dragged here nodes id from df) from)
|
||||
;; MOVE THE MAP THAT IS THERE; write a fresh one only where there is
|
||||
;; none. The test for "there is one" is `:time` itself, and whether
|
||||
;; it is the affine kind is `node/mapped-time?` — which an absent
|
||||
;; `:mode` satisfies, because `node/time-of` has always read it as
|
||||
;; `:map`. Asking for the key instead said no to every clip
|
||||
;; `span/held` makes, whose `:time` is `{:at f :rate 1}` and nothing
|
||||
;; more, and the else branch then REPLACED that map with one built
|
||||
;; from `d` alone: the first drag of a freshly drawn clip threw away
|
||||
;; its `:at` and jumped it to the head of the lane.
|
||||
shift (fn [n]
|
||||
(if (and (:time n) (node/mapped-time? n))
|
||||
(update-in n [:time :at] (fnil + 0) d)
|
||||
(assoc n :time {:mode :map :at d :rate 1})))
|
||||
moved (assoc nodes id (shift (get nodes id)))
|
||||
;; An editorial audio link follows a moved picture. Moving or
|
||||
;; trimming the audio itself remains independent.
|
||||
moved (if (= :audio (:kind (get nodes id)))
|
||||
moved
|
||||
(reduce (fn [ns [audio-id n]]
|
||||
(if (and (= :audio (:kind n)) (= id (:linked-to n)))
|
||||
(assoc ns audio-id (shift n))
|
||||
ns))
|
||||
moved nodes))]
|
||||
;; COMMITTED LIKE EVERY OTHER SPAN EDIT. This validated with
|
||||
;; `symbol/problems` and wrote the nodes in itself, which made it a
|
||||
;; SECOND commit path — and the one `span/finish`'s docstring says is
|
||||
;; the only one: "no command can commit an overlap in lane mode, and
|
||||
;; `symbol/overlaps` turning up anything is a bug in a command rather
|
||||
;; than a state to design around". It was that bug. A body drag could
|
||||
;; leave two clips of a lane on screen over the same frames, and from
|
||||
;; there the lane stops behaving: `symbol/children` has two clips
|
||||
;; claiming one frame, so which drawing a polygon lands in and whether
|
||||
;; a boundary can be rolled depend on which of them `some` reaches
|
||||
;; first. `finish` does the same `problems` check and the overlap one
|
||||
;; too, so this is less code and one fewer invariant to remember.
|
||||
(span/claim clip (:sid here) moved id :grow-symbol (random-uuid))))))
|
||||
|
||||
(defn slide-many
|
||||
"Move a selection simultaneously. Lane collisions refuse the entire edit."
|
||||
[document open paths df]
|
||||
(let [paths (vec (distinct (filter seq paths)))
|
||||
entries (mapv (fn [path]
|
||||
(let [here (down document open (pop path))
|
||||
id (peek path)
|
||||
nodes (get-in document [:symbols (:sid here) :nodes])]
|
||||
{:path path :here here :sid (:sid here) :id id
|
||||
:nodes nodes :node (get nodes id)})) paths)
|
||||
roots (remove
|
||||
(fn [{:keys [path sid id nodes]}]
|
||||
(some (fn [other]
|
||||
(or (and (< (count (:path other)) (count path))
|
||||
(= (:path other) (subvec path 0 (count (:path other)))))
|
||||
(and (= sid (:sid other)) (not= id (:id other))
|
||||
(some #{(:id other)} (rest (symbol/lineage nodes id))))))
|
||||
entries)) entries)]
|
||||
(if (some #(or (nil? (:node %)) (nil? (get-in % [:here :time]))) roots)
|
||||
{:refused "selection includes a node without an editable clock"}
|
||||
(let [changes
|
||||
(reduce (fn [changes {:keys [here sid id nodes node]}]
|
||||
(let [from (or (first (node/placed-span node)) (get-in node [:time :at] 0))
|
||||
d (- (dragged here nodes id from df) from)
|
||||
shift (fn [n] (update-in n [:time :at] (fnil + 0) d))
|
||||
ids (cons id (when (not= :audio (:kind node))
|
||||
(for [[aid n] nodes :when (= id (:linked-to n))] aid)))]
|
||||
(reduce (fn [out nid] (assoc-in out [sid nid] (shift (get nodes nid)))) changes ids)))
|
||||
{} roots)]
|
||||
(reduce (fn [result [sid changed]]
|
||||
(if (:refused result) (reduced result)
|
||||
(span/finish (:clip result) sid
|
||||
(merge (get-in (:clip result) [:symbols sid :nodes]) changed)
|
||||
nil :grow-symbol)))
|
||||
{:clip document} changes)))))
|
||||
|
||||
(defn resize-out
|
||||
"Move the right edge of the node at `path` by `df` frames of `open`.
|
||||
|
||||
One command either way: in a symbol drawn as a lane `span/resize-out` claims
|
||||
the time it grows into, and in a composition it is one write to one span. The
|
||||
mode says which, and nothing here has to ask."
|
||||
[clip open path df ripple?]
|
||||
(let [here (down clip open (pop path))
|
||||
sid (:sid here)
|
||||
id (peek path)
|
||||
nodes (:nodes (clip/symbol clip sid))
|
||||
n (get nodes id)]
|
||||
(cond
|
||||
(nil? n) {:refused "nothing to resize"}
|
||||
(nil? (:time here)) {:refused "a looping instance is in the way"}
|
||||
:else
|
||||
(span/resize-out clip sid id (dragged here nodes id (second (node/placed-span n)) df)
|
||||
{:ripple? ripple? :extent :grow-symbol}))))
|
||||
|
||||
(defn resize-in
|
||||
"Move the left edge of the node at `path` by `df` frames of `open`."
|
||||
[clip open path df]
|
||||
(let [here (down clip open (pop path))
|
||||
sid (:sid here)
|
||||
id (peek path)
|
||||
nodes (:nodes (clip/symbol clip sid))
|
||||
n (get nodes id)]
|
||||
(cond
|
||||
(nil? n) {:refused "nothing to resize"}
|
||||
(nil? (:time here)) {:refused "a looping instance is in the way"}
|
||||
:else
|
||||
(span/resize-in clip sid id (dragged here nodes id (first (node/placed-span n)) df)))))
|
||||
|
||||
(defn roll
|
||||
"Move the shared boundary at `right-path` and the adjacent `left-path`."
|
||||
[clip open left-path right-path df]
|
||||
(let [here (down clip open (pop right-path))
|
||||
sid (:sid here)
|
||||
left-id (peek left-path)
|
||||
right-id (peek right-path)
|
||||
nodes (:nodes (clip/symbol clip sid))
|
||||
right (get nodes right-id)]
|
||||
(cond
|
||||
(or (not= (pop left-path) (pop right-path)) (nil? right))
|
||||
{:refused "a rolling edit needs adjacent clips in one sequence"}
|
||||
(nil? (:time here)) {:refused "a looping instance is in the way"}
|
||||
:else
|
||||
(span/roll clip sid left-id right-id
|
||||
(dragged here nodes right-id (first (node/placed-span right)) df)))))
|
||||
|
||||
(defn restack
|
||||
"Put the node at row path `from` just in front of the one at `to` when
|
||||
`front?`, or just behind it — side by side in one symbol, as the timeline lists
|
||||
them. `{:clip :sid :id}` or `{:refused why}`.
|
||||
|
||||
ONE WRITE TO `:z`, between the two it lands between, so nothing else is
|
||||
renumbered. Among the nodes that share its parent, because that is what `:z`
|
||||
orders; a roto part's parent is the rig, and it restacks within that."
|
||||
[clip open from to front?]
|
||||
(let [{sid :sid} (down clip open (pop to))
|
||||
nodes (:nodes (clip/symbol clip sid))
|
||||
n (get nodes (peek from))
|
||||
t (get nodes (peek to))
|
||||
z #(or (:z %) "")
|
||||
zs (->> nodes
|
||||
(keep (fn [[k m]] (when (and (= (:parent m) (:parent t)) (not= k (peek from)))
|
||||
(z m))))
|
||||
sort)]
|
||||
(cond
|
||||
(not= (pop from) (pop to)) {:refused "only things side by side can be restacked"}
|
||||
(or (nil? n) (nil? t)) {:refused "nothing to restack"}
|
||||
(not= (:parent n) (:parent t)) {:refused "they hang off different parents"}
|
||||
:else
|
||||
{:sid sid
|
||||
:id (peek from)
|
||||
:clip (clip/update-symbol
|
||||
clip sid assoc-in [:nodes (peek from) :z]
|
||||
(if front?
|
||||
(symbol/z-between (z t) (first (filter #(pos? (compare % (z t))) zs)))
|
||||
(symbol/z-between (last (filter #(neg? (compare % (z t))) zs)) (z t))))})))
|
||||
|
||||
(defn group
|
||||
"Put the nodes at row paths `froms`, all side by side in one symbol, into a
|
||||
NEW symbol `sid`, placed where they were by instance `uuid`. `{:clip}` or
|
||||
`{:refused why}`.
|
||||
|
||||
The new symbol starts where the earliest of them starts and ends where the
|
||||
last one ends, so its instance's bar on the timeline covers exactly theirs.
|
||||
Its instance sits at the identity, so nothing moves. It stores no pivot: an
|
||||
instance turns about the middle of what it draws at the moment it is dragged,
|
||||
so this cannot leave one behind when the group's contents are edited later."
|
||||
[clip store open froms sid uuid f]
|
||||
(let [host-path (pop (first froms))
|
||||
{host :sid frame :frame} (inside clip store open host-path f)
|
||||
nodes (:nodes (clip/symbol clip host))]
|
||||
(cond
|
||||
(nil? host) {:refused "they have to be on screen at this frame"}
|
||||
(not-every? #(= host-path (pop %)) froms) {:refused "only things side by side can be grouped"}
|
||||
(some #(nil? (get nodes (peek %))) froms) {:refused "nothing to group"}
|
||||
:else
|
||||
(let [whole [0 (clip/frames clip host)]
|
||||
spans (for [from froms
|
||||
:let [n (get nodes (peek from))]]
|
||||
(or (node/placed-span n)
|
||||
(when (= :instance (:kind n))
|
||||
(node/placed-span
|
||||
(assoc n :span [0 (or (clip/frames clip (node/source n)) 0)])))
|
||||
whole))
|
||||
start (js/Math.floor (max 0 (apply min (map first spans))))
|
||||
end (min (second whole) (apply max (map second spans)))
|
||||
made (-> clip
|
||||
(assoc-in [:symbols sid] {:id sid :name (name sid) :fps (clip/fps clip host)
|
||||
:frames (max 1 (js/Math.ceil (- end start)))
|
||||
:nodes {}})
|
||||
(clip/place-symbol store host sid start uuid nil))
|
||||
back (node/invert-time (node/time-of (get-in made [:symbols host :nodes uuid])))
|
||||
moved (reduce (fn [acc from]
|
||||
(let [r (transplant (:clip acc) store host frame (peek from) sid
|
||||
(node/mat) back)]
|
||||
(if (:refused r) (reduced r) r)))
|
||||
{:clip made} froms)]
|
||||
moved))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; pegs
|
||||
|
||||
(defn peg
|
||||
"Put a PEG over the node at row path `path`: a free `:group` between it and
|
||||
whatever it hangs off now, sitting on the pivot the node has at frame `f`.
|
||||
`{:clip :sid :id}` or `{:refused why}`.
|
||||
|
||||
WHAT A PEG IS FOR, NOW THAT A NODE HAS ITS OWN PIVOT. `[:xform :pivot]` is how
|
||||
ONE node turns about a point of its own — that is `node/local!`, it needs no
|
||||
parent, and it is right between two keys. A peg is for the three things that
|
||||
are about MORE THAN ONE NODE, or about a node whose channels are not yours to
|
||||
write:
|
||||
|
||||
A PIVOT SHARED BETWEEN NODES. An arm and a forearm turning about one shoulder
|
||||
is one transform driving two drawings, and two pivots that have to agree
|
||||
frame for frame are not that. The peg is the shoulder, and they hang off it.
|
||||
|
||||
A SECOND TRANSFORM ON ONE NODE. A drawing turning about its own middle while
|
||||
the whole limb swings about the shoulder is two rotations, and a node has one
|
||||
`rot`. Toon Boom stacks pegs for exactly this.
|
||||
|
||||
A HAND TRANSFORM OVER A MEASURED ONE. `gesture/refusal` turns a drag on a
|
||||
measured node away because the next regenerate would discard it. A peg's
|
||||
channels are its own, so the hand transform composes OUTSIDE the measured one
|
||||
and the measurement stays regenerable. Resolve publishes a track and parents a
|
||||
transform to it; Harmony puts a peg over the drawing. Same shape.
|
||||
|
||||
The node's own pivot is NOT one of them, and that is the correction: a peg used
|
||||
to be the only pivot there was, so making one was the answer to \"this turns
|
||||
about the wrong point\" — which is a thing to drag a cross for, not a node to
|
||||
create. See `xform-paths`.
|
||||
|
||||
NOTHING MOVES, and that is `:pinv`'s whole job — Blender's parent-inverse, the
|
||||
same field `transplant` writes for the same reason. The peg takes the node's
|
||||
place in the hierarchy, inheriting its `:parent` and its `:pinv`; the node hangs
|
||||
off the peg with `T(-c)` as its own, so
|
||||
|
||||
... · pinv · T(c) · T(-c) · local = ... · pinv · local
|
||||
|
||||
to the bit. The node's channels are untouched, which is what lets this work on a
|
||||
measured node at all — and what lets the node keep its own pivot, which is in
|
||||
its own coordinates and therefore says nothing about who its parent is.
|
||||
|
||||
THE PEG TAKES THE NODE'S `:z`, so draw order is unchanged: a parent's z path is
|
||||
a prefix of its child's, so the node now sorts at `[… z z]` where it sorted at
|
||||
`[… z]`, and against any sibling the comparison is decided at the same place it
|
||||
was before. It takes no `:time` and no `:span`: an identity time map leaves the
|
||||
node's own frames exactly as they were, and a peg with no span is simply always
|
||||
there, so what is on screen when does not change either."
|
||||
[clip store open path f uuid]
|
||||
(let [{:keys [sid id frame]} (placement clip store open path f)
|
||||
n (when sid (get-in clip [:symbols sid :nodes id]))]
|
||||
(cond
|
||||
(nil? n) {:refused "it is not on screen at this frame"}
|
||||
(= :audio (:kind n)) {:refused "a sound has no transform to pivot"}
|
||||
(contains? (:nodes (clip/symbol clip sid)) uuid) {:refused "that id is taken"}
|
||||
:else
|
||||
(let [c (gesture/pivot (gesture/values n frame store)
|
||||
((pick/bounds-of clip store sid n) frame))]
|
||||
{:sid sid
|
||||
:id uuid
|
||||
:clip (clip/update-symbol
|
||||
clip sid update :nodes
|
||||
(fn [nodes]
|
||||
(-> nodes
|
||||
(assoc uuid (cond-> {:id uuid :kind :group
|
||||
:name (str (clip/node-label clip id n) " peg")
|
||||
:parent (:parent n)
|
||||
:z (:z n)
|
||||
:channels {[:xform :pos] (ch/framed c)}}
|
||||
(:pinv n) (assoc :pinv (:pinv n))))
|
||||
(update id assoc
|
||||
:parent uuid
|
||||
:pinv (vec (array-seq
|
||||
(node/local! (node/mat) (mapv - c) [0 0] 0 [1 1] [0 0])))))))}))))
|
||||
|
||||
|
|
@ -21,18 +21,41 @@
|
|||
"`: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.
|
||||
|
||||
:skew and :anchor are in here although nothing drives either yet. A
|
||||
decomposition is not extensible after the fact: adding a component later means
|
||||
migrating every stored transform, so both are in the shape and in the
|
||||
composition order from the start."
|
||||
[[:xform :pos] [:xform :rot] [:xform :scale] [:xform :skew] [:xform :anchor]])
|
||||
:skew is in here although nothing drives it yet. A decomposition is not
|
||||
extensible after the fact: adding a component later means migrating every
|
||||
stored transform, so it is in the shape and in the composition order from the
|
||||
start.
|
||||
|
||||
:pivot IS THE POINT ROTATION AND SCALE HAPPEN ABOUT, in the node's own
|
||||
coordinates, and it is in the decomposition because THERE IS NOWHERE ELSE IT
|
||||
CAN BE. Toon Boom gives every layer and peg a pivot; Flash gives every instance
|
||||
a transformation point; After Effects calls it the anchor point. All three store
|
||||
it, and all three are right to, for one reason: a turn has to be a turn ON EVERY
|
||||
FRAME, and the only way to keep a point still through an interpolated angle is
|
||||
for the angle to be composed about that point. Solving for the `pos` that holds
|
||||
a point still — `gesture/about` — is exact on the frame it is solved and wrong
|
||||
between two keys, because the solution is an ARC in the angle and `pos` tweens
|
||||
along the CHORD. See docs/animation-model.md, which has the measurements off the
|
||||
document where this was found.
|
||||
|
||||
So `local!` is `T(pos)·T(piv)·R·K·S·T(-piv)`, the transform conjugated by a
|
||||
translation — \"do M in a frame shifted by piv\" — and the node's pivot point
|
||||
sits at `pos + piv` in its parent. A pivot of `[0 0]`, which is the default,
|
||||
leaves that exactly `T(pos)·R·K·S`: a drawing needs no pivot, because
|
||||
`paint/centred` already put its origin on the middle of what it draws.
|
||||
|
||||
A PEG IS STILL A PEG. `nest/peg` is how a pivot gets SHARED between nodes, or
|
||||
put above a measured transform that a regenerate would overwrite; this is how
|
||||
one node turns about its own middle. The two were conflated — the peg was made
|
||||
to stand in for the pivot — and that is what cost a keyed turn its pivot."
|
||||
[[:xform :pos] [:xform :pivot] [:xform :rot] [:xform :scale] [:xform :skew]])
|
||||
|
||||
(def valid-paths
|
||||
"The set of valid channel paths follows from the node's :kind, and that is a
|
||||
|
|
@ -45,8 +68,13 @@
|
|||
change to this spec silently change what gets drawn."
|
||||
(let [base (into #{[:vis]} xform-paths)]
|
||||
{:group base
|
||||
:symbol base
|
||||
:audio (into base [[:audio :gain] [:audio :pan] [:audio :rate]])
|
||||
;; A palette-track placement is still an instance. Its palette choice is
|
||||
;; a parameter channel on that instance, so the generic span commands can
|
||||
;; move and trim it without knowing what kind of lane owns it.
|
||||
:instance (conj base [:palette])
|
||||
;; A sound is not in the picture: no transform, no visibility. After
|
||||
;; Effects' audio-only layer has no Transform group for the same reason.
|
||||
:audio #{[: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
|
||||
;; performance — but it is a channel like any other so it can be keyed.
|
||||
|
|
@ -57,29 +85,141 @@
|
|||
|
||||
(def defaults
|
||||
"The identity transform, as channels. A node's channel map is merged over this,
|
||||
so a hand-written scene says only what it means to say."
|
||||
so a hand-written scene says only what it means to say.
|
||||
|
||||
THE DEFAULT PIVOT IS THE NODE'S OWN ORIGIN, which is a real default and not a
|
||||
missing value: a drawing's origin IS the middle of what it draws — `paint/centred`
|
||||
put it there — so a shape turns about its own middle with no pivot stored, and
|
||||
`local!` collapses to `T(pos)·R·K·S` for it. What needs a pivot of its own is a
|
||||
node whose content is nowhere near its origin: a symbol instance, whose origin
|
||||
is the symbol's, and a measured part, whose origin is the corner of the footage.
|
||||
Both get one where they are made — `clip/place-symbol` — and `gesture/turn`
|
||||
gives one to anything that turns without having been given one."
|
||||
{[:xform :pos] (ch/framed [0.0 0.0])
|
||||
[:xform :pivot] (ch/framed [0.0 0.0])
|
||||
[:xform :rot] (ch/framed 0.0)
|
||||
[:xform :scale] (ch/framed [1.0 1.0])
|
||||
[:xform :skew] (ch/framed [0.0 0.0])
|
||||
[:xform :anchor] (ch/framed [0.0 0.0])
|
||||
[:vis] (ch/framed true)})
|
||||
|
||||
(def audio-defaults
|
||||
"Unity gain, centred, at its own speed."
|
||||
{[:audio :gain] (ch/framed 1.0)
|
||||
[:audio :pan] (ch/framed 0.0)
|
||||
[:audio :rate] (ch/framed 1.0)})
|
||||
|
||||
(defn defaults-of [n]
|
||||
(if (= :audio (:kind n)) audio-defaults defaults))
|
||||
|
||||
(defn channels
|
||||
"The node's channels with the transform defaults filled in."
|
||||
"The node's channels with its kind's defaults filled in."
|
||||
[n]
|
||||
(merge defaults (:channels n)))
|
||||
(merge (defaults-of n) (:channels n)))
|
||||
|
||||
(defn measured?
|
||||
"Is this node's transform regenerated from the footage rather than authored?
|
||||
|
||||
What it guards is that a hand edit to a measured transform is thrown away by
|
||||
the next regenerate, which is what `gesture/refusal` refuses. The way to
|
||||
transform one of these by hand is a PEG above it: the hand transform is then on
|
||||
a node of its own and the measured channels underneath are left to be
|
||||
regenerated. Nothing has to be written onto the measured node at all.
|
||||
|
||||
ITS PIVOT IS STILL ITS OWN, and that is the one part of its transform a hand
|
||||
may write: `[:xform :pivot]` is authored on every node alike, never dense and
|
||||
never regenerated, so a measured mouth can be told to turn about its own middle
|
||||
without a peg and without anything a regenerate would discard. What the peg is
|
||||
still for is the TRANSFORM over a measured one, which is this function's
|
||||
business; where the pivot goes is not."
|
||||
[n]
|
||||
(boolean (some #(let [c (get-in n [:channels [:xform %]])]
|
||||
(or (:dense c) (:generated c)))
|
||||
[:pos :rot :scale])))
|
||||
|
||||
(defn hold-only?
|
||||
"Channels whose values are choices, not quantities. Their keys may change at
|
||||
a frame boundary but there is no meaningful value between two keys."
|
||||
[path value]
|
||||
(or (= path [:style :color])
|
||||
(boolean? value)
|
||||
(and (keyword? value) (not= path [:palette]))))
|
||||
|
||||
(defn- enforce-hold [path c]
|
||||
(if (and (= path [:style :color]) (:keys c))
|
||||
(-> c (assoc :interp :hold) (dissoc :segments))
|
||||
c))
|
||||
|
||||
(defn set-channel
|
||||
"Write `v` into channel `path`: a key on the node's own frame `f` when the
|
||||
channel is keyed, its one value when it is not."
|
||||
[n path f v]
|
||||
(let [c (get (channels n) path)]
|
||||
(assoc-in n [:channels path]
|
||||
(enforce-hold
|
||||
path
|
||||
(if (:keys c)
|
||||
(assoc-in c [:keys f] v)
|
||||
(merge (select-keys c [:semantic]) (ch/framed v)))))))
|
||||
|
||||
(defn set-keyed-channel
|
||||
"Write `v` as a key at `f`, starting an animated channel when needed. This is
|
||||
the auto-key counterpart to `set-channel`; an existing channel keeps its
|
||||
interpolation and segment choices."
|
||||
[n path f v]
|
||||
(let [c (get (channels n) path)]
|
||||
(assoc-in n [:channels path]
|
||||
(enforce-hold
|
||||
path
|
||||
(if (:keys c)
|
||||
(assoc-in c [:keys f] v)
|
||||
(merge (select-keys c [:semantic])
|
||||
(ch/keyed {f v} (if (hold-only? path v) :hold :linear))))))))
|
||||
|
||||
(defn toggle-key
|
||||
"Key channel `path` on the node's own frame `f` with the value it has there, or
|
||||
take the key there off. The first key starts the channel animating and taking
|
||||
the last one off leaves it that one value. A boolean holds; anything else tweens.
|
||||
|
||||
`store` because the value it keys is read out of the channel, and a measured
|
||||
channel's values live in tier 2. Colour is always held even though current
|
||||
documents store palette choices as numeric slot indices."
|
||||
[n path f store]
|
||||
(let [c (get (channels n) path)
|
||||
v (ch/value-at c f store)
|
||||
ks (dissoc (:keys c) f)]
|
||||
(assoc-in n [:channels path]
|
||||
(enforce-hold
|
||||
path
|
||||
(cond
|
||||
(not (:keys c)) (merge (select-keys c [:semantic])
|
||||
(ch/keyed {f v} (if (hold-only? path v) :hold :linear)))
|
||||
(not (contains? (:keys c) f)) (assoc-in c [:keys f] v)
|
||||
(seq ks) (cond-> (assoc c :keys ks)
|
||||
(:segments c) (update :segments dissoc f))
|
||||
:else (ch/framed v))))))
|
||||
|
||||
(defn set-segment-interp
|
||||
"Choose how channel `path`'s key at `left` leads to the next one: `:hold` cuts
|
||||
there, `:linear` tweens. The same for a drawing's points as for a transform —
|
||||
only a gap that exists, between a key and a later one, can be chosen."
|
||||
[n path left interp]
|
||||
(let [ks (:keys (get (channels n) path))]
|
||||
(if (and (contains? ks left) (some #(< left %) (keys ks))
|
||||
(#{:hold :linear} interp)
|
||||
(or (= :hold interp) (not (hold-only? path nil))))
|
||||
(assoc-in n [:channels path :segments left] interp)
|
||||
n)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; time maps
|
||||
;;
|
||||
;; Exposure, mouth lead and a symbol instance's timing are ONE mechanism, and
|
||||
;; Cel, mouth lead and a symbol instance's timing are ONE mechanism, and
|
||||
;; seeing that is what keeps them from being three implementations that disagree
|
||||
;; at the edges.
|
||||
|
||||
(defn expose
|
||||
"Hold a frame back onto an exposure grid: 1 = on 1s, 2 = on 2s. Frame 5 at
|
||||
exposure 2 reads the pose from frame 4.
|
||||
cel 2 reads the pose from frame 4.
|
||||
|
||||
FLOOR, NEVER ROUND. Rounding would let an output frame read a pose from the
|
||||
FUTURE, which is a lead — a separate control, applied after this one, for a
|
||||
|
|
@ -87,53 +227,138 @@
|
|||
[f n]
|
||||
(if (and n (> n 1)) (* (js/Math.floor (/ f n)) n) f))
|
||||
|
||||
(defn sample-frame
|
||||
"Pick a source frame for a lower picture rate without changing clip time.
|
||||
(def same-time
|
||||
"The identity time map: these frames ARE those frames. What a walk starts from
|
||||
before it has composed anything, and what `time-of` gives a node with no time
|
||||
of its own."
|
||||
{:at 0 :rate 1})
|
||||
|
||||
The input is already an integer source frame from the audio clock. Its time is
|
||||
f/source-fps. Quantise that time to the picture grid, then read the latest
|
||||
source frame at or before it. The result is always an integer and never from
|
||||
the future, including when the rates do not divide (30 source → 24 picture)."
|
||||
[f source-fps picture-fps]
|
||||
(if (and source-fps picture-fps
|
||||
(pos? source-fps) (pos? picture-fps)
|
||||
(< picture-fps source-fps))
|
||||
(min f (js/Math.floor
|
||||
(* (js/Math.floor (/ (* f picture-fps) source-fps))
|
||||
(/ source-fps picture-fps))))
|
||||
(defn time-of
|
||||
"A node's own time as the affine map it is: `{:at a :rate r}`, meaning a frame
|
||||
`p` of its parent is frame `r·(p − a)` of its own. THE SAME FOR EVERY NODE. A
|
||||
node with no time map is `{:at 0 :rate 1}`, reading its parent's frames as its
|
||||
own; a mouth lead's `:offset` is folded into `:at`. Exposure is a floor, not
|
||||
part of the map, and is left out: this is the map a move preserves and a
|
||||
timeline row draws with, and `local-frame` is what reads a frame, the floor and
|
||||
the lead in their load-bearing order.
|
||||
|
||||
`:rate` is a RETIME SOMEBODY CHOSE and nothing else — half speed on an insert.
|
||||
Reconciling two frame rates is not a retime and does not belong here: it is a
|
||||
selection, and it lives in `domain/cadence`."
|
||||
[n]
|
||||
(let [{:keys [mode at rate offset] :or {mode :map at 0 rate 1 offset 0}} (:time n)]
|
||||
(if (= mode :map)
|
||||
{:at (- at (/ offset rate)) :rate rate}
|
||||
{:at 0 :rate 1})))
|
||||
|
||||
(defn mapped-time?
|
||||
"Whether `n`'s own time is the affine map `time-of` reads, and therefore
|
||||
whether moving it is a write to `:time :at`.
|
||||
|
||||
AN ABSENT `:mode` IS `:map`, which is what `time-of` has always defaulted it
|
||||
to — and the default is the ordinary case, not an edge one: `span/held` writes
|
||||
`{:at f :rate 1}` with no mode at all, so every clip a drawing creates is in
|
||||
it. Code that asked `(= :map (get-in n [:time :mode]))` instead answered no
|
||||
for those, and `nest/slide` acted on the answer by REPLACING the whole map
|
||||
with a fresh one — so the first drag of a freshly drawn clip discarded its
|
||||
`:at` and teleported it to the head of the lane."
|
||||
[n]
|
||||
(= :map (:mode (:time n) :map)))
|
||||
|
||||
(defn then-time
|
||||
"`outer` then `inner`: the map from `outer`'s parent straight to `inner`'s own
|
||||
frames. Time maps compose like matrices do, which is what makes a nesting of
|
||||
any depth one map."
|
||||
[{a1 :at r1 :rate} {a2 :at r2 :rate}]
|
||||
{:at (+ a1 (/ a2 r1)) :rate (* r1 r2)})
|
||||
|
||||
(defn invert-time [{:keys [at rate]}]
|
||||
{:at (- (* at rate)) :rate (/ 1 rate)})
|
||||
|
||||
(defn placed-span
|
||||
"Where a node exists, as `[in out)` in its PARENT's frames, or nil for always.
|
||||
|
||||
A `:span` is in the node's OWN frames — which of its frames exist — for every
|
||||
node alike, and its time map says where they land in the parent. For a node
|
||||
with no time map the two are the same frames, so a shape's span reads as it
|
||||
always did. Moving a node along its parent is then one write to `:at`, and the
|
||||
span, which says what the node IS, does not change when it is moved."
|
||||
[n]
|
||||
(when-let [[in out] (:span n)]
|
||||
(let [{:keys [at rate]} (time-of n)]
|
||||
[(+ at (/ in rate)) (+ at (/ out rate))])))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; what an instance places
|
||||
;;
|
||||
(defn source
|
||||
"The symbol used by this cel. Sequence groups arrange cels;
|
||||
a row is a view of that group, not one row per source."
|
||||
[n]
|
||||
(when (= :instance (:kind n)) (get-in n [:source :symbol])))
|
||||
|
||||
(defn sources
|
||||
"Structural references, including cels outside the playhead."
|
||||
[n]
|
||||
(if-let [sid (source n)] #{sid} #{}))
|
||||
|
||||
(defn playback-of [n]
|
||||
(merge {:in 0 :speed 1 :end :stop} (:playback n)))
|
||||
|
||||
(defn source-time
|
||||
"Invertible cel -> source map, or nil for holds and endpoint policies.
|
||||
Forward sampling remains available through `placed-frame` in every case."
|
||||
[n]
|
||||
(let [{:keys [in speed end]} (playback-of n)]
|
||||
(when (and (pos? speed) (= :stop end) (not (get-in n [:time :loop?])))
|
||||
{:at (- (/ in speed)) :rate speed})))
|
||||
|
||||
(defn placed-frame
|
||||
"Sample source time without changing the cel's property clock:
|
||||
`{:symbol :frame}`, the symbol shown and which of its frames. `length` is that
|
||||
symbol's frame count. Nil means no source contribution — this cel
|
||||
places nothing, or its playback has run past what there is to show."
|
||||
[n f length]
|
||||
(when-let [sid (source n)]
|
||||
(when (and (number? length) (pos? length))
|
||||
(let [{:keys [in speed end]} (playback-of n)
|
||||
raw (+ in (* speed f))
|
||||
frame (case (if (get-in n [:time :loop?]) :loop end)
|
||||
:loop (mod raw length)
|
||||
:hold (max 0 (min (dec length) raw))
|
||||
:stop raw)]
|
||||
(when (and (<= 0 frame) (< frame length))
|
||||
{:symbol sid :frame frame})))))
|
||||
|
||||
(defn finite-number? [v] (and (number? v) (js/Number.isFinite v)))
|
||||
|
||||
(defn hold
|
||||
"Floor `f` onto the last of `holds` at or before it, and before the first onto
|
||||
the first. Exposure on authored frames rather than on a grid: `[0 12 30]` shows
|
||||
frame 12 from 12 until 30. What a tracing layer's held photos and a face's
|
||||
trace keys are.
|
||||
|
||||
A `reduce` that stops at the first hold past `f`, because this runs per node
|
||||
per frame and the list is sorted."
|
||||
[f holds]
|
||||
(if (seq holds)
|
||||
(reduce (fn [held h] (if (<= h f) h (reduced held))) (first holds) holds)
|
||||
f))
|
||||
|
||||
(defn local-frame
|
||||
"Apply a node's time map to the frame it was handed by its parent.
|
||||
"Apply an artistic time map within one frame space: expose, hold, then offset.
|
||||
Frame-rate selection happens at symbol boundaries in domain/clip.
|
||||
|
||||
ORDER IS LOAD-BEARING: expose first, then offset. Flooring onto a grid and
|
||||
shifting against the clock do not commute — shift first and the floor discards
|
||||
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
|
||||
rules fall out and they are different rules: exposure INHERITS STRICTLY,
|
||||
because a head cutting on odd frames against a mouth cutting on even ones reads
|
||||
as two performances; offset is PER-NODE by design, because mouth lead applies
|
||||
to performance nodes and not to the plate, which is the entire point of it."
|
||||
Holds come after exposure and inherit the same way, strictly: they are a floor
|
||||
of this node's own frame, and its children are handed the floored frame."
|
||||
[n f]
|
||||
(let [{:keys [mode offset rate at in source-fps sample-fps]
|
||||
ex :expose :or {mode :inherit}} (:time n)]
|
||||
(let [{:keys [mode at rate offset holds] ex :expose :or {mode :map at 0 rate 1}} (:time n)]
|
||||
(if (= mode :inherit)
|
||||
f
|
||||
(do
|
||||
(when (and (not (#{:symbol :audio} (:kind n))) rate (not= rate 1.0) (not= rate 1))
|
||||
(throw (ex-info "time map :rate belongs to a symbol or audio instance"
|
||||
{:node (:id n) :time (:time n)})))
|
||||
(when (and sample-fps (not (and source-fps (pos? source-fps))))
|
||||
(throw (ex-info "picture sampling needs a positive source fps"
|
||||
{:node (:id n) :time (:time n)})))
|
||||
(cond-> (if (#{:symbol :audio} (:kind n))
|
||||
(+ (or in 0) (* (or rate 1) (- f (or at 0))))
|
||||
f)
|
||||
sample-fps (sample-frame source-fps sample-fps)
|
||||
(cond-> (* rate (- f at))
|
||||
ex (expose ex)
|
||||
offset (+ offset))))))
|
||||
(seq holds) (hold holds)
|
||||
offset (+ offset)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the transform
|
||||
|
|
@ -164,11 +389,17 @@
|
|||
dest))
|
||||
|
||||
(defn local!
|
||||
"dest := T(pos) · T(anchor) · R(rot) · K(skew) · S(scale) · T(-anchor)
|
||||
"dest := T(pos) · T(piv) · R(rot) · K(skew) · S(scale) · T(-piv)
|
||||
|
||||
Written out closed-form rather than as five matrix products, because this runs
|
||||
per node per frame and the five products would each allocate. The derivation,
|
||||
so the constants are checkable rather than trusted:
|
||||
The transform CONJUGATED BY ITS PIVOT, which is to say: do the rotation, skew
|
||||
and scale in a frame shifted to `piv`, so the point `piv` of the node's own
|
||||
coordinates does not move however they change. Toon Boom's layer pivot, Flash's
|
||||
transformation point, After Effects' anchor point; `xform-paths` says why it is
|
||||
in the decomposition rather than solved for per drag.
|
||||
|
||||
Written out closed-form rather than as six matrix products, because this runs
|
||||
per node per frame and each product would allocate. The derivation, so the
|
||||
constants are checkable rather than trusted:
|
||||
|
||||
R·K·S = | c -s | · | 1 kx | · | sx 0 |
|
||||
| s c | | ky 1 | | 0 sy |
|
||||
|
|
@ -179,32 +410,36 @@
|
|||
R·K·S = | sx(c - s·ky) sy(c·kx - s) |
|
||||
| sx(s + c·ky) sy(s·kx + c) |
|
||||
|
||||
and the translation is anchor + pos - M·anchor, which is what makes rotation
|
||||
and scale happen ABOUT the anchor. :anchor is Flash's registration point and
|
||||
Blender's origin, and getting it wrong is why hand-placed parts swing rather
|
||||
than turn.
|
||||
which is the linear part, UNTOUCHED BY THE PIVOT — a conjugation by a
|
||||
translation cannot change it, which is why a pivot is free to move without
|
||||
reshaping anything. All of it lands in the translation:
|
||||
|
||||
T(p)·T(a)·M·T(-a) = T(p + a - M·a) · M
|
||||
|
||||
so with `a = [0 0]` this is exactly `T(pos)·R·K·S` and a node with no pivot
|
||||
composes as it always did, to the bit.
|
||||
|
||||
:skew is stored as shear FACTORS, not angles — kx is x gained per unit y — so
|
||||
that the identity is 0 and a decomposition round-trips without a tangent."
|
||||
[^js dest pos rot scale skew anchor]
|
||||
[^js dest pos piv rot scale skew]
|
||||
(let [c (js/Math.cos rot)
|
||||
s (js/Math.sin rot)
|
||||
sx (ch/component scale 0)
|
||||
sy (ch/component scale 1)
|
||||
kx (ch/component skew 0)
|
||||
ky (ch/component skew 1)
|
||||
ax (ch/component anchor 0)
|
||||
ay (ch/component anchor 1)
|
||||
ax (ch/component piv 0)
|
||||
ay (ch/component piv 1)
|
||||
a (* sx (- c (* s ky)))
|
||||
b (* sx (+ s (* c ky)))
|
||||
cc (* sy (- (* c kx) s))
|
||||
c* (* sy (- (* c kx) s))
|
||||
d (* sy (+ (* s kx) c))]
|
||||
(aset dest 0 a)
|
||||
(aset dest 1 b)
|
||||
(aset dest 2 cc)
|
||||
(aset dest 2 c*)
|
||||
(aset dest 3 d)
|
||||
(aset dest 4 (+ ax (ch/component pos 0) (- (+ (* a ax) (* cc ay)))))
|
||||
(aset dest 5 (+ ay (ch/component pos 1) (- (+ (* b ax) (* d ay)))))
|
||||
(aset dest 4 (+ (ch/component pos 0) ax (- (+ (* a ax) (* c* ay)))))
|
||||
(aset dest 5 (+ (ch/component pos 1) ay (- (+ (* b ax) (* d ay)))))
|
||||
dest))
|
||||
|
||||
(defn pinv
|
||||
|
|
@ -228,6 +463,16 @@
|
|||
pinv-m (mul! dest pinv-m local)
|
||||
:else (doto dest (.set local))))
|
||||
|
||||
(defn invert
|
||||
"The inverse of a 2x3 affine, or nil when it has none — a node scaled to
|
||||
nothing has no inside to draw into."
|
||||
[^js m]
|
||||
(let [[a b c d e f] (array-seq m)
|
||||
det (- (* a d) (* b c))]
|
||||
(when-not (zero? det)
|
||||
(js/Float64Array. #js [(/ d det) (/ (- b) det) (/ (- c) det) (/ a det)
|
||||
(/ (- (* c f) (* d e)) det) (/ (- (* b e) (* a f)) det)]))))
|
||||
|
||||
(defn apply-pt!
|
||||
"out[2i], out[2i+1] := m · (x, y)."
|
||||
[^js out i ^js m x y]
|
||||
|
|
@ -265,19 +510,49 @@
|
|||
(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 :audio) (nil? (get-in n [:source :footage])))
|
||||
(conj "an audio instance needs :source :footage")
|
||||
(and (#{:symbol :audio} k) (some? (get-in n [:time :rate]))
|
||||
(not (pos? (get-in n [:time :rate]))))
|
||||
(conj "an instance's :rate must be positive")
|
||||
(and (= k :instance) (not (keyword? (source n))))
|
||||
(conj "an instance needs :source {:symbol <symbol-id>}")
|
||||
(and (= k :instance)
|
||||
(let [{:keys [in speed end]} (playback-of n)]
|
||||
(not (and (finite-number? in) (<= 0 in)
|
||||
(finite-number? speed) (<= 0 speed)
|
||||
(#{:stop :hold :loop} end)))))
|
||||
(conj "playback needs a nonnegative finite :in and :speed, and :end :stop, :hold or :loop")
|
||||
(:layout n)
|
||||
(conj ":layout is not a node field — a lane is how the timeline DRAWS a symbol, not a thing in the document")
|
||||
(and (= k :audio) (not (some (:source n) [:footage :sound])))
|
||||
(conj "an audio node needs a :source :footage or :sound")
|
||||
(and (some? (get-in n [:time :rate]))
|
||||
(not (and (finite-number? (get-in n [:time :rate]))
|
||||
(pos? (get-in n [:time :rate])))))
|
||||
(conj ":time :rate must be positive")
|
||||
(contains? (:channels n) [:xform :anchor])
|
||||
(conj (str ":anchor is now [:xform :pivot], and it means the same point "
|
||||
"in the same coordinates — but the composition around it "
|
||||
"changed from T(pos)·M·T(-a) to T(pos)·T(a)·M·T(-a), so a "
|
||||
"turned or scaled node reading one as the other would move"))
|
||||
(nil? (:z n)) (conj "no :z — draw order is authored per scene, not implied by the tree")
|
||||
(and (:span n) (not= 2 (count (:span n))))
|
||||
(conj ":span must be [in out]"))
|
||||
(and (:span n) (not (and (vector? (:span n)) (= 2 (count (:span n)))
|
||||
(every? finite-number? (:span n))
|
||||
(apply < (:span n)))))
|
||||
(conj ":span must be a finite, increasing [in out]")
|
||||
(some? (get-in n [:time :in]))
|
||||
(conj ":time has an :in — an instance's first frame is the start of its own :span")
|
||||
(let [hs (get-in n [:time :holds])]
|
||||
(and (some? hs) (not (and (vector? hs) (every? finite-number? hs)
|
||||
(or (empty? hs) (apply < hs))))))
|
||||
(conj ":time :holds must be a vector of increasing frames"))
|
||||
|
||||
;; `[:xform :anchor]` is excluded because it has a NAMED refusal above.
|
||||
;; It is the one invalid path a stored document is likely to carry — every
|
||||
;; schema-6 drawing and placement had one, and schema 7 refused them all
|
||||
;; rather than convert — so "not valid on a :poly node" would be the first
|
||||
;; thing a person saw, and it says nothing about what to do. The precedent
|
||||
;; is `symbol/problems`' refusal of `:trace`.
|
||||
(into (when valid
|
||||
(for [[path _] (:channels n)
|
||||
:when (not (contains? valid path))]
|
||||
:when (and (not (contains? valid path))
|
||||
(not= path [:xform :anchor]))]
|
||||
(str "channel " (pr-str path) " is not valid on a " k " node"))))
|
||||
|
||||
(into (for [[path c] (:channels n)
|
||||
|
|
|
|||
20
frontend/src/arthur/domain/onion.cljs
Normal file
20
frontend/src/arthur/domain/onion.cljs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
(ns arthur.domain.onion
|
||||
"Editor-only ghosts of the picture a few frames either side of the playhead.
|
||||
|
||||
THE STAGE AT ANOTHER FRAME, nothing more. The resolver already draws any
|
||||
frame, so a ghost is what it draws `n` frames back or ahead — whatever is
|
||||
animating, by spans, keys or anything else, shows up without being looked
|
||||
for.")
|
||||
|
||||
(def defaults {:on? false :before 1 :after 1 :opacity 0.25})
|
||||
|
||||
(defn frames
|
||||
"The output frames to ghost around frame `f` of a `length`-frame transport:
|
||||
`before` back and `after` ahead, each tagged with its direction, nearest
|
||||
first, and none off either end."
|
||||
[f length {:keys [before after]}]
|
||||
(concat
|
||||
(for [i (range 1 (inc before)) :let [g (- f i)] :when (<= 0 g)]
|
||||
{:frame g :direction :before})
|
||||
(for [i (range 1 (inc after)) :let [g (+ f i)] :when (< g length)]
|
||||
{:frame g :direction :after})))
|
||||
303
frontend/src/arthur/domain/outline.cljs
Normal file
303
frontend/src/arthur/domain/outline.cljs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
(ns arthur.domain.outline
|
||||
"A brush stroke, as pixels and then as polygons.
|
||||
|
||||
Flash's brush: what you paint becomes filled shapes the moment you let go, and
|
||||
the stroke is not kept. Here the stroke is first a MASK — a byte per stage
|
||||
pixel, stamped with the brush's disc as the pointer moves, so what is on screen
|
||||
while painting is exactly the pixels — and on letting go each piece of it is
|
||||
traced round its pixel edges and simplified to as many points as are asked for,
|
||||
as the mouth's ring is.
|
||||
|
||||
The trace walks pixel CORNERS, so before simplifying, a traced ring filled by
|
||||
`raster/fill-poly-buf!` is the mask again exactly.
|
||||
|
||||
A HOLE STAYS A HOLE, and a shape is still one ring. A loop painted round a gap
|
||||
is a piece with a hole in it; the hole is traced as well, and joined to the
|
||||
outside by a HORIZONTAL bridge along a whole-pixel row — out and back along
|
||||
the same line. The fill samples at pixel centres (y + 0.5) and an edge with no
|
||||
height crosses no scanline, so the bridge draws nothing, the even-odd fill
|
||||
leaves the hole empty, and nothing downstream has to know a ring can have
|
||||
one. It is how earcut and the TrueType rasterisers take holes too.
|
||||
|
||||
`pieces` keeps the trace — outside and holes, at full resolution — so the
|
||||
fit can change after the stroke, and `polygon` is the one place it is fitted
|
||||
and bridged: the preview and the saved shape are the same function's output,
|
||||
so what is on screen while painting is what is kept.
|
||||
|
||||
FITTED TO A TOLERANCE, not cut to a count: every point of the traced edge is
|
||||
within so many pixels of the polygon (Douglas-Peucker), so points go where
|
||||
the shape bends and none are spent on a straight run."
|
||||
(:require ["polygon-clipping" :as clipping]
|
||||
[arthur.domain.raster :as raster]))
|
||||
|
||||
(defn mask [w h] {:w w :h h :buf (js/Uint8Array. (* w h))})
|
||||
|
||||
(defn stamp!
|
||||
"The brush, `size` pixels across, from `[ax ay]` to `[bx by]`: a disc every
|
||||
half radius along the way, so a fast stroke is still one stroke."
|
||||
[m [ax ay] [bx by] size]
|
||||
(let [[ox oy] (or (:origin m) [0 0])
|
||||
ax (- ax ox) ay (- ay oy) bx (- bx ox) by (- by oy)
|
||||
r (max 0.5 (/ size 2))
|
||||
steps (max 1 (js/Math.ceil (/ (js/Math.hypot (- bx ax) (- by ay)) (max 0.5 (/ r 2)))))]
|
||||
(dotimes [i (inc steps)]
|
||||
(let [t (/ i steps)]
|
||||
(raster/fill-disc! m (+ ax (* t (- bx ax))) (+ ay (* t (- by ay))) r 1))))
|
||||
m)
|
||||
|
||||
(defn- flood!
|
||||
"Label the 4-connected region of pixels whose ink is `ink` from pixel `start`
|
||||
with `id` in `lab`, inside the box `[x0 y0 x1 y1]`. Its size, and whether it
|
||||
reaches the box's edge — a gap that does is outside, not a hole.
|
||||
|
||||
A typed stack and no allocation per pixel: this runs on every pointer move."
|
||||
[^js lab ^js buf w [x0 y0 x1 y1] ink start id ^js stack]
|
||||
(aset lab start id)
|
||||
(aset stack 0 start)
|
||||
(let [top (volatile! 1) size (volatile! 0) edge? (volatile! false)
|
||||
visit! (fn [q] (when (and (zero? (aget lab q)) (== ink (aget buf q)))
|
||||
(aset lab q id)
|
||||
(aset stack @top q)
|
||||
(vswap! top inc)))]
|
||||
(while (pos? @top)
|
||||
(let [p (aget stack (vswap! top dec))
|
||||
x (mod p w) y (quot p w)]
|
||||
(vswap! size inc)
|
||||
(when (or (== x x0) (== x x1) (== y y0) (== y y1)) (vreset! edge? true))
|
||||
(when (< x0 x) (visit! (dec p)))
|
||||
(when (< x x1) (visit! (inc p)))
|
||||
(when (< y0 y) (visit! (- p w)))
|
||||
(when (< y y1) (visit! (+ p w)))))
|
||||
{:size @size :edge? @edge?}))
|
||||
|
||||
(def ^:private dirs [[1 0] [0 1] [-1 0] [0 -1]])
|
||||
|
||||
(defn- ring
|
||||
"The edge of the region `in?` that starts at pixel `start`, the first of it in
|
||||
raster order, as flat corner points: walked with the region on the right and a
|
||||
point only where the walk turns. Round a piece, that is its outside; round a
|
||||
hole, the inside edge of the piece around it."
|
||||
[w in? start]
|
||||
(let [x0 (mod start w) y0 (quot start w)]
|
||||
;; Heading east along the start pixel's top edge, the region is below: on the
|
||||
;; right. At each corner the two pixels ahead decide: the one ahead on the
|
||||
;; right empty turns right, the one ahead on the left full turns left, and
|
||||
;; otherwise straight on. Turning right first keeps two regions that touch
|
||||
;; only at a corner apart, as the 4-connected fill did.
|
||||
;; The start corner is always a turn — the walk arrives at it heading
|
||||
;; north up the start pixel's left edge — and is passed only once, because
|
||||
;; the three pixels round it other than the start are all outside.
|
||||
(loop [x x0 y y0 d 0 out [x0 y0]]
|
||||
(let [[l r] (case d
|
||||
0 [[x (dec y)] [x y]]
|
||||
1 [[x y] [(dec x) y]]
|
||||
2 [[(dec x) y] [(dec x) (dec y)]]
|
||||
3 [[(dec x) (dec y)] [x (dec y)]])
|
||||
nd (cond (not (apply in? r)) (mod (inc d) 4)
|
||||
(apply in? l) (mod (+ d 3) 4)
|
||||
:else d)
|
||||
out (if (or (= nd d) (and (= x x0) (= y y0))) out (conj out x y))
|
||||
[dx dy] (dirs nd)
|
||||
nx (+ x dx) ny (+ y dy)]
|
||||
(if (and (= nx x0) (= ny y0))
|
||||
out
|
||||
(recur nx ny nd out))))))
|
||||
|
||||
(defn- bounds
|
||||
"`[x0 y0 x1 y1]` round what the mask holds, a pixel wider each way so a gap
|
||||
open to the outside reaches the edge of it — or nil for an empty mask."
|
||||
[{:keys [w h ^js buf]}]
|
||||
(let [b #js [w h -1 -1]]
|
||||
(dotimes [o (* w h)]
|
||||
(when (== 1 (aget buf o))
|
||||
(let [x (mod o w) y (quot o w)]
|
||||
(aset b 0 (min (aget b 0) x)) (aset b 1 (min (aget b 1) y))
|
||||
(aset b 2 (max (aget b 2) x)) (aset b 3 (max (aget b 3) y)))))
|
||||
(when (<= 0 (aget b 2))
|
||||
[(max 0 (dec (aget b 0))) (max 0 (dec (aget b 1)))
|
||||
(min (dec w) (inc (aget b 2))) (min (dec h) (inc (aget b 3)))])))
|
||||
|
||||
(defn- buffer-pieces
|
||||
"Each piece of the mask bigger than `smallest` pixels, biggest first, as
|
||||
`{:outer ring :holes [ring …]}` at full resolution: a hole is a gap inside a
|
||||
piece that does not reach the outside, of more than `smallest` pixels."
|
||||
([m] (buffer-pieces m 2))
|
||||
([{:keys [w h ^js buf] :as m} smallest]
|
||||
(when-let [[x0 y0 x1 y1 :as box] (bounds m)]
|
||||
(let [lab (js/Int32Array. (* w h))
|
||||
stack (js/Int32Array. (* w h))
|
||||
;; Pieces get positive labels and gaps negative ones, in raster
|
||||
;; order, so each is labelled from its own first pixel.
|
||||
found (let [acc (array)]
|
||||
(doseq [y (range y0 (inc y1))]
|
||||
(dotimes [i (inc (- x1 x0))]
|
||||
(let [o (+ x0 i (* y w))]
|
||||
(when (zero? (aget lab o))
|
||||
(let [ink (aget buf o)
|
||||
id (cond-> (inc (.-length acc)) (zero? ink) -)]
|
||||
(.push acc (assoc (flood! lab buf w box ink o id stack)
|
||||
:id id :start o)))))))
|
||||
(vec acc))
|
||||
in (fn [id] (fn [x y] (and (< -1 x w) (< -1 y h) (== id (aget lab (+ x (* y w)))))))
|
||||
;; A hole's first pixel has a pixel of its piece directly above it:
|
||||
;; anything else above would be the hole itself, and earlier.
|
||||
holes (group-by #(aget lab (- (:start %) w))
|
||||
(filter #(and (neg? (:id %)) (not (:edge? %)) (< smallest (:size %))) found))]
|
||||
(->> found
|
||||
(filter #(and (pos? (:id %)) (< smallest (:size %))))
|
||||
(sort-by (comp - :size))
|
||||
(mapv (fn [{:keys [id start]}]
|
||||
{:outer (ring w (in id) start)
|
||||
:holes (mapv #(ring w (in (:id %)) (:start %)) (get holes id))})))))))
|
||||
|
||||
(defn pieces
|
||||
([m] (pieces m 2))
|
||||
([m smallest]
|
||||
(let [[ox oy] (or (:origin m) [0 0])
|
||||
shift (fn [ring] (mapv (fn [i v] (+ v (if (even? i) ox oy))) (range) ring))]
|
||||
(mapv (fn [p] (-> p (update :outer shift) (update :holes #(mapv shift %))))
|
||||
(buffer-pieces m smallest)))))
|
||||
|
||||
(defn rings
|
||||
"The outside of every piece of the mask, as flat corner points, biggest
|
||||
first."
|
||||
([m] (rings m 2))
|
||||
([m smallest] (mapv :outer (pieces m smallest))))
|
||||
|
||||
(defn- area
|
||||
"The area inside ring `pts`, by the shoelace."
|
||||
[pts]
|
||||
(let [ps (vec (partition 2 pts)) n (count ps)]
|
||||
(js/Math.abs (/ (reduce + (map (fn [i] (let [[ax ay] (ps i) [bx by] (ps (mod (inc i) n))]
|
||||
(- (* ax by) (* bx ay))))
|
||||
(range n)))
|
||||
2))))
|
||||
|
||||
(defn- bridge
|
||||
"Ring `outer` with `hole` joined into it: from the hole's leftmost point,
|
||||
along its row to the nearest edge of `outer` on the left, round the hole, and
|
||||
back. Both are on a whole-pixel row, so the bridge is never filled. See the
|
||||
namespace docstring."
|
||||
[outer hole]
|
||||
(let [hs (vec (partition 2 hole))
|
||||
k (apply min-key (comp first hs) (range (count hs)))
|
||||
[hx hy] (hs k)
|
||||
os (vec (partition 2 outer))
|
||||
n (count os)
|
||||
;; The edge the row meets nearest on the left. A level edge is skipped:
|
||||
;; running along one draws nothing either.
|
||||
[i qx] (->> (range n)
|
||||
(keep (fn [i]
|
||||
(let [[ax ay] (os i) [bx by] (os (mod (inc i) n))]
|
||||
(when (and (not= ay by) (<= (min ay by) hy (max ay by)))
|
||||
(let [x (+ ax (* (/ (- hy ay) (- by ay)) (- bx ax)))]
|
||||
(when (< x hx) [i x]))))))
|
||||
(reduce (fn [best c] (if (or (nil? best) (< (second best) (second c))) c best))
|
||||
nil))]
|
||||
(if i
|
||||
(vec (concat (apply concat (subvec os 0 (inc i)))
|
||||
[qx hy]
|
||||
(apply concat (subvec hs k)) (apply concat (subvec hs 0 k))
|
||||
[hx hy qx hy]
|
||||
(apply concat (subvec os (inc i)))))
|
||||
outer)))
|
||||
|
||||
(defn- dp
|
||||
"Douglas-Peucker over the open run of points `i`..`j` of `xs`/`ys`: the
|
||||
indices kept so that no point between is further than `tol` from the line."
|
||||
[^js xs ^js ys i j tol]
|
||||
(let [ax (aget xs i) ay (aget ys i) bx (aget xs j) by (aget ys j)
|
||||
dx (- bx ax) dy (- by ay) l (js/Math.hypot dx dy)
|
||||
[k d] (reduce (fn [[_ best :as acc] k]
|
||||
(let [px (- (aget xs k) ax) py (- (aget ys k) ay)
|
||||
e (if (zero? l) (js/Math.hypot px py) (/ (js/Math.abs (- (* dx py) (* dy px))) l))]
|
||||
(if (< best e) [k e] acc)))
|
||||
[nil 0] (range (inc i) j))]
|
||||
(if (and k (< tol d))
|
||||
(into (dp xs ys i k tol) (rest (dp xs ys k j tol)))
|
||||
[i j])))
|
||||
|
||||
(defn fit
|
||||
"Ring `pts` with as few points as keep every point of it within `tol` pixels
|
||||
of the result: Douglas-Peucker, closed by splitting at the point furthest from
|
||||
the first. A pixel staircase within a pixel of a diagonal becomes the
|
||||
diagonal, and a square corner stays — the points go where the shape bends."
|
||||
[pts tol]
|
||||
(let [c (quot (count pts) 2)]
|
||||
(if (<= c 3)
|
||||
(vec pts)
|
||||
(let [xs (js/Float64Array. (take-nth 2 pts)) ys (js/Float64Array. (take-nth 2 (rest pts)))
|
||||
far (apply max-key #(js/Math.hypot (- (aget xs %) (aget xs 0)) (- (aget ys %) (aget ys 0)))
|
||||
(range c))
|
||||
xs2 (js/Float64Array. (inc c)) ys2 (js/Float64Array. (inc c))
|
||||
_ (dotimes [k c] (aset xs2 k (aget xs k)) (aset ys2 k (aget ys k)))
|
||||
_ (do (aset xs2 c (aget xs 0)) (aset ys2 c (aget ys 0)))
|
||||
keep (into (dp xs2 ys2 0 far tol) (rest (butlast (dp xs2 ys2 far c tol))))]
|
||||
(if (< (count keep) 3)
|
||||
(vec pts)
|
||||
(into [] (mapcat (fn [k] [(aget xs k) (aget ys k)])) keep))))))
|
||||
|
||||
(defn- crosses?
|
||||
"Does any edge of ring `a` cross any edge of ring `b`?"
|
||||
[a b]
|
||||
(let [edges (fn [r] (let [ps (vec (partition 2 r)) n (count ps)]
|
||||
(map (fn [i] [(ps i) (ps (mod (inc i) n))]) (range n))))
|
||||
side (fn [[ax ay] [bx by] [px py]] (- (* (- bx ax) (- py ay)) (* (- by ay) (- px ax))))]
|
||||
(some (fn [[p q]]
|
||||
(some (fn [[r s]]
|
||||
(and (neg? (* (side p q r) (side p q s)))
|
||||
(neg? (* (side r s p) (side r s q)))))
|
||||
(edges b)))
|
||||
(edges a))))
|
||||
|
||||
(defn- perimeter [pts]
|
||||
(let [ps (vec (partition 2 pts)) n (count ps)]
|
||||
(reduce + (map (fn [i] (let [[ax ay] (ps i) [bx by] (ps (mod (inc i) n))]
|
||||
(js/Math.hypot (- bx ax) (- by ay))))
|
||||
(range n)))))
|
||||
|
||||
(defn ->js
|
||||
"Flat ring `ring` as `polygon-clipping` takes one."
|
||||
[ring]
|
||||
(into-array (map into-array (partition 2 ring))))
|
||||
|
||||
(defn ->ring
|
||||
"A ring back from `polygon-clipping`, flat, without the point it repeats to
|
||||
close."
|
||||
[^js r]
|
||||
(into [] (mapcat identity) (butlast (map vec (array-seq r)))))
|
||||
|
||||
(defn rings-of
|
||||
"Piece `p` of `pieces` fitted to within `tol` pixels, outside first, holes
|
||||
after.
|
||||
|
||||
EVERY HOLE IS KEPT, and kept simple: fitted like the outside, then clipped to
|
||||
the inside of it and clear of the holes before it. Fitting each ring on its
|
||||
own can push a hole's edge across the outside's, and a crossing is what makes
|
||||
the even-odd fill cut through the body; clipping takes exactly that part off
|
||||
and leaves the few points the fit chose. Fitting closer until nothing crossed
|
||||
was tried, and spent hundreds of points on what is plainly a straight line."
|
||||
[{:keys [outer holes]} tol]
|
||||
(let [outer (fit outer tol)]
|
||||
(reduce (fn [rs hole]
|
||||
(let [h (fit hole tol)]
|
||||
(if (not-any? #(crosses? h %) rs)
|
||||
(conj rs h)
|
||||
(let [inside (clipping/intersection #js [(->js h)] #js [(->js outer)])
|
||||
clear (if (next rs)
|
||||
(clipping/difference inside (into-array (map #(array (->js %)) (rest rs))))
|
||||
inside)]
|
||||
(into rs (comp (map #(->ring (aget % 0))) (filter #(< 2 (area %))))
|
||||
(array-seq clear))))))
|
||||
[outer]
|
||||
(sort-by (comp - area) holes))))
|
||||
|
||||
(defn join
|
||||
"Rings `[outer & holes]` as one ring, each hole bridged in, leftmost first."
|
||||
[[outer & holes]]
|
||||
(reduce bridge outer (sort-by #(apply min (take-nth 2 %)) holes)))
|
||||
|
||||
(defn polygon
|
||||
"Piece `p` of `pieces` as the one ring a shape is made of. See `rings-of`."
|
||||
[p tol]
|
||||
(join (rings-of p tol)))
|
||||
|
|
@ -1,12 +1,57 @@
|
|||
(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.
|
||||
|
||||
A SHAPE'S ORIGIN IS THE MIDDLE OF WHAT IT DRAWS. Points arrive here in the
|
||||
space the node's `[:xform :pos]` lives in — that is what `nest/drawn-inside`
|
||||
hands over — and `centred` splits them into a ring about the origin and the
|
||||
`pos` that puts it back where it was drawn. See `centred`: it is the invariant
|
||||
the whole pivot story rests on, and this namespace is where it is established."
|
||||
(:require [arthur.domain.channel :as channel]))
|
||||
|
||||
(def geometry [:geom :pts])
|
||||
|
||||
(defn shapes [clip]
|
||||
(->> (get-in clip [:timelines :main :nodes])
|
||||
(defn middle
|
||||
"The middle of the box round flat points `pts`, in their own space."
|
||||
[pts]
|
||||
(let [xs (take-nth 2 pts)
|
||||
ys (take-nth 2 (rest pts))]
|
||||
[(/ (+ (apply min xs) (apply max xs)) 2)
|
||||
(/ (+ (apply min ys) (apply max ys)) 2)]))
|
||||
|
||||
(defn centred
|
||||
"Flat points `pts`, given in the space a node's `pos` lives in, as
|
||||
`[ring pos]`: the same drawing about the origin, and the position that puts it
|
||||
back exactly where it was.
|
||||
|
||||
THE ORIGIN OF A SHAPE IS THE MIDDLE OF WHAT IT DRAWS, so a shape needs no
|
||||
pivot of its own: `[:xform :pivot]` defaults to the node's origin, and for a
|
||||
drawing that IS the middle of the drawing. A stroke stored exactly as it was
|
||||
drawn would have its origin at the SYMBOL's origin, which on the stage is the
|
||||
top-left corner — 126 px away on a 320x200 stage, an orbit wider than the
|
||||
stage — and that is what every drawing would turn about with a default pivot
|
||||
and no centring here.
|
||||
|
||||
IT IS WORTH DOING ANYWAY, NOW THAT THERE IS A PIVOT, because this is the half
|
||||
nobody has to choose. A pivot is a stored choice and a default has to be a good
|
||||
one: with the origin on the content the default is already right, a turn writes
|
||||
`rot` alone, and nothing is stored on the node for anybody to have to look at.
|
||||
A symbol instance is the case that cannot do this — its origin is its symbol's,
|
||||
and moving a symbol's origin would move every drawing inside it out from under
|
||||
everything that reads them — so it gets a pivot written where it is placed;
|
||||
see `clip/place-symbol`.
|
||||
|
||||
Hand-authored scenes have always been written this way — `demo/scene.edn`'s
|
||||
card is `[-44 -30 44 -30 44 30 -44 30]` with its place in `pos` — so this is
|
||||
the paint tool joining the convention rather than a new one."
|
||||
[pts]
|
||||
(let [[cx cy] (middle pts)]
|
||||
[(into [] (map-indexed (fn [i v] (- v (if (even? i) cx cy)))) pts)
|
||||
[cx cy]]))
|
||||
|
||||
(defn shapes [clip sid]
|
||||
(->> (get-in clip [:symbols sid :nodes])
|
||||
(filter (fn [[_ node]] (:paint? node)))
|
||||
(sort-by (comp :z val))
|
||||
vec))
|
||||
|
|
@ -15,43 +60,116 @@
|
|||
(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` with a shape drawn at `points` — in the space the new node's `pos` will
|
||||
live in, which is the symbol's own coordinates — on frame `frame` of `sid`.
|
||||
|
||||
THE RING IS CENTRED AND THE MIDDLE GOES IN `pos`, which is the whole of
|
||||
`centred`: the shape's origin is what it draws, so it turns and scales about
|
||||
itself with nothing stored and nothing solved. This is the one place every
|
||||
drawing is born — the pen, the brush, and each piece the eraser leaves — so it
|
||||
is the one place the invariant has to be established."
|
||||
[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))))
|
||||
(let [[ring pos] (centred points)]
|
||||
(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)}})
|
||||
:channels {geometry (channel/keyed {frame ring} :hold)
|
||||
[:xform :pos] (channel/framed pos)
|
||||
[: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)]
|
||||
(if (and (:paint? node) (<= start frame) (< frame end) ch)
|
||||
(assoc-in clip (into path [:channels geometry :keys frame])
|
||||
(vec (channel/value-at ch frame)))
|
||||
;; A drawing is authored and keyed, never dense, so there is
|
||||
;; no tier-2 store to read it out of.
|
||||
(vec (channel/value-at ch frame nil)))
|
||||
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])
|
||||
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
|
||||
:segments key-frame] interp)
|
||||
(defn- every-key
|
||||
"`f` over the points of every key of the shape's geometry."
|
||||
[clip sid id f]
|
||||
(let [path [:symbols sid :nodes id :channels geometry :keys]]
|
||||
(if (and (:paint? (get-in clip [:symbols sid :nodes id])) (map? (get-in clip path)))
|
||||
(update-in clip path update-vals f)
|
||||
clip)))
|
||||
|
||||
(defn insert-vertex
|
||||
"A new point after point `i`, a fraction `t` of the way along the edge to the
|
||||
next, on EVERY key: a point's index is what it is across keys, so a tween
|
||||
between two keys only means anything while they have the same points. The
|
||||
shape does not change on any key."
|
||||
[clip sid id i t]
|
||||
(every-key clip sid id
|
||||
(fn [pts]
|
||||
(let [n (quot (count pts) 2)
|
||||
j (mod (inc i) n)
|
||||
at #(+ (nth pts (+ (* 2 i) %))
|
||||
(* t (- (nth pts (+ (* 2 j) %)) (nth pts (+ (* 2 i) %)))))]
|
||||
(if (< i n)
|
||||
(-> (subvec pts 0 (* 2 (inc i)))
|
||||
(conj (at 0) (at 1))
|
||||
(into (subvec pts (* 2 (inc i)))))
|
||||
pts)))))
|
||||
|
||||
(defn delete-vertex
|
||||
"Point `i` gone from every key, as `insert-vertex` adds one. A triangle keeps
|
||||
its three."
|
||||
[clip sid id i]
|
||||
(every-key clip sid id
|
||||
(fn [pts]
|
||||
(if (and (< 6 (count pts)) (< (* 2 i) (count pts)))
|
||||
(into (subvec pts 0 (* 2 i)) (subvec pts (* 2 (inc i))))
|
||||
pts))))
|
||||
|
||||
(defn set-points
|
||||
"Key `key-frame` of the shape is `points`, whatever it held, IN THE NODE'S OWN
|
||||
COORDINATES: a cut writing back the piece it kept.
|
||||
|
||||
The node's origin is left where it is, which is why this is the form a cut
|
||||
uses. Re-centring would have to move `pos` to compensate, and `pos` can be
|
||||
keyed and the drawing can be keyed, so there is no one middle to move it to —
|
||||
the only exact moment for that is while the transform is static, which is what
|
||||
`place-points` is for."
|
||||
[clip sid id key-frame points]
|
||||
(let [path [:symbols sid :nodes id :channels geometry :keys key-frame]]
|
||||
(if (get-in clip path)
|
||||
(assoc-in clip path points)
|
||||
clip)))
|
||||
|
||||
(defn place-points
|
||||
"Key `key-frame` of the shape is `points`, GIVEN IN THE SPACE ITS `pos` LIVES
|
||||
IN: centred as `new-shape` centres them, with `pos` moved to put them back.
|
||||
|
||||
What a re-fit writes. Adjusting a brush stroke's fit re-traces the same stroke
|
||||
from the stage pixels it was painted in, so its points arrive in the same space
|
||||
they did when the shape was made, and writing them as the node's own would move
|
||||
the drawing by its own position. Only ever used on a shape a stroke has just
|
||||
made, which is why moving `pos` is exact here: nothing is keyed yet."
|
||||
[clip sid id key-frame points]
|
||||
(let [path [:symbols sid :nodes id :channels geometry :keys key-frame]]
|
||||
(if (get-in clip path)
|
||||
(let [[ring pos] (centred points)]
|
||||
(-> clip
|
||||
(assoc-in path ring)
|
||||
(assoc-in [:symbols sid :nodes id :channels [:xform :pos]]
|
||||
(channel/framed pos))))
|
||||
clip)))
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
(ns arthur.domain.palette
|
||||
"The indexed palette.
|
||||
"Project palette assets and their render-time index banks.
|
||||
|
||||
THE RULE, and it is a rule rather than a default: a part carries a palette
|
||||
INDEX, never a sampled RGB value. Sampling colour off the footage produces a
|
||||
pixel-art filter, and it does so irrecoverably — once a shape holds a measured
|
||||
colour there is no way back to an authored one, because the information that it
|
||||
was ever a choice is gone. Every `[:style :color]` channel holds one of the
|
||||
keywords below.
|
||||
Drawing data stores a dumb LOCAL SLOT NUMBER. A symbol or instance supplies
|
||||
the palette context. `compile` gives project palettes disjoint ranges in the
|
||||
raster index space, so differently-paletted subtrees can coexist. The ranges
|
||||
are derived, never persisted: adding a palette never rewrites drawing data.")
|
||||
|
||||
Entries are ordered, and the order IS the index the raster writes. Inserting in
|
||||
the middle renumbers every stored index, so new tones append.")
|
||||
(def default-id :arthur/default)
|
||||
(def inherit :arthur.palette/inherit)
|
||||
|
||||
(def entries
|
||||
[{:name :bg :hex "#12141c"}
|
||||
|
|
@ -48,3 +46,106 @@
|
|||
(def rgb
|
||||
"Index -> [r g b], precomputed."
|
||||
(mapv hex->rgb hexes))
|
||||
|
||||
(def default-palette
|
||||
{:id default-id :name "Arthur"
|
||||
:slots (into entries (repeat 7 {:hex "#000000"}))})
|
||||
|
||||
(defn palettes [clip]
|
||||
(if (seq (:palettes clip)) (:palettes clip) {default-id default-palette}))
|
||||
|
||||
(defn default-palette-id [clip]
|
||||
(let [ps (palettes clip)]
|
||||
(or (:default-palette clip)
|
||||
(when (contains? ps default-id) default-id)
|
||||
(first (sort-by str (keys ps))))))
|
||||
|
||||
(defn- slot-index [p value]
|
||||
(cond
|
||||
(and (integer? value) (<= 0 value) (< value (count (:slots p)))) value
|
||||
;; Compatibility for existing documents. New drawing data is numeric.
|
||||
(keyword? value) (first (keep-indexed #(when (= value (:name %2)) %1) (:slots p)))
|
||||
:else nil))
|
||||
|
||||
(defn compile
|
||||
"Compile the project's palette assets into the one ramp used by the raster.
|
||||
Index 255 remains the conspicuous bad-data sentinel."
|
||||
[clip]
|
||||
(let [selection-values (fn [x]
|
||||
(cond
|
||||
(nil? x) []
|
||||
(and (map? x) (:keys x)) (vals (:keys x))
|
||||
(and (map? x) (contains? x :value)) [(:value x)]
|
||||
:else [x]))
|
||||
used (into #{(default-palette-id clip)}
|
||||
(mapcat selection-values)
|
||||
(concat (map :palette (vals (:symbols clip)))
|
||||
(map :palette-channel (vals (:symbols clip)))
|
||||
(map (fn [sym]
|
||||
(when (= :palette (:type sym)) (:palette-ref sym)))
|
||||
(vals (:symbols clip)))
|
||||
(for [sym (vals (:symbols clip))
|
||||
n (vals (:nodes sym))]
|
||||
(:palette n))
|
||||
(for [sym (vals (:symbols clip))
|
||||
n (vals (:nodes sym))]
|
||||
(get-in n [:channels [:palette]]))))
|
||||
ps (select-keys (palettes clip) used)
|
||||
ordered (sort-by (comp str key) ps)
|
||||
offsets (loop [xs ordered at 0 out {}]
|
||||
(if-let [[id p] (first xs)]
|
||||
(recur (next xs) (+ at (count (:slots p))) (assoc out id at))
|
||||
out))
|
||||
total (reduce + (map #(count (:slots (val %))) ordered))]
|
||||
(when (> total 255)
|
||||
(throw (ex-info "palettes visible in one render use more than 255 slots"
|
||||
{:slots total :palettes (count ps)})))
|
||||
{:palettes ps
|
||||
:default (default-palette-id clip)
|
||||
:offsets offsets
|
||||
:ramp (vec (mapcat (fn [[_ p]] (map #(hex->rgb (:hex %)) (:slots p))) ordered))
|
||||
:bg (get offsets (default-palette-id clip) 0)}))
|
||||
|
||||
(defn render-index
|
||||
"A local slot (or a legacy tone keyword) in palette `id` -> raster index."
|
||||
[{:keys [palettes offsets]} id value]
|
||||
(let [id (if (map? id) (:from id) id)
|
||||
p (get palettes id)
|
||||
i (and p (slot-index p value))]
|
||||
(if (some? i) (+ (get offsets id 0) i) 255)))
|
||||
|
||||
(defn background-index
|
||||
"The raster index of slot zero in the resolver's active palette.
|
||||
|
||||
`index-of` remains a supported legacy palette map for domain callers; it has
|
||||
no palette banks or active selection, so its established `:bg` index applies."
|
||||
[palette active]
|
||||
(if (and (:palettes palette) (:offsets palette))
|
||||
(render-index palette active 0)
|
||||
(get palette :bg 0)))
|
||||
|
||||
(defn effective-ramp
|
||||
"The compiled ramp with the active palette blend applied. A hold returns the
|
||||
original vector; a linear palette segment rewrites only the left palette's
|
||||
bank, which is the bank the indexed raster was drawn into."
|
||||
[{:keys [palettes offsets ramp]} active]
|
||||
(if-let [{:keys [from to t]} (when (map? active) active)]
|
||||
(let [a (get palettes from)
|
||||
b (get palettes to)
|
||||
at (get offsets from)
|
||||
n (min (count (:slots a)) (count (:slots b)))]
|
||||
(if (and a b (number? at) (number? t))
|
||||
(reduce (fn [out i]
|
||||
(let [x (hex->rgb (get-in a [:slots i :hex]))
|
||||
y (hex->rgb (get-in b [:slots i :hex]))]
|
||||
(assoc out (+ at i)
|
||||
(mapv #(js/Math.round (+ %1 (* t (- %2 %1)))) x y))))
|
||||
ramp (range n))
|
||||
ramp))
|
||||
ramp))
|
||||
|
||||
(defn valid-palette? [{:keys [id name slots]}]
|
||||
(and id (string? name) (seq name) (vector? slots) (pos? (count slots))
|
||||
(<= (count slots) 255)
|
||||
(every? #(and (string? (:hex %))
|
||||
(boolean (re-matches #"#[0-9a-fA-F]{6}" (:hex %)))) slots)))
|
||||
|
|
|
|||
226
frontend/src/arthur/domain/pick.cljs
Normal file
226
frontend/src/arthur/domain/pick.cljs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
(ns arthur.domain.pick
|
||||
"What is under the pointer on the stage, and which row a click on it selects.
|
||||
|
||||
THE OPS ALREADY SAY. The stage draws a flat list of ops, and each op's `:node`
|
||||
is the row path of what drew it — the instances down to it and its own id — so
|
||||
hit-testing is a walk of the list, topmost first, and needs nothing resolved.
|
||||
|
||||
WHICH LEVEL a click selects is Figma's and Illustrator's rule, and Flash's
|
||||
without its edit mode: a click selects the thing in the open symbol, a
|
||||
double-click goes one level into what is selected, ⌥-click goes straight to the
|
||||
shape itself. A click inside what is selected keeps it, so a deep selection can
|
||||
be dragged; one elsewhere selects at the same depth, beside it."
|
||||
(:require [arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.palette :as pal]))
|
||||
|
||||
(def ^:private slop
|
||||
"Stage pixels a click may miss by. The shapes here are a few pixels across."
|
||||
2)
|
||||
|
||||
(defn- path-of [op]
|
||||
(let [n (:node op)] (if (vector? n) n [n])))
|
||||
|
||||
(defn- near-segment? [x y ax ay bx by]
|
||||
(let [dx (- bx ax) dy (- by ay)
|
||||
l2 (+ (* dx dx) (* dy dy))
|
||||
t (if (zero? l2) 0 (-> (/ (+ (* (- x ax) dx) (* (- y ay) dy)) l2) (max 0) (min 1)))
|
||||
ex (- x (+ ax (* t dx)))
|
||||
ey (- y (+ ay (* t dy)))]
|
||||
(<= (+ (* ex ex) (* ey ey)) (* slop slop))))
|
||||
|
||||
(defn- on-poly? [^js pts n x y]
|
||||
(let [px #(aget pts (* 2 (mod % n)))
|
||||
py #(aget pts (inc (* 2 (mod % n))))]
|
||||
(or (odd? (count (filter (fn [i]
|
||||
(let [ay (py i) by (py (inc i))]
|
||||
(and (not= (> ay y) (> by y))
|
||||
(< x (+ (px i) (/ (* (- y ay) (- (px (inc i)) (px i)))
|
||||
(- by ay)))))))
|
||||
(range n))))
|
||||
(some #(near-segment? x y (px %) (py %) (px (inc %)) (py (inc %))) (range n)))))
|
||||
|
||||
(defn- trace-corners
|
||||
"A trace op's image rectangle on the stage, as four [x y] corners."
|
||||
[{[w h] :size m :m}]
|
||||
(for [[x y] [[0 0] [w 0] [w h] [0 h]]]
|
||||
[(+ (* (aget m 0) x) (* (aget m 2) y) (aget m 4))
|
||||
(+ (* (aget m 1) x) (* (aget m 3) y) (aget m 5))]))
|
||||
|
||||
(defn- on? [{:keys [kind pts n cx cy r size m]} x y]
|
||||
(case kind
|
||||
:poly (on-poly? pts n x y)
|
||||
:disc (<= (js/Math.hypot (- x cx) (- y cy)) (+ r slop))
|
||||
:rect (let [h (+ slop (/ size 2))]
|
||||
(and (<= (js/Math.abs (- x cx)) h) (<= (js/Math.abs (- y cy)) h)))
|
||||
;; Into the image's own pixels, where the test is a rectangle however the
|
||||
;; layer is turned or scaled.
|
||||
:trace (when-let [inv (node/invert m)]
|
||||
(let [[w h] size
|
||||
u (+ (* (aget inv 0) x) (* (aget inv 2) y) (aget inv 4))
|
||||
v (+ (* (aget inv 1) x) (* (aget inv 3) y) (aget inv 5))]
|
||||
(and (<= 0 u) (< u w) (<= 0 v) (< v h))))
|
||||
false))
|
||||
|
||||
;; A HOLE AND A LIGHT HAVE BOUNDS LIKE ANYTHING ELSE. A knockout and a remap
|
||||
;; were both left out of this, which is the rule `hit-op` plays by — a click
|
||||
;; goes through them to what shows under — carried over to a gesture it is wrong
|
||||
;; for: a marquee asks what is INSIDE it, not what a point lands on, and leaving
|
||||
;; them out meant the only way to select one anywhere was its timeline row.
|
||||
(defn- op-bounds [{:keys [kind pts n cx cy r size] :as op}]
|
||||
(case kind
|
||||
:trace (let [cs (trace-corners op)]
|
||||
[(apply min (map first cs)) (apply min (map second cs))
|
||||
(apply max (map first cs)) (apply max (map second cs))])
|
||||
:poly (reduce (fn [b i]
|
||||
(let [x (aget pts (* 2 i)) y (aget pts (inc (* 2 i)))]
|
||||
(if b (let [[x0 y0 x1 y1] b]
|
||||
[(min x0 x) (min y0 y) (max x1 x) (max y1 y)])
|
||||
[x y x y]))) nil (range n))
|
||||
:disc [(- cx r) (- cy r) (+ cx r) (+ cy r)]
|
||||
:rect (let [h (/ size 2)] [(- cx h) (- cy h) (+ cx h) (+ cy h)])
|
||||
nil))
|
||||
|
||||
(defn in-rect
|
||||
"Distinct row paths whose drawn bounds intersect `[x0 y0 x1 y1]`. `depth`
|
||||
chooses objects at one hierarchy level, just as an ordinary stage click does."
|
||||
[ops [ax ay bx by] depth]
|
||||
(let [[rx0 rx1] [(min ax bx) (max ax bx)]
|
||||
[ry0 ry1] [(min ay by) (max ay by)]]
|
||||
(->> ops
|
||||
(keep (fn [op]
|
||||
(when-let [[x0 y0 x1 y1] (op-bounds op)]
|
||||
(when (and (<= x0 rx1) (<= rx0 x1) (<= y0 ry1) (<= ry0 y1))
|
||||
(let [path (path-of op)]
|
||||
(subvec path 0 (min (count path) (max 1 depth))))))))
|
||||
distinct vec)))
|
||||
|
||||
(defn- prefix? [a b]
|
||||
(and (<= (count a) (count b)) (= a (subvec b 0 (count a)))))
|
||||
|
||||
(defn hit-op
|
||||
"The topmost op in `ops`, in draw order, that SHOWS at stage point `[x y]`, or
|
||||
nil. A knockout is never hit: it is a hole, and a click in it is a click on
|
||||
whatever shows through — so it hides the ops beneath it in its own symbol, of
|
||||
the colour it clears. A remap is the same kind of thing the other way up: it is
|
||||
light on what is under it, so a click goes through it to what it lights.
|
||||
|
||||
EXCEPT WHAT IS ALREADY SELECTED, which `selected` is the row path of. Both of
|
||||
those rules are about reaching PAST a shape, and neither has anything to say
|
||||
about the shape you have in your hands: a selected remap or knockout answered
|
||||
no point on the stage at all, so there was nothing to drag it by — the stage's
|
||||
move gesture is this hit-test on the ops, and `handles` draws a box round
|
||||
bounds that nothing could then grab. The first press on one started a marquee
|
||||
instead, which selected nothing and so threw the selection away, and that is
|
||||
the whole of why a palette swapper could be selected from a timeline row and
|
||||
then neither moved nor resized. The ordinary depth rule — a click inside what
|
||||
is selected keeps it, so a deep selection can be dragged — is `choose`'s, and
|
||||
this is the same rule reaching one step further down."
|
||||
([ops point] (hit-op ops point nil))
|
||||
([ops [x y] selected]
|
||||
(:op (reduce (fn [holes op]
|
||||
(cond
|
||||
(not (on? op x y)) holes
|
||||
(and selected (prefix? selected (path-of op))) (reduced {:op op})
|
||||
(:lut op) holes
|
||||
(:knock op) (conj holes [(pop (path-of op)) (:knock op)])
|
||||
(some (fn [[in k]] (and (prefix? in (path-of op))
|
||||
(or (neg? k) (== k (:color op)))))
|
||||
holes) holes
|
||||
:else (reduced {:op op})))
|
||||
[] (let [{traces true drawn false} (group-by #(= :trace (:kind %)) (rseq (vec ops)))]
|
||||
(concat drawn traces))))))
|
||||
|
||||
(defn hit
|
||||
"The row path of the topmost op that shows at stage point `[x y]`, or nil.
|
||||
`selected` is what is selected now, which a light or a hole does not hide."
|
||||
([ops point] (hit ops point nil))
|
||||
([ops point selected] (some-> (hit-op ops point selected) path-of)))
|
||||
|
||||
(defn choose
|
||||
"The row path a click on `hit` selects, with `selected` the one selected now."
|
||||
[selected hit deep?]
|
||||
(cond
|
||||
(nil? hit) nil
|
||||
deep? hit
|
||||
(and selected (prefix? selected hit)) selected
|
||||
(and selected (prefix? (pop selected) hit)) (subvec hit 0 (count selected))
|
||||
:else [(first hit)]))
|
||||
|
||||
(defn deeper
|
||||
"One level into `selected` towards `hit`, for a double-click."
|
||||
[selected hit]
|
||||
(if (and selected hit (prefix? selected hit) (< (count selected) (count hit)))
|
||||
(subvec hit 0 (inc (count selected)))
|
||||
selected))
|
||||
|
||||
(defn bounds-of
|
||||
"A closure from a frame to `[x0 y0 x1 y1]` around what node `n` draws on it, in
|
||||
its own coordinates — nil on a frame it draws nothing on.
|
||||
|
||||
A CLOSURE, as `clip/resolver` is, and for the same reason it is: inside an
|
||||
instance is its whole symbol resolved at that frame, and a resolver costs the
|
||||
symbol to BUILD and a lookup to RUN. Asking frame by frame through a fresh one
|
||||
is a resolver per frame.
|
||||
|
||||
WHAT A SELECTION BOX IS DRAWN FROM, and what a pivot DEFAULTS to: a node
|
||||
nobody has pivoted turns about the middle of these bounds, and the first turn
|
||||
or scale writes that point down as its `[:xform :pivot]` — `gesture/pivot` and
|
||||
`gesture/with-pivot`. So the box and the cross start out as one computation,
|
||||
and ⌖ in the inspector brings a chosen pivot back to it (`gesture/centred`).
|
||||
|
||||
A DEFAULT, AND NOT WHERE THE PIVOT LIVES. Once a pivot is the node's own, this
|
||||
is not consulted for it again: the pivot is a choice, and a choice that
|
||||
silently followed the drawing would mean adding a shape to a symbol re-aimed
|
||||
every keyed spin of every instance of it. See `domain/gesture`."
|
||||
[document store sid n]
|
||||
(let [grow (fn [[x0 y0 x1 y1 :as b] x y]
|
||||
(if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y]))
|
||||
at (fn [f p] (ch/value-at (get (node/channels n) p) f store))]
|
||||
(case (if (some->> (node/source n) (clip/symbol document) clip/trace?) :trace (:kind n))
|
||||
;; A tracing symbol draws nothing to resolve: what it covers is its media's
|
||||
;; pixels, in its own coordinates, on every frame it shows one.
|
||||
:trace
|
||||
(let [{:keys [width height]} (clip/symbol document (node/source n))]
|
||||
(fn [f] (when (clip/placed-frame document sid n f) [0 0 width height])))
|
||||
|
||||
:instance
|
||||
;; ONE RESOLVER PER DRAWING THE LANE CAN SHOW, built once for the reason
|
||||
;; the single one used to be: a resolver costs the symbol to build and a
|
||||
;; lookup to run, and a lane asked frame by frame through a fresh one is a
|
||||
;; resolver per frame.
|
||||
(let [loop? (get-in n [:time :loop?])
|
||||
resolvers (into {} (map (fn [child]
|
||||
[child (clip/resolver document child store
|
||||
pal/index-of {:grid-fps (clip/fps document child)})]))
|
||||
(node/sources n))]
|
||||
(fn [f0]
|
||||
(let [shown (clip/placed-frame document sid n f0)
|
||||
frames (when shown (clip/frames document (:symbol shown)))
|
||||
f (when (number? frames)
|
||||
(if loop? (mod (:frame shown) frames) (:frame shown)))
|
||||
resolve (when shown (get resolvers (:symbol shown)))]
|
||||
(when (and resolve (< -1 f frames))
|
||||
(reduce (fn [b {:keys [kind pts n cx cy r size]}]
|
||||
(case kind
|
||||
:poly (reduce #(grow %1 (aget pts (* 2 %2)) (aget pts (inc (* 2 %2))))
|
||||
b (range n))
|
||||
:disc (-> b (grow (- cx r) (- cy r)) (grow (+ cx r) (+ cy r)))
|
||||
:rect (let [h (/ size 2)]
|
||||
(-> b (grow (- cx h) (- cy h)) (grow (+ cx h) (+ cy h))))
|
||||
b))
|
||||
nil
|
||||
(resolve f))))))
|
||||
:poly (fn [f]
|
||||
(let [pts (at f [:geom :pts])]
|
||||
(when-not (ch/nothing? pts)
|
||||
(reduce (fn [b i] (grow b (ch/component pts (* 2 i)) (ch/component pts (inc (* 2 i)))))
|
||||
nil (range (quot (if (vector? pts) (count pts) (.-length pts)) 2))))))
|
||||
:disc (fn [f]
|
||||
(let [r (at f [:geom :radius])]
|
||||
(when-not (ch/nothing? r) [(- r) (- r) r r])))
|
||||
:rect (fn [f]
|
||||
(let [s (at f [:geom :size])]
|
||||
(when-not (ch/nothing? s) (let [h (/ s 2)] [(- h) (- h) h h]))))
|
||||
(constantly nil))))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,16 +2,146 @@
|
|||
"An instance's explicit, held choices of source pose for each shape group.
|
||||
|
||||
A track is {local-frame -> source-frame}. The key is when the cut happens;
|
||||
the value is the frozen pose to read. Skipped source frames remain available.")
|
||||
the value is the frozen pose to read. Skipped source frames remain available.
|
||||
|
||||
AND THE PRESERVE-SNAP, which is the same question asked where nobody has
|
||||
answered it by hand. A grid slot already picks a native frame — the latest one
|
||||
at or before its time, see `domain/cadence` — and that pick has no opinion about
|
||||
content, so at 12fps out of 30 it drops two frames in three and cannot know that
|
||||
one of them is where the mouth shut. The snap gives it one: a slot reads the
|
||||
latest MARKED frame inside its own gap. `marks` is where the marks come from and
|
||||
`snapped-frame` is the rule; `domain/symbol` seats the rule as the DEFAULT pose,
|
||||
so a hand cut still beats it without anything having to say so."
|
||||
(:require [arthur.domain.channel :as ch]
|
||||
[arthur.domain.node :as node]))
|
||||
|
||||
(defn prepare
|
||||
"Sort exposure tracks once when building a resolver."
|
||||
"Sort pose tracks once when building a resolver."
|
||||
[tracks]
|
||||
(into {}
|
||||
(map (fn [[group entries]]
|
||||
[group (vec (sort-by first entries))]))
|
||||
tracks))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the preserve-snap: which frames are worth landing on
|
||||
|
||||
(def ^:private closure-cuts
|
||||
"The `[:vis]` provenances that are a CLOSURE, and therefore a frame worth
|
||||
landing on.
|
||||
|
||||
A SKIPPED FRAME, A HIDDEN FEATURE AND AN ABSENT MEASUREMENT ARE THREE
|
||||
DIFFERENT FACTS, and marks being derived from a visibility cut is exactly what
|
||||
makes that easy to blur. `flow/freeze` writes `[:vis]` for three different
|
||||
reasons: the mouth's aperture against the take's peak, `condition/resolve-blink`
|
||||
with its cut and dwell, and whether a teeth contour could be extracted. The
|
||||
first two are a part CLOSING — a thing the picture should land on. The third is
|
||||
a feature not being there to draw, which says nothing about a performance, and
|
||||
nor does a `[:vis]` somebody keyed by hand to switch a part off.
|
||||
|
||||
So this is a whitelist of two and not a test for `:generated`: reading every
|
||||
stored `[:vis]` would snap the grid onto the frames a part happened to be
|
||||
missing on, which is the cadence being dragged about by an absence."
|
||||
#{:roto/mouth-aperture :roto/blink})
|
||||
|
||||
(defn- shut-frames
|
||||
"The frames channel `c` calls shut, or nil when it is not a closure cut.
|
||||
|
||||
Sampled through a CURSOR rather than `ch/value-at` because the frames are read
|
||||
in order: `value-at` rebuilds the sorted key index per call and the two are
|
||||
required to agree exactly, so the sequential reader is the cheap half of an
|
||||
equality the model already guarantees.
|
||||
|
||||
`false?` and not falsiness. `ch/absent` is not a closure — a frame the subject
|
||||
was not on has no mouth to be shut — and a dense `[:vis]` yields 0, which is
|
||||
truthy in CLJS, so neither obvious test is right. `symbol/visible?` insists on
|
||||
the same boolean for the same reason."
|
||||
[c frames store]
|
||||
(when (contains? closure-cuts (:by (:generated c)))
|
||||
(let [cur (ch/cursor c store)]
|
||||
(into [] (filter #(false? (ch/sample! cur %))) (range frames)))))
|
||||
|
||||
(defn marks
|
||||
"Frames worth landing on, per pose group: `{group -> ascending frames}`, or nil
|
||||
where nothing is marked.
|
||||
|
||||
PRECOMPUTED WHEN THE RESOLVER IS BUILT, beside `prepare`,
|
||||
and this is the reason it is a function rather than a line inside the snap. A
|
||||
`[:vis]` channel is keys and not dense, so reading one per node per frame would
|
||||
be cheap — but the snap needs the marks SORTED for a backward lookup, and
|
||||
building that per frame is what docs/animation-model.md forbids on the render
|
||||
path.
|
||||
|
||||
PER POSE GROUP, because the thing being recovered is one group's closure. The
|
||||
alternative — snapping the slot itself, where the grid becomes a native frame —
|
||||
is one native frame for the whole picture, so a head would go two frames stale
|
||||
for one output frame to fix a mouth. A group's related parts share one answer,
|
||||
which is what `:pose-group` is already for: the mouth outline, its interior and
|
||||
the teeth reading different frames is the bug grouping prevents.
|
||||
|
||||
No new signal and no new stored field: the cut `flow/freeze` already wrote is
|
||||
read where it lies, with its thresholding and hysteresis already decided. A
|
||||
group whose parts carry no closure cut gets no marks and no entry, which is
|
||||
correct for brows — there is no extreme brow position worth protecting."
|
||||
[nodes frames store]
|
||||
(when (and (integer? frames) (pos? frames))
|
||||
(not-empty
|
||||
(into {}
|
||||
(keep (fn [[group ns]]
|
||||
(let [fs (into (sorted-set)
|
||||
(mapcat #(shut-frames (get (:channels %) [:vis])
|
||||
frames store))
|
||||
ns)]
|
||||
(when (seq fs) [group (vec fs)]))))
|
||||
(group-by #(or (:pose-group %) (:id %)) (vals nodes))))))
|
||||
|
||||
(defn- latest-mark
|
||||
"The largest mark at or before `f`, or nil. Binary search, as `held-frame` is:
|
||||
the marks are sorted once and read in whatever order the transport asks for."
|
||||
[ms f]
|
||||
(loop [lo 0 hi (dec (count ms)) hit nil]
|
||||
(if (> lo hi)
|
||||
(when (some? hit) (nth ms hit))
|
||||
(let [mid (bit-shift-right (+ lo hi) 1)]
|
||||
(if (<= (nth ms mid) f)
|
||||
(recur (inc mid) hi mid)
|
||||
(recur lo (dec mid) hit))))))
|
||||
|
||||
(defn snapped-frame
|
||||
"The native frame a grid slot reads: THE LATEST MARK IN `(lo, hi]`, AND `hi`
|
||||
WHEN THERE IS NONE.
|
||||
|
||||
`hi` is the frame the slot defaults to — the latest native frame at or before
|
||||
its time — and `lo` is the frame the slot BEFORE it defaulted to. So the
|
||||
half-open interval is exactly the frames this slot is the first to cover, which
|
||||
are exactly the ones the grid shows to nobody. That one sentence is the whole
|
||||
mechanism: it recovers a dropped frame out of the slot's own gap, it can never
|
||||
read a frame another slot already showed, and it cannot reach past `hi`.
|
||||
|
||||
BACKWARD ONLY, NEVER FORWARD, and note which direction that actually is because
|
||||
it is the easy thing to get wrong. A closure at native 13 in a 12-from-30 output
|
||||
is recovered by the slot whose default is 15, reading 13. It is NOT recovered by
|
||||
the slot whose default is 12 reaching forward to 13: that slot's instant is
|
||||
5/12s and native 13's is 13/30s, so it would show the closure 17ms before the
|
||||
mouth shut. `cadence/frame`'s contract is at-or-before and `cadence_test` asserts
|
||||
`selected <= f*native/grid` over every grid and native pair, so reaching forward
|
||||
would break a tested invariant as well as the no-lead rule. Reading 13 two
|
||||
native frames late is the lateness every hold already has.
|
||||
|
||||
An EMPTY interval snaps nothing. A slot that reads what the slot before it read
|
||||
— a held exposure, or an output grid faster than the content — has no gap of its
|
||||
own, and `(hi, hi]` contains nothing to find.
|
||||
|
||||
Takes no tolerance and will not grow one. The output rate or the exposure
|
||||
setting has already chosen the sparseness; the only question left is WHICH
|
||||
native frame an already-decided slot reads, and a second knob here would be a
|
||||
control with nothing to control."
|
||||
[marks group lo hi]
|
||||
(if-let [ms (get marks group)]
|
||||
(let [m (latest-mark ms hi)]
|
||||
(if (and (some? m) (> m lo)) m hi))
|
||||
hi))
|
||||
|
||||
(defn held-frame
|
||||
"Last value keyed at or before f, or default before the first key."
|
||||
[entries f default-frame]
|
||||
|
|
@ -32,16 +162,20 @@
|
|||
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 [inst (get-in clip [:symbols sid :nodes instance])
|
||||
;; A cut is checked against the ONE symbol this cel places. Which
|
||||
;; drawing a lane shows is a question about the lane's other cels,
|
||||
;; and each of them owns its own tracks — so there is nothing to union.
|
||||
placed (get-in clip [:symbols (node/source inst)])
|
||||
length (or (:frames placed) 0)
|
||||
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 inst))
|
||||
(or (contains? groups group)
|
||||
(and (vector? group) (= 2 (count group))
|
||||
(= :node (first group))
|
||||
|
|
@ -50,22 +184,22 @@
|
|||
(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)))
|
||||
|
||||
(defn problems
|
||||
"Errors in one symbol instance's exposure tracks."
|
||||
"Errors in one symbol instance's pose tracks."
|
||||
[tracks source-frames groups]
|
||||
(cond
|
||||
(nil? tracks) []
|
||||
|
|
|
|||
|
|
@ -29,6 +29,31 @@
|
|||
(:require [arthur.domain.leaf :as leaf]
|
||||
[arthur.domain.wire :as wire]))
|
||||
|
||||
(def schema-version
|
||||
"The stored document format. A client refuses a project stored under a
|
||||
different one — see `events/project` — so this is bumped by any change to what
|
||||
a leaf may contain.
|
||||
|
||||
8 added `[:xform :pivot]`, the point a node turns and scales about, which 7 had
|
||||
deleted as `[:xform :anchor]` on the theory that a peg could stand in for one.
|
||||
It cannot: a peg is a node and a turn about one is still `pos` solved per
|
||||
frame, so a KEYED turn of anything whose origin was not its own middle orbited
|
||||
that origin. See `node/xform-paths` and docs/animation-model.md.
|
||||
|
||||
CONVERTED, AND THE ONLY VERSION SO FAR THAT IS, because this one is exact: a
|
||||
schema-7 node has no pivot, an absent pivot reads as `[0 0]` off
|
||||
`node/defaults`, and `T(pos)·T(0)·M·T(-0)` is `T(pos)·M` to the bit. So every
|
||||
stored document composes to the same matrices it did, and the migration only
|
||||
restamps the version — `clips/migrations/0017`. That is the difference from 7,
|
||||
which could not convert an anchor it had deleted: dropping one moved anything
|
||||
since turned or scaled by hand, and tier-2 positions cannot be rewritten at
|
||||
all. Adding a component with an identity default takes nothing away.
|
||||
|
||||
A pivot a schema-7 document never got to choose is still unchosen, and the
|
||||
first turn or scale of such a node writes one — `gesture/with-pivot` — so the
|
||||
conversion does not have to guess where anybody wanted it."
|
||||
8)
|
||||
|
||||
(defn block-keys
|
||||
"Every tier-2 key a leaf map names, in a stable order."
|
||||
[leaves]
|
||||
|
|
@ -53,8 +78,8 @@
|
|||
round-trip a clip through `JSON.parse(JSON.stringify(...))` and be running the
|
||||
same conversion the network runs, rather than a CLJS-shaped rehearsal of it. The
|
||||
one thing a keywordising `js->clj` would quietly break is the leaf paths —
|
||||
`:clip/c1/timeline/main/node/mouth` is a keyword whose `name` is
|
||||
\"c1/timeline/main/node/mouth\", so the
|
||||
`:clip/c1/symbol/main/node/mouth` is a keyword whose `name` is
|
||||
\"c1/symbol/main/node/mouth\", so the
|
||||
\"clip/\" would be lost on the way back in.
|
||||
|
||||
Refuses a document `domain/leaf` calls unaddressable, which is where a hand-made
|
||||
|
|
@ -83,19 +108,27 @@
|
|||
:state (when state (wire/base64 state))}))
|
||||
(block-keys leaves)))})))
|
||||
|
||||
(defn load
|
||||
"The parsed response -> `{:clip :store}`, which is what `flow/freeze` returns
|
||||
and therefore what the player already knows how to play."
|
||||
[cid ^js doc]
|
||||
(let [leaves (.-leaves doc)
|
||||
tier1 (into {} (map (fn [path] [path (wire/decode-json (aget leaves path))]))
|
||||
(js-keys leaves))]
|
||||
{:clip (leaf/clip cid tier1)
|
||||
:store (into {}
|
||||
(defn tier1
|
||||
"A response's leaves object -> `{path value}`, the shape `leaf/leaves` returns."
|
||||
[^js leaves]
|
||||
(into {} (map (fn [path] [path (wire/decode-json (aget leaves path))]))
|
||||
(js-keys leaves)))
|
||||
|
||||
(defn store
|
||||
"Fetched blocks -> the store `save` reads them back out of."
|
||||
[blocks]
|
||||
(into {}
|
||||
(map (fn [^js b]
|
||||
[(.-key b)
|
||||
(cond-> {:descriptor (.-descriptor b)
|
||||
:data (wire/typed (block-type (.-descriptor b))
|
||||
(.-data b))}
|
||||
(.-state b) (assoc :state (wire/bytes-of (.-state b))))]))
|
||||
(array-seq (or (.-blocks doc) #js [])))}))
|
||||
(array-seq (or blocks #js []))))
|
||||
|
||||
(defn load
|
||||
"The parsed response -> `{:clip :store}`, which is what `flow/freeze` returns
|
||||
and therefore what the player already knows how to play."
|
||||
[cid ^js doc]
|
||||
{:clip (leaf/clip cid (tier1 (.-leaves doc)))
|
||||
:store (store (.-blocks doc))})
|
||||
|
|
|
|||
|
|
@ -24,19 +24,67 @@
|
|||
(.fill buf index)
|
||||
r)
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; layers and knockouts
|
||||
;;
|
||||
;; A symbol holding a KNOCKOUT shape is drawn into a layer of its own first —
|
||||
;; Flash's Erase blend inside a symbol set to Layer. A layer is a raster with a
|
||||
;; `:cov` byte per pixel saying whether anything is there; a knockout clears
|
||||
;; coverage, of every colour or of one, and the layer lands on the one under it
|
||||
;; only where it is covered. The main raster has no `:cov` and is always
|
||||
;; covered.
|
||||
;;
|
||||
;; THE INK is how a shape marks the pixels it covers, and there are three, as
|
||||
;; Deluxe Paint and Animator Pro had inks:
|
||||
;;
|
||||
;; nil an ordinary fill: write the shape's index
|
||||
;; a number a knockout: clear coverage — of every colour at -1, or of
|
||||
;; that index only
|
||||
;; a Uint8Array a REMAP, index -> index: what is already there is drawn
|
||||
;; in another slot. A flashlight is a circle of this.
|
||||
|
||||
(defn- plot! [^js buf cov o index ink]
|
||||
(cond
|
||||
(nil? ink) (do (aset buf o index)
|
||||
(when cov (aset cov o 1)))
|
||||
(number? ink) (when (and cov (or (neg? ink) (== (aget buf o) ink)))
|
||||
(aset cov o 0))
|
||||
:else (aset buf o (aget ink (aget buf o)))))
|
||||
|
||||
(defn- shows?
|
||||
"Does pixel `o` hold `over`? A stencil only matches what is really there, so
|
||||
an uncovered pixel of a layer holds nothing."
|
||||
[^js buf cov o over]
|
||||
(or (nil? over)
|
||||
(and (or (nil? cov) (== 1 (aget cov o))) (== (aget buf o) over))))
|
||||
|
||||
(defn layer [w h]
|
||||
{:w w :h h :buf (js/Uint8Array. (* w h)) :cov (js/Uint8Array. (* w h))})
|
||||
|
||||
(defn composite!
|
||||
"Land layer `l` on raster `r` wherever `l` is covered."
|
||||
[{:keys [buf cov] :as r} l]
|
||||
(let [^js lb (:buf l) ^js lc (:cov l)]
|
||||
(dotimes [o (.-length lb)]
|
||||
(when (== 1 (aget lc o))
|
||||
(aset buf o (aget lb o))
|
||||
(when cov (aset cov o 1)))))
|
||||
r)
|
||||
|
||||
(defn fill-poly-buf!
|
||||
"Even-odd scanline fill of a polygon held FLAT in `pts` as [x0 y0 x1 y1 …],
|
||||
using the first `n` points. Samples at pixel centres (y + 0.5), so a polygon
|
||||
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.
|
||||
|
||||
`pts` may be a CLJS vector or any typed array; scanline crossings are collected
|
||||
into a plain JS array and sorted in place."
|
||||
[{:keys [w h buf] :as r} pts n index]
|
||||
into a plain JS array and sorted in place. `ink` is as `plot!`'s."
|
||||
([r pts n index] (fill-poly-buf! r pts n index nil))
|
||||
([{:keys [w h buf cov] :as r} pts n index ink]
|
||||
(when (>= n 3)
|
||||
(let [px (fn [i] (if (vector? pts) (-nth pts (* 2 i)) (aget pts (* 2 i))))
|
||||
py (fn [i] (if (vector? pts) (-nth pts (inc (* 2 i))) (aget pts (inc (* 2 i)))))
|
||||
|
|
@ -78,8 +126,8 @@
|
|||
x-to (min (dec w) (js/Math.floor (- xb 0.5)))
|
||||
row (* y w)]
|
||||
(dotimes [dx (inc (- x-to x-from))]
|
||||
(aset buf (+ row x-from dx) index)))))))))
|
||||
r)
|
||||
(plot! buf cov (+ row x-from dx) index ink)))))))))
|
||||
r))
|
||||
|
||||
(defn fill-poly!
|
||||
"`fill-poly-buf!` over a seq of {:x :y} points.
|
||||
|
|
@ -99,8 +147,9 @@
|
|||
any radius, including mid-blink when the opening is a two-pixel sliver, so
|
||||
the lid crops the iris for free instead of the gaze range needing a
|
||||
clamp that would flatten the performance at the extremes."
|
||||
([r cx cy rad index] (fill-disc! r cx cy rad index nil))
|
||||
([{:keys [w h buf] :as r} cx cy rad index over]
|
||||
([r cx cy rad index] (fill-disc! r cx cy rad index nil nil))
|
||||
([r cx cy rad index over] (fill-disc! r cx cy rad index over nil))
|
||||
([{:keys [w h buf cov] :as r} cx cy rad index over ink]
|
||||
(let [rr (* rad rad)
|
||||
y0 (max 0 (js/Math.floor (- cy rad)))
|
||||
y1 (min (dec h) (js/Math.ceil (+ cy rad)))
|
||||
|
|
@ -114,8 +163,8 @@
|
|||
dy (- (+ y 0.5) cy)]
|
||||
(when (<= (+ (* dx dx) (* dy dy)) rr)
|
||||
(let [o (+ (* y w) x)]
|
||||
(when (or (nil? over) (= (aget buf o) over))
|
||||
(aset buf o index)))))))
|
||||
(when (shows? buf cov o over)
|
||||
(plot! buf cov o index ink)))))))
|
||||
r)))
|
||||
|
||||
(defn fill-rect!
|
||||
|
|
@ -132,8 +181,9 @@
|
|||
on every frame. Round the extents instead and a fractional centre gives you
|
||||
three pixels on one frame and four on the next, which reads as the pupil
|
||||
breathing."
|
||||
([r cx cy size index] (fill-rect! r cx cy size index nil))
|
||||
([{:keys [w h buf] :as r} cx cy size index over]
|
||||
([r cx cy size index] (fill-rect! r cx cy size index nil nil))
|
||||
([r cx cy size index over] (fill-rect! r cx cy size index over nil))
|
||||
([{:keys [w h buf cov] :as r} cx cy size index over ink]
|
||||
(let [size (js/Math.round size)]
|
||||
(when (>= size 1)
|
||||
(let [x0 (js/Math.round (- cx (/ size 2)))
|
||||
|
|
@ -143,8 +193,8 @@
|
|||
(dotimes [iy (- yb ya)]
|
||||
(dotimes [ix (- xb xa)]
|
||||
(let [o (+ (* (+ ya iy) w) xa ix)]
|
||||
(when (or (nil? over) (= (aget buf o) over))
|
||||
(aset buf o index))))))))
|
||||
(when (shows? buf cov o over)
|
||||
(plot! buf cov o index ink))))))))
|
||||
r))
|
||||
|
||||
(def ^:private little-endian?
|
||||
|
|
@ -223,6 +273,27 @@
|
|||
(aset d (+ o 3) 255))))))
|
||||
{:width W :height H :data d})))
|
||||
|
||||
(defn- fill-mask!
|
||||
"Every pixel set in `mask`, a byte per stage pixel: a brush stroke as it is
|
||||
being painted, before it is a polygon."
|
||||
[{:keys [buf cov] :as r} ^js mask index ink]
|
||||
(dotimes [o (.-length mask)]
|
||||
(when (== 1 (aget mask o))
|
||||
(plot! buf cov o index ink)))
|
||||
r)
|
||||
|
||||
;; One layer per depth of nesting, reused from frame to frame, as the resolver
|
||||
;; reuses its point buffers.
|
||||
(defonce ^:private layers (atom {}))
|
||||
|
||||
(defn- layer-at [depth w h]
|
||||
(let [l (get @layers depth)]
|
||||
(if (and l (= w (:w l)) (= h (:h l)))
|
||||
l
|
||||
(let [l (layer w h)]
|
||||
(swap! layers assoc depth l)
|
||||
l))))
|
||||
|
||||
(defn draw-ops!
|
||||
"Paint a list of draw ops, in the order given, into the raster. Stage 7.
|
||||
|
||||
|
|
@ -230,12 +301,26 @@
|
|||
space points and a PALETTE INDEX, and the rasteriser knows nothing about nodes,
|
||||
channels, time maps or provenance. Everything above here can be rearranged
|
||||
without touching a scanline, and a painted cel and a rotoscoped mouth arrive
|
||||
here indistinguishable from each other, which is the point."
|
||||
[r ops]
|
||||
(doseq [{:keys [kind pts n color stencil cx cy size] :as op} ops]
|
||||
here indistinguishable from each other, which is the point.
|
||||
|
||||
`:begin` and `:end` bracket the ops of a symbol drawn into a layer of its own;
|
||||
`:knock` on an op makes it a knockout and `:lut` a remap. See `plot!`."
|
||||
[{:keys [w h] :as r} ops]
|
||||
(reduce
|
||||
(fn [stack {:keys [kind pts n color stencil cx cy size] :as op}]
|
||||
(let [top (peek stack)
|
||||
ink (or (:knock op) (:lut op))]
|
||||
(case kind
|
||||
:poly (fill-poly-buf! r pts n color)
|
||||
:disc (fill-disc! r cx cy (:r op) color stencil)
|
||||
:rect (fill-rect! r cx cy size color stencil)
|
||||
(throw (ex-info "draw op kind is not rasterisable" {:op (dissoc op :pts)}))))
|
||||
:begin (let [l (layer-at (count stack) w h)]
|
||||
(.fill (:cov l) 0)
|
||||
(conj stack l))
|
||||
:end (if (< 1 (count stack))
|
||||
(let [s (pop stack)] (composite! (peek s) top) s)
|
||||
stack)
|
||||
:poly (do (fill-poly-buf! top pts n color ink) stack)
|
||||
:disc (do (fill-disc! top cx cy (:r op) color stencil ink) stack)
|
||||
:rect (do (fill-rect! top cx cy size color stencil ink) stack)
|
||||
:mask (do (fill-mask! top (:mask op) color ink) stack)
|
||||
(throw (ex-info "draw op kind is not rasterisable" {:op (dissoc op :pts)})))))
|
||||
[r] ops)
|
||||
r)
|
||||
|
|
|
|||
|
|
@ -13,9 +13,15 @@
|
|||
back to positions with indexOf would silently pick the wrong slot if a table
|
||||
ever repeated an id.
|
||||
|
||||
For even n this naturally lands on the cardinal positions (corners and lip
|
||||
centres) of a 20-point ring. Fixed indices, never adaptive decimation: the
|
||||
vertex at slot k means the same thing on every frame of the shot."
|
||||
Fixed indices, never adaptive decimation: the vertex at slot k means the same
|
||||
thing on every frame of the shot.
|
||||
|
||||
WHICH CARDINAL POSITIONS SURVIVE DEPENDS ON n, and not merely on n being even.
|
||||
On a 20-point ring the corners at 0 and 10 are kept by every even n, but the lip
|
||||
centres at 5 and 15 are kept only when n is a multiple of 4: n = 6, 10, 14 and
|
||||
18 all drop them. So nothing may assume a named slot is still in the output —
|
||||
the aperture pair least of all. A signal that needs those two landmarks reads
|
||||
them from a measurement, not from a subsampled ring."
|
||||
[len n]
|
||||
(mapv (fn [k] (mod (js/Math.round (/ (* k len) n)) len)) (range n)))
|
||||
|
||||
|
|
|
|||
191
frontend/src/arthur/domain/select.cljs
Normal file
191
frontend/src/arthur/domain/select.cljs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
(ns arthur.domain.select
|
||||
"Which frames out of a dense measurement are worth keeping.
|
||||
|
||||
A SELECTION IS A SET OF FRAMES CHOSEN OUT OF A DENSE MEASUREMENT, AND READ BY
|
||||
HOLDING THE LATEST ONE AT OR BEFORE NOW. The holding half is `pose/held-frame`
|
||||
and is already shared by tracing and by pose tracks; this is the choosing half,
|
||||
which nothing did automatically before. One component, two sites: the plate
|
||||
frames an artist has to draw a head on, and the frames a performance changes a
|
||||
shape on.
|
||||
|
||||
`domain/cadence` answers the same question with no opinion about content, and
|
||||
cannot know that the one frame where a mouth is fully shut is worth more than
|
||||
its neighbours. That is the gap `:protect` closes.
|
||||
|
||||
THREE LAYERS, AND THE MIDDLE ONE IS DERIVED. A selection is a `:policy` (what
|
||||
the proposer was asked for), a `:keep` and a `:drop` (what the hand insists on
|
||||
and refuses). Only the hand's two sets are the document: the proposal is
|
||||
recomputed whenever the policy or the signal changes, which is the whole reason
|
||||
the hand's decisions are stored as their own sets rather than as the resulting
|
||||
frame list. Re-suggesting at a new tolerance must never cost somebody their
|
||||
pinned blink.
|
||||
|
||||
Pure. No store access and no clip access — each site reads its own dense data
|
||||
and hands over a plain vector. Proposing is a command and not a subscription:
|
||||
this allocates, which is fine for a button press over a few hundred frames and
|
||||
would not be fine on the per-frame path.")
|
||||
|
||||
(defn- sample
|
||||
"One sample as a flat vector of numbers, or nil where there is no measurement.
|
||||
|
||||
A plain number is a one-component sample, and nested points are flattened:
|
||||
four transformed corners and the eight numbers in them are the same signal, so
|
||||
neither site has to flatten on the way in. Nil is an ABSENT measurement — a
|
||||
frame where the face was not found — which says nothing about the signal and is
|
||||
not the same fact as a frame being skipped."
|
||||
[s]
|
||||
(cond
|
||||
(nil? s) nil
|
||||
(number? s) [s]
|
||||
:else (not-empty (vec (flatten s)))))
|
||||
|
||||
(defn- distance
|
||||
"How far apart two samples are: the largest absolute difference over their
|
||||
components. A tolerance therefore means \"this far\" in the units the site
|
||||
handed over, whether it handed over an aperture or a quad of corners, and
|
||||
nobody has to say which.
|
||||
|
||||
Both samples are the same width, which `propose` has already insisted on rather
|
||||
than comparing whatever prefix the two happen to share."
|
||||
[a b]
|
||||
(reduce (fn [m i] (max m (js/Math.abs (- (nth a i) (nth b i)))))
|
||||
0 (range (count a))))
|
||||
|
||||
(defn- walk
|
||||
"The greedy walk: keep frame 0 as the anchor, keep every frame in `forced`, and
|
||||
keep every other frame that has moved further than `tolerance` from the anchor.
|
||||
Each kept frame becomes the anchor.
|
||||
|
||||
A FORCED FRAME BECOMES THE ANCHOR LIKE ANY OTHER, which is why protection is
|
||||
settled before the walk rather than unioned into its result. The anchor is what
|
||||
is on screen; once a protected frame is kept the viewer is looking at it, so
|
||||
measuring drift from a frame that is no longer displayed is simply wrong. Doing
|
||||
it the other way holds a protected one-frame closure across two frames and
|
||||
shows it twice as long as it was measured, which is the perceptual error the
|
||||
protection was added to prevent.
|
||||
|
||||
Order-dependent and slightly suboptimal, and deliberately the first
|
||||
implementation anyway — it is what the prototype shipped, and the dynamic
|
||||
program that replaces it gets tested by being compared against it. Keeping the
|
||||
anchor honest here leaves greed as the only difference between the two.
|
||||
|
||||
An absent sample is not a change: it cannot move the anchor and it is not worth
|
||||
a frame. A measurement arriving where the anchor has none is, since a signal
|
||||
coming back is a frame the picture has to show something on. A forced frame
|
||||
with no measurement is still kept — it was asked for — and leaves the anchor
|
||||
where it was, there being nothing there to measure from."
|
||||
[n sig tolerance forced]
|
||||
(loop [f 1 anchor (nth sig 0) kept (transient [0])]
|
||||
(if (>= f n)
|
||||
(persistent! kept)
|
||||
(let [s (nth sig f)]
|
||||
(if (or (contains? forced f)
|
||||
(and s (or (nil? anchor) (> (distance anchor s) tolerance))))
|
||||
(recur (inc f) (or s anchor) (conj! kept f))
|
||||
(recur (inc f) anchor kept))))))
|
||||
|
||||
(defn- segments
|
||||
"Maximal runs of consecutive measured frames. A gap is not something to compare
|
||||
across: the samples either side of an absent measurement are not neighbours."
|
||||
[n sig]
|
||||
(->> (range n)
|
||||
(partition-by #(some? (nth sig %)))
|
||||
(remove #(nil? (nth sig (first %))))))
|
||||
|
||||
(defn- turns
|
||||
"Frames in one measured segment that are a strict local extremum by more than
|
||||
`tolerance` — a full mouth closure, a full blink. These are exactly the frames
|
||||
a zero-order hold is most wrong about, and exactly what a cadence drops.
|
||||
|
||||
A run of equal samples is ONE extremum, reported at the frame it begins: the
|
||||
hold reads from there, so naming any later frame of the run would show the
|
||||
closure late. The ends of a segment are not extrema; they have only one side."
|
||||
[sig tolerance frames]
|
||||
(let [runs (vec (partition-by #(nth sig %) frames))
|
||||
at #(first (nth sig %))]
|
||||
(for [i (range 1 (dec (count runs)))
|
||||
:let [f (first (nth runs i))
|
||||
v (at f)
|
||||
p (at (first (nth runs (dec i))))
|
||||
q (at (first (nth runs (inc i))))]
|
||||
:when (and (or (and (< v p) (< v q)) (and (> v p) (> v q)))
|
||||
(> (min (js/Math.abs (- v p)) (js/Math.abs (- v q)))
|
||||
tolerance))]
|
||||
f)))
|
||||
|
||||
(defn propose
|
||||
"Frames worth keeping out of `n`, given `signal`.
|
||||
|
||||
`signal` is a vector of n samples, each a number or a seq of numbers in a fixed
|
||||
order. FRAME REMOVAL, NOT KEY EXTRACTION: every frame is a candidate and the
|
||||
question is which can be dropped, which is why this walks and holds rather than
|
||||
looking for peaks.
|
||||
|
||||
`:tolerance` is how far the signal may move before a frame has to be kept, in
|
||||
whatever units the signal is in. `:protect` is the ONE input for frames that
|
||||
have to be kept whatever the walk thought, and it carries two kinds of thing at
|
||||
once so that it never has to become two parameters:
|
||||
|
||||
:extrema keep the signal's own strict local extrema as well
|
||||
[12 30] keep these frames, whatever the walk thought
|
||||
#{:extrema 12} both
|
||||
|
||||
The frames are how one selection constrains another — every kept plate frame is
|
||||
a protected frame of the performance selection, so the drawing and the
|
||||
performance can never cut against each other on neighbouring frames — and it is
|
||||
the same mechanism that keeps a blink, which is why there is only one input.
|
||||
|
||||
`:extrema` is refused on a multi-component signal. The magnitude of a vector of
|
||||
landmarks has local maxima that mean nothing; an aperture's closure is a real
|
||||
extremum and a head has no such thing as an extreme position worth protecting.
|
||||
|
||||
Protection is settled BEFORE the walk, not unioned into its result, because a
|
||||
protected frame anchors the walk like any other kept frame — see `walk`. The
|
||||
result is therefore not `(walk) ∪ (protected)`: protected frames change what is
|
||||
kept after them, which is the whole reason they are not applied afterwards.
|
||||
|
||||
Always contains frame 0 when there is a frame at all. The result is an ascending
|
||||
vector of distinct frames, which is the shape both sites already store."
|
||||
[n signal {:keys [tolerance protect]}]
|
||||
(if-not (pos? n)
|
||||
[]
|
||||
(let [;; A slider that has been cleared reads as NaN, and every comparison
|
||||
;; against NaN is false, which would propose frame 0 alone and quietly
|
||||
;; collapse a whole performance to one pose. Nought keeps every frame
|
||||
;; that changes, which is merely the cadence back again: wrong in a way
|
||||
;; somebody can see and undo.
|
||||
tol (if (js/Number.isFinite tolerance) tolerance 0)
|
||||
sig (mapv #(sample (nth signal % nil)) (range n))
|
||||
;; One width for the whole signal, so `distance` is never comparing the
|
||||
;; prefix two samples happen to share. A reader that drops a component
|
||||
;; on one frame is a bug with a loud version and a silent version, and
|
||||
;; the silent version is a signal that is quietly the wrong shape.
|
||||
seen (into #{} (map count) (remove nil? sig))
|
||||
width (first seen)
|
||||
p (cond (nil? protect) #{}
|
||||
(keyword? protect) #{protect}
|
||||
:else (set protect))
|
||||
pins (into #{} (filter #(and (integer? %) (<= 0 %) (< % n))) p)]
|
||||
(when (< 1 (count seen))
|
||||
(throw (ex-info "signal samples are not all the same width"
|
||||
{:widths (vec (sort seen))})))
|
||||
(when (and (contains? p :extrema) width (< 1 width))
|
||||
(throw (ex-info ":protect :extrema needs a single-component signal"
|
||||
{:components width})))
|
||||
(walk n sig tol (cond-> pins
|
||||
(contains? p :extrema)
|
||||
(into (mapcat #(turns sig tol %) (segments n sig))))))))
|
||||
|
||||
(defn effective
|
||||
"`proposed` with the hand's decisions applied. Always contains 0: something has
|
||||
to be on screen at the start.
|
||||
|
||||
Manual precedence is absolute — a drop beats a proposal, always — and it is the
|
||||
proposal it beats, not the start of the take. Hand decisions apply whether or
|
||||
not the proposer is switched on, which is why they are not a mode: turning
|
||||
smart picking off must not throw away the frames somebody pinned."
|
||||
[proposed keep drop]
|
||||
(-> (reduce disj (into (set proposed) keep) drop)
|
||||
(conj 0)
|
||||
sort
|
||||
vec))
|
||||
803
frontend/src/arthur/domain/span.cljs
Normal file
803
frontend/src/arthur/domain/span.cljs
Normal file
|
|
@ -0,0 +1,803 @@
|
|||
(ns arthur.domain.span
|
||||
"The commands over a node's place in time: split it, trim an edge, move it —
|
||||
and, where the symbol holding it is drawn as a lane, re-span its siblings to
|
||||
make room.
|
||||
|
||||
A `:span` is in the node's OWN frames and its `:time` says where those land in
|
||||
its parent, and that is true of EVERY node. A clip in a sequence, a symbol
|
||||
placed straight into a shot, a shape that exists for part of one: each is a
|
||||
span in a parent's frame space, and a span in a parent's frame space is the
|
||||
whole of what these commands touch.
|
||||
|
||||
THE COORDINATE IS ALWAYS THE PARENT'S. `host-frame` reads the frame space the
|
||||
node is positioned in, whatever that is, so a caller holding a node does not
|
||||
branch on what it sits in.
|
||||
|
||||
A GROUP IS REFUSED where one node is being divided. Dividing a group means
|
||||
deciding what becomes of its children, and nothing in a span says: a span that
|
||||
narrows past a child hides it without saying so.
|
||||
|
||||
WHY THE SEQUENCE COMMANDS ARE HERE TOO. They used to be `domain/lane`, gated on
|
||||
a group with `:layout :sequence`, because a lane was the only thing anybody had
|
||||
timed. There is no such group any more — the SYMBOL is the container and a lane
|
||||
is how the timeline draws one, `symbol/lane?` — so their subject is a symbol and
|
||||
its children, which is the same subject as everything else here: one write to
|
||||
one node's span, with the siblings re-spanned around it. See
|
||||
`docs/lane-is-a-view-plan.md`.
|
||||
|
||||
THE CLAIM-TIME RULE FOLLOWS THE MODE. In lane mode, placing, moving or growing
|
||||
over occupied frames TRIMS what it lands on — trimming the incumbent, removing
|
||||
one wholly covered, splitting one it lands inside — so the result has no overlap
|
||||
because the operation that could have made one did not. Outside lane mode
|
||||
nothing is enforced, because overlapping children are what compositing IS.
|
||||
|
||||
EVERY COMMAND IS ONE STEP AND ALL OF IT. Each returns `{:clip :selection}` or
|
||||
`{:refused reason}` — never a half-applied edit, and never a document that
|
||||
`clip/problems` would reject. A command that cannot say what the person meant
|
||||
refuses and says why, rather than picking for them: the overflow policy is a
|
||||
caller's `:extent`, and decoupling shared content is its own command instead of
|
||||
something an ordinary edit does silently.
|
||||
|
||||
IDS COME FROM THE CALLER, because a clip's identity is a uuid and this namespace
|
||||
is pure. Ids for new CONTENT are derived from the drawing being copied —
|
||||
`clip/free-id` is pure too, and `drawing-a-2` says what it came from in a way
|
||||
`symbol-7` does not.
|
||||
|
||||
`finish` lives here because every command in this namespace commits through it."
|
||||
(:require [arthur.domain.bring :as bring]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.symbol :as symbol]))
|
||||
|
||||
(defn- fit-lanes
|
||||
"Make direct lane children cover `sid`'s authored window.
|
||||
|
||||
A lane has no independently authored extent: it is a view of its parent
|
||||
symbol's timeline. Keep that invariant at the one commit point that can grow
|
||||
a symbol, so neither the wrapper nor the lane symbol retains an old parent
|
||||
length."
|
||||
[clip sid]
|
||||
(let [frames (clip/frames clip sid)]
|
||||
(reduce
|
||||
(fn [c [id n]]
|
||||
(let [source (node/source n)]
|
||||
(if (and (nil? (:parent n))
|
||||
(symbol/lane? (clip/symbol c source)))
|
||||
(-> c
|
||||
(assoc-in [:symbols sid :nodes id :span] [0 frames])
|
||||
(assoc-in [:symbols sid :nodes id :time :at] 0)
|
||||
(assoc-in [:symbols source :frames] frames))
|
||||
c)))
|
||||
clip
|
||||
(get-in clip [:symbols sid :nodes]))))
|
||||
|
||||
(defn finish
|
||||
"Commit `nodes` as symbol `sid`'s, or refuse.
|
||||
|
||||
THE SHOT LENGTH IS AUTHORED. `:frames` is the symbol's window — how long the
|
||||
shot IS — and where its clips reach is a different fact derived from them. A
|
||||
command may GROW the window when the caller says `:grow-symbol`, and never
|
||||
shrinks it: emptying the end of a shot leaves a shot with empty frames at the
|
||||
end, which is a true statement about what somebody authored. Deriving the window
|
||||
from the reach instead would make deleting the last drawing silently shorten the
|
||||
film.
|
||||
|
||||
So there are two numbers and this function keeps them apart: `needed` is where
|
||||
the clips reach, `:frames` is what was authored, and the only way the second
|
||||
follows the first is a caller asking.
|
||||
|
||||
Only a symbol drawn AS A LANE is measured for reach. A node placed into a
|
||||
composition may hang off the end of it — that is an ordinary thing to author and
|
||||
the window is what crops it — whereas a lane's clips are a sequence whose length
|
||||
is the thing being edited.
|
||||
|
||||
THE OVERLAP INVARIANT IS ENFORCED HERE, and here is the only place it needs to
|
||||
be: this is the single commit path for every sequence command, it validates
|
||||
before it returns, and it refuses rather than half-applying. So no command can
|
||||
commit an overlap in lane mode, and `symbol/overlaps` turning up anything is a
|
||||
bug in a command rather than a state to design around."
|
||||
[clip sid nodes selection extent]
|
||||
(let [sym (clip/symbol clip sid)
|
||||
lane? (symbol/lane? sym)
|
||||
after (assoc sym :nodes nodes)
|
||||
needed (if-not lane?
|
||||
0
|
||||
(js/Math.ceil (apply max 0 (keep #(second (node/placed-span %))
|
||||
(symbol/children nodes)))))
|
||||
ps (symbol/problems after)
|
||||
clashing (when lane? (symbol/overlaps after))]
|
||||
(cond
|
||||
(seq ps) {:refused (first ps)}
|
||||
(seq clashing)
|
||||
{:refused (str "that would put " (pr-str (ffirst clashing)) " and "
|
||||
(pr-str (second (first clashing)))
|
||||
" on screen over the same frames of a lane")}
|
||||
(not (#{:keep :grow-symbol} extent)) {:refused "choose an explicit shot-length policy"}
|
||||
(and (> needed (:frames sym)) (= :keep extent))
|
||||
{:refused (str "the edit needs " needed " frames; extend the shot to continue")
|
||||
:required-frames needed}
|
||||
:else {:clip (fit-lanes
|
||||
(cond-> (assoc-in clip [:symbols sid :nodes] nodes)
|
||||
(> needed (:frames sym))
|
||||
(assoc-in [:symbols sid :frames] needed))
|
||||
sid)
|
||||
:selection selection})))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the geometry every edge edit is made of
|
||||
;;
|
||||
;; A `:span` is in the node's OWN frames and its `:time` says where those
|
||||
;; land in the parent. So moving an edge is one write to `:span`, and `:time`
|
||||
;; and `:playback` are untouched — which is why trimming the front of a playing
|
||||
;; insert starts it later in its source instead of resetting it, and why the two
|
||||
;; halves of a split go on meaning what the one node meant.
|
||||
;; Trim, split and `blank` are all this one operation, applied differently.
|
||||
|
||||
(defn local
|
||||
"Parent frame `f` as one of `n`'s own frames."
|
||||
[n f]
|
||||
(let [{:keys [at rate]} (node/time-of n)]
|
||||
(* rate (- f at))))
|
||||
|
||||
(defn edged
|
||||
"`n` with its `:in` or `:out` edge at parent frame `f`."
|
||||
[n which f]
|
||||
(assoc-in n [:span (case which :in 0 :out 1)] (local n f)))
|
||||
|
||||
(defn claim
|
||||
"Commit proposed `nodes`, with `id` claiming its interval in lane mode.
|
||||
|
||||
This is the one difference between editing a lane and a composition. In a
|
||||
composition it is exactly `finish`. In a lane, immediately before that same
|
||||
commit, clips covered by the edited one are removed and clips crossing either
|
||||
edge are trimmed. A clip crossing both edges is split and therefore needs a
|
||||
caller-supplied `remainder-id`."
|
||||
[clip sid nodes id extent remainder-id]
|
||||
(let [n (get nodes id)
|
||||
lane-child? (and (symbol/lane? (clip/symbol clip sid))
|
||||
n (nil? (:parent n)) (node/placed-span n))]
|
||||
(if-not lane-child?
|
||||
(finish clip sid nodes id extent)
|
||||
(let [[a b] (node/placed-span n)
|
||||
others (remove #(= id (:id %)) (symbol/children nodes))
|
||||
spanning (first (filter #(let [[lo hi] (node/placed-span %)]
|
||||
(and (< lo a) (> hi b)))
|
||||
others))]
|
||||
(if (and spanning (or (nil? remainder-id) (contains? nodes remainder-id)))
|
||||
{:refused "claiming time inside one clip needs a free ID for its remainder"}
|
||||
(finish
|
||||
clip sid
|
||||
(reduce
|
||||
(fn [ns other]
|
||||
(let [oid (:id other)
|
||||
[lo hi] (node/placed-span other)]
|
||||
(cond
|
||||
(or (<= hi a) (>= lo b)) ns
|
||||
(and (< lo a) (> hi b))
|
||||
(-> ns
|
||||
(assoc oid (edged other :out a))
|
||||
(assoc remainder-id
|
||||
(assoc (edged other :in b) :id remainder-id
|
||||
:z (str "a-" remainder-id))))
|
||||
(and (>= lo a) (<= hi b)) (dissoc ns oid)
|
||||
(< lo a) (assoc ns oid (edged other :out a))
|
||||
:else (assoc ns oid (edged other :in b)))))
|
||||
nodes others)
|
||||
id extent))))))
|
||||
|
||||
(defn host-frame
|
||||
"Symbol frame `f` as a frame of the space node `id` is POSITIONED in — its
|
||||
parent's — which is the frame space every command here takes its coordinate
|
||||
in. For a clip of a lane that is the symbol's own frames, because the symbol is
|
||||
the container and the clip has no parent. Nil through a stepped or looping
|
||||
ancestor, where one frame of the symbol is not one frame of the parent and there
|
||||
is no single answer to give."
|
||||
[clip sid id f]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])]
|
||||
(when-let [{:keys [at rate]} (symbol/frame-map nodes (:parent (get nodes id)))]
|
||||
(* rate (- f at)))))
|
||||
|
||||
(defn- subject
|
||||
"The node `id` names, as `{:node n}`, or `{:refused why}` where these commands
|
||||
have nothing to act on. The one guard they share."
|
||||
[nodes id]
|
||||
(let [n (get nodes id)]
|
||||
(cond
|
||||
(nil? n) {:refused "select something with a place in time"}
|
||||
(= :group (:kind n))
|
||||
{:refused "a group is divided by its children, not by its span"}
|
||||
(nil? (node/placed-span n))
|
||||
{:refused "this is on screen for the whole shot, so it has no edges to cut"}
|
||||
:else {:node n})))
|
||||
|
||||
(defn- siblings
|
||||
"The other clips of `sid`'s sequence, or nil where `sid` is not drawn as a lane
|
||||
and so has no sequence to re-span."
|
||||
[clip sid nodes id]
|
||||
(when (symbol/lane? (clip/symbol clip sid))
|
||||
(remove #(= id (:id %)) (symbol/children nodes))))
|
||||
|
||||
(defn split
|
||||
"Cut node `id` in two at parent frame `cut`. The left piece keeps its
|
||||
identity; the right gets `new-id`.
|
||||
|
||||
NOTHING BUT `:span` DIFFERS between the two pieces. They keep one `:time`, so
|
||||
the right piece's own frames carry on exactly where the left's stopped, and its
|
||||
source clock, its keys and its corrections therefore go on meaning what they
|
||||
meant before the cut — preserved by construction rather than by arithmetic on
|
||||
in-points that could be wrong. A held drawing holds the same frame on both
|
||||
sides; a playing insert plays on through the cut without a seam; a shape goes
|
||||
on being the same shape over each half. That is what `:span` being in the
|
||||
node's OWN coordinates buys, and it is why splitting needs no shot-length
|
||||
policy: the pieces occupy the frames the one node occupied.
|
||||
|
||||
THE RIGHT PIECE KEEPS THE ORIGINAL'S `:z`. Two halves of one thing draw at
|
||||
one depth; nothing orders them against each other, because they are never on
|
||||
screen on the same frame. A lane's clips do not consult `:z` at all —
|
||||
`symbol/children` sorts them by where they start.
|
||||
|
||||
The right piece is the selection, because it is the piece that was made."
|
||||
[clip sid id cut new-id]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])
|
||||
{:keys [node refused]} (subject nodes id)
|
||||
[lo hi] (when node (node/placed-span node))]
|
||||
(cond
|
||||
refused {:refused refused}
|
||||
(not (integer? cut)) {:refused "a cut is a whole frame"}
|
||||
(contains? nodes new-id) {:refused "the new ID is already used"}
|
||||
(not (< lo cut hi)) {:refused (str "frame " cut " is not inside this")}
|
||||
:else
|
||||
(let [nodes (-> nodes
|
||||
(assoc id (edged node :out cut))
|
||||
(assoc new-id (assoc (edged node :in cut) :id new-id)))]
|
||||
(finish clip sid nodes new-id :keep)))))
|
||||
|
||||
(defn trim
|
||||
"Move one edge of node `id` to parent frame `to`, without disturbing anything
|
||||
else at all.
|
||||
|
||||
TRIM NARROWS. Lengthening is `resize-out`, which carries a ripple policy and a
|
||||
shot-length policy because it needs them; letting trim grow as well would give
|
||||
one gesture two sets of rules and a way to overlap its neighbour. `edge` is
|
||||
`:in` or `:out`.
|
||||
|
||||
The source clock is untouched, so trimming the front of a playing insert
|
||||
starts it later INTO its animation rather than restarting it — which is the
|
||||
difference between trimming and slipping, and why they are separate commands."
|
||||
[clip sid id edge to]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])
|
||||
{:keys [node refused]} (subject nodes id)
|
||||
[lo hi] (when node (node/placed-span node))]
|
||||
(cond
|
||||
refused {:refused refused}
|
||||
(not (#{:in :out} edge)) {:refused "an edge is :in or :out"}
|
||||
(not (integer? to)) {:refused "an edge goes to a whole frame"}
|
||||
(not (< lo to hi))
|
||||
{:refused (str "frame " to " is not inside this; trim narrows it")}
|
||||
:else (finish clip sid (assoc nodes id (edged node edge to)) id :keep))))
|
||||
|
||||
(defn move
|
||||
"Put node `id` at parent frame `to`, leaving its own length, source and
|
||||
corrections alone — and, in a lane, every other clip.
|
||||
|
||||
One write to `:time :at`. A destination that would overlap a neighbour IN A
|
||||
LANE is refused rather than rippled or overwritten: moving a drawing and
|
||||
re-timing the ones around it are different intentions, and a move that
|
||||
silently pushed the rest would be the second one wearing the first one's name.
|
||||
Clear the room first — `blank` makes a gap, `trim` shortens a neighbour — or
|
||||
say you meant to claim it, which is `adopt`, what a body drag does.
|
||||
Outside lane mode there is no such rule to break: things placed in a
|
||||
composition are allowed to be on screen together, so the move simply happens."
|
||||
[clip sid id to]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])
|
||||
{:keys [node refused]} (subject nodes id)]
|
||||
(cond
|
||||
refused {:refused refused}
|
||||
(not (integer? to)) {:refused "a move goes to a whole frame"}
|
||||
:else
|
||||
(let [moved (update-in node [:time :at] (fnil + 0) (- to (first (node/placed-span node))))]
|
||||
(if (not= to (first (node/placed-span moved)))
|
||||
{:refused "timing through a stepped or looping parent is not supported"}
|
||||
(finish clip sid (assoc nodes id moved) id :keep))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the sequence: one edge edit, with the siblings re-spanned around it
|
||||
|
||||
(defn- trimmed-into-a-sequence
|
||||
"`nodes` with every clip's right edge pulled back to where the next one starts,
|
||||
and any clip the next one wholly covers removed.
|
||||
|
||||
THE SAME CLAIM-TIME RULE, APPLIED ALL AT ONCE. Later claims from earlier
|
||||
everywhere it has to, which is the rule every other command here follows one
|
||||
edit at a time; doing it as a pass is only what makes the answer to \"make this
|
||||
a lane\" one undo step."
|
||||
[nodes]
|
||||
(reduce
|
||||
(fn [ns [earlier later]]
|
||||
(let [[lo hi] (node/placed-span (get ns (:id earlier)))
|
||||
[next-lo _] (node/placed-span later)]
|
||||
(cond
|
||||
(nil? lo) ns
|
||||
(<= hi next-lo) ns
|
||||
(<= next-lo lo) (dissoc ns (:id earlier))
|
||||
:else (assoc ns (:id earlier) (edged (get ns (:id earlier)) :out next-lo)))))
|
||||
nodes
|
||||
(partition 2 1 (symbol/children nodes))))
|
||||
|
||||
(defn draw-as-lane
|
||||
"Turn symbol `sid`'s lane mode on or off. `{:clip c}` or `{:refused why}`.
|
||||
|
||||
OFF IS ALWAYS POSSIBLE: a composition has no invariant to break, so dropping
|
||||
the hint drops the rules with it and nothing in the document moves.
|
||||
|
||||
ON IS THE ONE PLACE A PERSON CAN ASK FOR THE IMPOSSIBLE. Every other command
|
||||
maintains the sequence; this one asks a symbol whose clips may already be on
|
||||
screen together to start being one, and there is no answer that does not throw
|
||||
frames away. So it refuses and says how many clips it would have to trim,
|
||||
carrying `:required-trim` for the retry the UI offers as one button — the same
|
||||
shape as `finish`'s `:required-frames`. With `trim?` it does it: later claims
|
||||
from earlier, which is the rule everything else here already follows."
|
||||
[clip sid on? {:keys [trim?]}]
|
||||
(let [sym (clip/symbol clip sid)]
|
||||
(cond
|
||||
(nil? sym) {:refused "there is no such symbol"}
|
||||
(not on?) {:clip (update-in clip [:symbols sid] dissoc :display)}
|
||||
:else
|
||||
(let [clashing (symbol/overlaps sym)]
|
||||
(cond
|
||||
(empty? clashing) {:clip (assoc-in clip [:symbols sid :display] :lane)}
|
||||
(not trim?)
|
||||
{:refused (str "drawing this as a lane means trimming "
|
||||
(count clashing) " clip"
|
||||
(when (< 1 (count clashing)) "s")
|
||||
" that overlap a neighbour")
|
||||
:required-trim (count clashing)}
|
||||
:else
|
||||
(let [nodes (trimmed-into-a-sequence (:nodes sym))
|
||||
after (assoc sym :nodes nodes :display :lane)]
|
||||
(if (seq (symbol/overlaps after))
|
||||
{:refused "these clips cannot be trimmed into a sequence"}
|
||||
{:clip (assoc-in clip [:symbols sid] after)})))))))
|
||||
|
||||
(defn resize-out
|
||||
"Put clip `id`'s right edge at parent frame `to`, allowing it to grow.
|
||||
|
||||
IN A LANE this claims time. Without `ripple?`, growing consumes the starts of
|
||||
the clips it reaches: wholly covered clips disappear and the last partially
|
||||
covered one is trimmed. Shrinking leaves a gap. With `ripple?`, every clip
|
||||
beginning at or after the old edge moves by the same delta, in either
|
||||
direction, so their contents are preserved. Either way there is no overlap,
|
||||
because the operation that could have made one did not.
|
||||
|
||||
OUTSIDE A LANE it is one write to one span and nothing else moves, because
|
||||
things placed in a composition are allowed to be on screen together. One
|
||||
gesture, and the mode says which rule it plays by."
|
||||
[clip sid id to {:keys [extent ripple?] :or {extent :keep ripple? false}}]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])
|
||||
{:keys [node refused]} (subject nodes id)
|
||||
[lo old-out] (when node (node/placed-span node))
|
||||
later (when (and node ripple?)
|
||||
(filter #(>= (first (node/placed-span %)) old-out)
|
||||
(siblings clip sid nodes id)))]
|
||||
(cond
|
||||
refused {:refused refused}
|
||||
(not (integer? to)) {:refused "an edge goes to a whole frame"}
|
||||
(not (< lo to)) {:refused "a clip must keep at least one frame"}
|
||||
(= to old-out) {:clip clip :selection id}
|
||||
:else
|
||||
(let [delta (- to old-out)
|
||||
resized (assoc nodes id (edged node :out to))
|
||||
changed (reduce (fn [ns sibling]
|
||||
(update-in ns [(:id sibling) :time :at] (fnil + 0) delta))
|
||||
resized later)]
|
||||
(claim clip sid changed id extent nil)))))
|
||||
|
||||
(defn resize-in
|
||||
"Put clip `id`'s left edge at parent frame `to`. Shrinking leaves a gap;
|
||||
in a lane, growing left consumes earlier clips symmetrically with `resize-out`."
|
||||
[clip sid id to]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])
|
||||
{:keys [node refused]} (subject nodes id)
|
||||
[old-in hi] (when node (node/placed-span node))]
|
||||
(cond
|
||||
refused {:refused refused}
|
||||
(not (integer? to)) {:refused "an edge goes to a whole frame"}
|
||||
(not (< to hi)) {:refused "a clip must keep at least one frame"}
|
||||
(neg? to) {:refused "a clip cannot begin before the shot"}
|
||||
(= to old-in) {:clip clip :selection id}
|
||||
:else (claim clip sid (assoc nodes id (edged node :in to)) id :keep nil))))
|
||||
|
||||
(defn roll
|
||||
"Move the shared boundary between adjacent clips `left-id` and `right-id`.
|
||||
This is deliberately only the composition of the two ordinary edge edits."
|
||||
[clip sid left-id right-id to]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])
|
||||
left (get nodes left-id)
|
||||
right (get nodes right-id)
|
||||
[llo lhi] (when left (node/placed-span left))
|
||||
[rlo rhi] (when right (node/placed-span right))]
|
||||
(cond
|
||||
(not (symbol/lane? (clip/symbol clip sid)))
|
||||
{:refused "a rolling edit needs a symbol drawn as a lane"}
|
||||
(not (and llo rlo (nil? (:parent left)) (nil? (:parent right))))
|
||||
{:refused "a rolling edit needs two clips of one sequence"}
|
||||
(not= lhi rlo) {:refused "a rolling edit needs one shared boundary"}
|
||||
(not (integer? to)) {:refused "a clip edge goes to a whole frame"}
|
||||
(not (< llo to rhi)) {:refused "both clips must keep at least one frame"}
|
||||
:else
|
||||
(let [left-result (resize-out clip sid left-id to {})]
|
||||
(if (:refused left-result)
|
||||
left-result
|
||||
(resize-in (:clip left-result) sid right-id to))))))
|
||||
|
||||
(defn blank
|
||||
"Clear frames `[a b)` of symbol `sid`, leaving a GAP.
|
||||
|
||||
A gap is not a drawing. Nothing is invented to cover those frames and nothing
|
||||
closes the hole — the clips after it stay where they are, because emptying
|
||||
frames and re-timing a performance are different intentions.
|
||||
|
||||
What it does to each clip it meets is `edged`, applied three ways: one wholly
|
||||
inside is removed, one overlapping an end is trimmed to it, and the one that
|
||||
spans the whole range is split, which is the only case that needs `id`. Their
|
||||
drawings stay in the library — a symbol does not own its content, and a drawing
|
||||
whose last clip is gone is still a drawing somebody made."
|
||||
[clip sid [a b] {:keys [id]}]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])
|
||||
lane? (symbol/lane? (clip/symbol clip sid))
|
||||
members (when lane? (symbol/children nodes))
|
||||
spanning (when members
|
||||
(first (filter #(let [[lo hi] (node/placed-span %)] (and (< lo a) (> hi b)))
|
||||
members)))]
|
||||
(cond
|
||||
(not lane?) {:refused "clearing a range of frames needs a symbol drawn as a lane"}
|
||||
(not (and (integer? a) (integer? b) (< a b)))
|
||||
{:refused "a range to blank is whole frames, and not empty"}
|
||||
(and spanning (or (nil? id) (contains? nodes id)))
|
||||
{:refused "blanking inside one clip splits it, which needs a free ID for the remainder"}
|
||||
:else
|
||||
(let [nodes (reduce
|
||||
(fn [ns n]
|
||||
(let [[lo hi] (node/placed-span n)]
|
||||
(cond
|
||||
(or (<= hi a) (>= lo b)) ns
|
||||
(and (< lo a) (> hi b))
|
||||
(-> ns
|
||||
(assoc (:id n) (edged n :out a))
|
||||
(assoc id (assoc (edged n :in b) :id id :z (str "a-" id))))
|
||||
(and (>= lo a) (<= hi b)) (dissoc ns (:id n))
|
||||
(< lo a) (assoc ns (:id n) (edged n :out a))
|
||||
:else (assoc ns (:id n) (edged n :in b)))))
|
||||
nodes members)]
|
||||
;; NOTHING SENSIBLE IS SELECTED by emptying frames, so nothing is: a
|
||||
;; split names its remainder, and otherwise the caller keeps whatever was
|
||||
;; selected rather than being handed a clip it did not ask for.
|
||||
(finish clip sid nodes (when spanning id) :keep)))))
|
||||
|
||||
(defn extend-hold
|
||||
"Change one held clip's duration by `delta` frames and ripple its later
|
||||
siblings. Keys, source clocks and the clips' own channels stay put.
|
||||
Returns {:clip :selection} or {:refused :required-frames?}; never partially edits."
|
||||
[clip sid id delta {:keys [extent] :or {extent :keep}}]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])
|
||||
n (get nodes id)
|
||||
rate (:rate (node/time-of n))
|
||||
span (:span n)]
|
||||
(cond
|
||||
(not (symbol/lane? (clip/symbol clip sid)))
|
||||
{:refused "a hold is lengthened in a symbol drawn as a lane"}
|
||||
(nil? (node/placed-span n)) {:refused "select a clip in the lane"}
|
||||
(some? (:parent n)) {:refused "select a clip of the lane itself"}
|
||||
(not (and (integer? delta) (not (zero? delta)))) {:refused "hold change must be a nonzero whole number of frames"}
|
||||
(not (zero? (:speed (node/playback-of n)))) {:refused "hold length applies to a held drawing"}
|
||||
(<= (+ (second span) (* rate delta)) (first span)) {:refused "a drawing must keep a positive exposure"}
|
||||
:else
|
||||
(let [[_ boundary] (node/placed-span n)
|
||||
later (filter #(>= (first (node/placed-span %)) boundary)
|
||||
(siblings clip sid nodes id))
|
||||
nodes (assoc-in nodes [id :span 1] (+ (second span) (* rate delta)))
|
||||
nodes (reduce (fn [ns sibling]
|
||||
(update-in ns [(:id sibling) :time :at] (fnil + 0) delta))
|
||||
nodes later)]
|
||||
(finish clip sid nodes id extent)))))
|
||||
|
||||
(defn placement-refusal
|
||||
"Why direct child `n` may not be placed in symbol `sid`, or nil.
|
||||
|
||||
This is the type-containment boundary for every placement path. Specialized
|
||||
symbols are confined here rather than by drag affordances, so pool drops,
|
||||
transfers, structural nesting and future commands cannot disagree."
|
||||
[clip sid n]
|
||||
(let [destination (clip/symbol clip sid)
|
||||
source (when (= :instance (:kind n)) (clip/symbol clip (node/source n)))
|
||||
destination-type (:type destination)
|
||||
source-type (:type source)]
|
||||
(cond
|
||||
(= :palette destination-type) "a palette symbol cannot contain nodes"
|
||||
(and (= :palette-track destination-type) (not= :palette source-type))
|
||||
"a palette track accepts only palette symbols"
|
||||
(and (= :palette source-type) (not= :palette-track destination-type))
|
||||
"a palette symbol can be placed only in a palette track"
|
||||
:else nil)))
|
||||
|
||||
(defn place-node
|
||||
"Place the already-materialized, direct child `n` into symbol `sid` at `at`.
|
||||
|
||||
THIS IS THE PLACEMENT RULE. Materializing a symbol instance, an audio node, or
|
||||
imported content is deliberately somebody else's job; once it is a node, its
|
||||
origin no longer matters. The destination alone decides the edit:
|
||||
|
||||
- an ordinary symbol attaches it and permits overlap;
|
||||
- a lane symbol first clears the interval it claims.
|
||||
|
||||
The caller hands this function a node not currently present in the destination.
|
||||
Its duration, channels, source clock and identity are preserved; only its
|
||||
parent-space start changes. Returns the ordinary command result shape."
|
||||
[clip sid n at {:keys [extent remainder-id] :or {extent :keep}}]
|
||||
(let [sym (clip/symbol clip sid)
|
||||
nodes (:nodes sym)
|
||||
id (:id n)
|
||||
[lo hi] (when n (node/placed-span n))
|
||||
duration (when (and lo hi) (- hi lo))
|
||||
incompatible (when (and sym n) (placement-refusal clip sid n))]
|
||||
(cond
|
||||
(nil? sym) {:refused "there is no destination symbol"}
|
||||
(nil? n) {:refused "there is no clip to place"}
|
||||
incompatible {:refused incompatible}
|
||||
(contains? nodes id) {:refused "the destination already uses that clip ID"}
|
||||
(some? (:parent n)) {:refused "only a direct child can be placed in a symbol"}
|
||||
(not (and (integer? at) (not (neg? at))))
|
||||
{:refused "a position is a nonnegative whole frame"}
|
||||
(not (pos? duration)) {:refused "the clip has no frames to place"}
|
||||
(and remainder-id (contains? nodes remainder-id))
|
||||
{:refused "the remainder clip needs a free ID"}
|
||||
:else
|
||||
(let [placed (update-in n [:time :at] (fnil + 0) (- at lo))]
|
||||
(claim clip sid (assoc nodes id placed) id extent remainder-id)))))
|
||||
|
||||
(defn place-symbol
|
||||
"Materialize an instance of `source-id`, then place it into `sid` at `at`.
|
||||
|
||||
IN A LANE the new clip claims its interval: existing clips under it are
|
||||
trimmed, removed, or split, so the sequence stays a partition rather than
|
||||
storing an overlap. Outside one it is simply placed. This is the generic
|
||||
operation behind dropping a library symbol into the timeline; one-frame held
|
||||
drawing creation remains a policy of `append-drawing`/`overwrite-drawing`,
|
||||
not a different kind of container."
|
||||
[clip store sid id source-id at
|
||||
{:keys [extent point remainder-id] :or {extent :keep}}]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])
|
||||
seeded (clip/place-symbol clip store sid source-id 0 id point)
|
||||
n (get-in seeded [:symbols sid :nodes id])]
|
||||
(cond
|
||||
(contains? nodes id) {:refused "the new clip ID is already used"}
|
||||
(nil? (clip/symbol clip source-id)) {:refused "there is no such symbol to place"}
|
||||
(nil? n) {:refused "a symbol cannot go inside itself"}
|
||||
:else
|
||||
(place-node clip sid n at {:extent extent :remainder-id remainder-id}))))
|
||||
|
||||
(defn adopt
|
||||
"Move an existing clip of `sid` to frame `at`, CLAIMING the time it lands on.
|
||||
|
||||
Its source, span, transforms, corrections, and identity come with it; in a lane
|
||||
the destination interval is claimed with the same trimming as a pool drop. This
|
||||
is what a body drag does, and it is why `move` and this are two commands:
|
||||
`move` refuses to disturb a neighbour, and a drag onto occupied time has
|
||||
already said it means to."
|
||||
[clip sid id at opts]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])
|
||||
n (get nodes id)]
|
||||
(cond
|
||||
(nil? n) {:refused "select a clip to move"}
|
||||
(some? (:parent n)) {:refused "only a clip of the symbol itself is placed in its sequence"}
|
||||
:else
|
||||
(place-node (assoc-in clip [:symbols sid :nodes] (dissoc nodes id))
|
||||
sid n at opts))))
|
||||
|
||||
(defn transfer
|
||||
"Move direct child `id` from `from-sid` into `to-sid` at `at`.
|
||||
|
||||
Cross-symbol and same-symbol moves are deliberately the same composition:
|
||||
detach, then `place-node`. The destination's mode—not the gesture, source, or
|
||||
payload kind—decides whether occupied time is claimed."
|
||||
[clip from-sid id to-sid at opts]
|
||||
(let [from-nodes (get-in clip [:symbols from-sid :nodes])
|
||||
n (get from-nodes id)]
|
||||
(cond
|
||||
(nil? n) {:refused "select a clip to move"}
|
||||
(some? (:parent n)) {:refused "only a direct child can move between symbols"}
|
||||
:else
|
||||
(let [detached (assoc-in clip [:symbols from-sid :nodes] (dissoc from-nodes id))
|
||||
result (place-node detached to-sid n at opts)]
|
||||
(if-let [made (:clip result)]
|
||||
(if-let [why (first (clip/problems made))]
|
||||
{:refused why}
|
||||
result)
|
||||
result)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; putting drawings in a sequence
|
||||
|
||||
(defn- held
|
||||
"A one-frame held clip of `drawing-id`, starting at frame `at`.
|
||||
|
||||
Held rather than playing, and one frame rather than the length of what it
|
||||
places: a clip's duration is the sequence's business — `extend-hold` and
|
||||
`resize-out` are how it changes — and reading it off the content would make
|
||||
placing a ten-frame animation and holding its first drawing the same gesture.
|
||||
|
||||
NO PIVOT, unlike `clip/place-symbol`, and the difference is whether the content
|
||||
EXISTS YET. Dropping a symbol onto the stage places a drawing somebody can see,
|
||||
so the middle of it is known and is stored as the instance's pivot; a cel is
|
||||
made to be drawn in, and the middle of an empty drawing is nothing to commit to.
|
||||
So a cel's pivot stays unchosen until a turn or a scale chooses it, which is
|
||||
`gesture/with-pivot`, from the bounds the drawing has by then."
|
||||
[id drawing-id at]
|
||||
{:id id :kind :instance :z (str "a-" id)
|
||||
:span [0 1] :time {:at at :rate 1}
|
||||
:source {:symbol drawing-id} :playback {:in 0 :speed 0 :end :stop}})
|
||||
|
||||
(defn- sequence-end
|
||||
"Where `sid`'s occupied frames stop."
|
||||
[nodes]
|
||||
(apply max 0 (map #(second (node/placed-span %)) (symbol/children nodes))))
|
||||
|
||||
(defn- place
|
||||
"Put a held clip of `drawing-id` into `sid` at frame `at`, and RIPPLE:
|
||||
everything starting at or after it moves later by its duration.
|
||||
|
||||
There is one placement function and `:end` is a position like any other, so
|
||||
appending is not a different operation from inserting — the end is just where
|
||||
nothing has to move. Overwriting is the other policy and is NOT this: taking
|
||||
frames away from the clip already there is trimming, which is its own command
|
||||
and not something placing a drawing should do on the quiet.
|
||||
|
||||
`:frame` in the result is where it landed, in the symbol's own frames, for a
|
||||
caller that wants to look at what it just made."
|
||||
[clip sid id drawing-id at extent ripple?]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])
|
||||
at (if (= :end at) (sequence-end nodes) at)
|
||||
n (held id drawing-id at)
|
||||
[lo hi] (node/placed-span n)
|
||||
;; RIPPLE FOLLOWS THE MODE. Pushing later siblings is what inserting
|
||||
;; into a sequence means; in a composition there is no "later sibling"
|
||||
;; to push, because being on screen together is the point.
|
||||
later (when (and ripple? (symbol/lane? (clip/symbol clip sid)))
|
||||
(filter #(>= (first (node/placed-span %)) lo) (symbol/children nodes)))
|
||||
nodes (reduce (fn [ns sibling]
|
||||
(update-in ns [(:id sibling) :time :at] (fnil + 0) (- hi lo)))
|
||||
(assoc nodes id n) later)
|
||||
result (finish clip sid nodes id extent)]
|
||||
(cond-> result
|
||||
(:clip result) (assoc :frame at))))
|
||||
|
||||
(defn- placeable
|
||||
"Why a held clip cannot go into `sid` at `at`, or nil."
|
||||
[clip sid id at]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])
|
||||
;; INSIDE a clip is not a position for another one, IN A LANE. Splitting
|
||||
;; that clip is what makes it two, and doing it here would be one command
|
||||
;; quietly performing two: the caller asks for `split` and then places.
|
||||
;; In a composition landing inside something is not a collision at all.
|
||||
inside (when (and (number? at) (symbol/lane? (clip/symbol clip sid)))
|
||||
(some (fn [n] (let [[lo hi] (node/placed-span n)]
|
||||
(when (< lo at hi) n)))
|
||||
(symbol/children nodes)))]
|
||||
(cond
|
||||
(contains? nodes id) "the new clip ID is already used"
|
||||
(not (or (= :end at) (and (integer? at) (not (neg? at)))))
|
||||
"a position is :end or a whole frame"
|
||||
inside (str "frame " at " is inside a clip; split it first"))))
|
||||
|
||||
(defn append-drawing
|
||||
"Append fresh empty content and a held clip of it. IDs come from the
|
||||
caller so a command is deterministic and replayable.
|
||||
|
||||
Fresh content, not a blank range: a sequence with no clip over a frame shows
|
||||
nothing there already, and a drawing nobody has drawn in is a different thing
|
||||
from a gap."
|
||||
[clip sid id drawing-id {:keys [at extent] :or {extent :keep at :end}}]
|
||||
(if-let [why (or (placeable clip sid id at)
|
||||
(when (clip/symbol clip drawing-id) "the new drawing ID is already used"))]
|
||||
{:refused why}
|
||||
(place (assoc-in clip [:symbols drawing-id]
|
||||
{:id drawing-id :name (name drawing-id) :fps (clip/fps clip sid) :frames 1 :nodes {}})
|
||||
sid id drawing-id at extent true)))
|
||||
|
||||
(defn reuse-drawing
|
||||
"Append a held clip of content the document ALREADY has, so the same
|
||||
drawing is exposed twice and editing it changes both clips.
|
||||
|
||||
This is the command `make-unique` is the undo of, and the reason they are two
|
||||
commands: reuse is a decision to share, and sharing is not something to
|
||||
discover later when an edit turns up somewhere else."
|
||||
[clip sid id drawing-id {:keys [at extent] :or {extent :keep at :end}}]
|
||||
(if-let [why (or (placeable clip sid id at)
|
||||
(when-not (clip/symbol clip drawing-id) "there is no such drawing to reuse")
|
||||
;; Placing something that contains this symbol would close a
|
||||
;; loop, and a sequence is no different from any other placement.
|
||||
(when (clip/contains-symbol? clip drawing-id sid)
|
||||
"a symbol cannot go inside itself"))]
|
||||
{:refused why}
|
||||
(place clip sid id drawing-id at extent true)))
|
||||
|
||||
(defn- copied
|
||||
"A copy of symbol `from`, as `{:clip :id}`.
|
||||
|
||||
SHALLOW by default: its own nodes and channels are copied, and its references
|
||||
to other symbols are kept, so a head built out of reusable eyes still uses
|
||||
those eyes. `deep?` copies everything it places as well, with new ids
|
||||
throughout, for a drawing that must share nothing — the distinction the
|
||||
shallow copy cannot make on its own, and a promise of independence that only
|
||||
the deep one keeps."
|
||||
[clip from deep?]
|
||||
(if deep?
|
||||
(let [{c :clip ids :ids} (bring/symbols clip clip [from] {})]
|
||||
{:clip c :id (ids from)})
|
||||
(let [id (clip/free-id (:symbols clip) from)]
|
||||
{:clip (assoc-in clip [:symbols id] (assoc (clip/symbol clip from) :id id))
|
||||
:id id})))
|
||||
|
||||
(defn duplicate-drawing
|
||||
"Append a held clip of a COPY of what clip `id` places, for when
|
||||
the drawing on screen is the starting point for the next one.
|
||||
|
||||
The copy is of the content only. The new clip is a plain one-frame hold
|
||||
rather than a copy of `id`'s own transform or corrections: those belong to
|
||||
that clip, and carrying them over would make duplicating a drawing quietly
|
||||
duplicate the treatment of one use of it."
|
||||
[clip sid id new-id {:keys [at extent deep?] :or {extent :keep at :end}}]
|
||||
(let [n (get-in clip [:symbols sid :nodes id])
|
||||
from (node/source n)]
|
||||
(if-let [why (or (when-not from "select a clip to duplicate")
|
||||
(when-not (clip/symbol clip from) "the drawing it places is missing")
|
||||
(placeable clip sid new-id at))]
|
||||
{:refused why}
|
||||
(let [{c :clip copy :id} (copied clip from deep?)]
|
||||
(place c sid new-id copy at extent true)))))
|
||||
|
||||
(defn overwrite-drawing
|
||||
"Put a fresh one-frame drawing at frame `at`, replacing whatever was there and
|
||||
leaving every other clip where it was.
|
||||
|
||||
This is `blank` and placement composed in ONE command and therefore one undo
|
||||
step. `remainder-id` is used only when clearing the frame cuts one clip into
|
||||
two; ids still come from the caller because this namespace is pure."
|
||||
[clip sid id drawing-id at {:keys [extent remainder-id] :or {extent :keep}}]
|
||||
(let [nodes (get-in clip [:symbols sid :nodes])]
|
||||
(if-let [why (cond
|
||||
(not (and (integer? at) (not (neg? at))))
|
||||
"a position is a nonnegative whole frame"
|
||||
(contains? nodes id) "the new clip ID is already used"
|
||||
(or (= id remainder-id) (contains? nodes remainder-id))
|
||||
"the remainder clip needs a free ID different from the new clip"
|
||||
(clip/symbol clip drawing-id) "the new drawing ID is already used")]
|
||||
{:refused why}
|
||||
(let [c (assoc-in clip [:symbols drawing-id]
|
||||
{:id drawing-id :name (name drawing-id)
|
||||
:fps (clip/fps clip sid) :frames 1 :nodes {}})
|
||||
nodes (assoc nodes id (held id drawing-id at))]
|
||||
(claim c sid nodes id extent remainder-id)))))
|
||||
|
||||
(defn make-unique
|
||||
"Point clip `id` at a private copy of its content, leaving every other
|
||||
clip of that drawing sharing the original.
|
||||
|
||||
Refused when nothing else uses it: a drawing with one clip is already
|
||||
unique, and answering with a silent copy would leave a second identical symbol
|
||||
in the library for no reason a person could see."
|
||||
[clip sid id {:keys [deep?]}]
|
||||
(let [n (get-in clip [:symbols sid :nodes id])
|
||||
from (node/source n)
|
||||
elsewhere (for [[osid osym] (:symbols clip)
|
||||
[oid on] (:nodes osym)
|
||||
:when (and (= from (node/source on)) (not= [sid id] [osid oid]))]
|
||||
[osid oid])]
|
||||
(if-let [why (or (when-not from "select a clip to make unique")
|
||||
(when-not (clip/symbol clip from) "the drawing it places is missing")
|
||||
(when (empty? elsewhere) "nothing else uses this drawing"))]
|
||||
{:refused why}
|
||||
(let [{c :clip copy :id} (copied clip from deep?)
|
||||
c (assoc-in c [:symbols sid :nodes id :source :symbol] copy)
|
||||
ps (clip/problems c)]
|
||||
(if (seq ps) {:refused (first ps)} {:clip c :selection id})))))
|
||||
866
frontend/src/arthur/domain/symbol.cljs
Normal file
866
frontend/src/arthur/domain/symbol.cljs
Normal file
|
|
@ -0,0 +1,866 @@
|
|||
(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. 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 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 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 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 sym f store palette opts)
|
||||
THE SPECIFICATION. Allocating, order-free,
|
||||
obviously correct. Use it in tests and for a
|
||||
one-off render.
|
||||
|
||||
(resolver sym store palette opts)
|
||||
-> (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
|
||||
maps and nothing else.
|
||||
|
||||
Both run the same walk — `eval-into` below — parameterised by how a channel is
|
||||
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 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:
|
||||
ops carry palette indices and raster-space points, and the rasteriser knows
|
||||
nothing about nodes, channels or time.
|
||||
|
||||
Geometry is stored FLAT — [x0 y0 x1 y1 …] — in authored channels as well as
|
||||
dense ones. A dense block is a rectangular Int16Array and an authored ring is a
|
||||
vector of numbers, and they read the same way, which is what makes freezing
|
||||
fill in the same channel rather than convert into a second format."
|
||||
(:require [arthur.domain.channel :as ch]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.pose :as pose]
|
||||
[arthur.domain.palette :as pal]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; structure: depth, topological order, draw order
|
||||
|
||||
(defn lineage
|
||||
"The node's id and every ancestor's, nearest first and root last.
|
||||
|
||||
One walk, shared by `depth` and `z-path`, which otherwise duplicate it.
|
||||
|
||||
A cycle is caught by LENGTH rather than by a `seen` set: a chain that does not
|
||||
repeat cannot be longer than the number of nodes, so one step past that is
|
||||
proof of a loop and needs no bookkeeping. Caught rather than hung — a cycle is
|
||||
reachable from one bad `:node/set-parent`, and a hung tab is a far worse
|
||||
diagnostic than a stack trace naming the nodes.
|
||||
|
||||
THE PROOF ONLY HOLDS FOR A NODE THIS SYMBOL HAS. An id that is not in `nodes`
|
||||
contributes a step the count knows nothing about, and its lineage is just
|
||||
itself; in an EMPTY symbol that one step used to be read as a loop, so asking
|
||||
where a node of another symbol sits threw `parent cycle` instead of answering
|
||||
that it sits nowhere."
|
||||
[nodes id]
|
||||
(let [up (fn [i]
|
||||
(when-let [p (:parent (get nodes i))]
|
||||
(if (contains? nodes p)
|
||||
p
|
||||
(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 (and (contains? nodes id) (> (count chain) (count nodes)))
|
||||
(throw (ex-info "parent cycle in symbol" {:node id :chain chain})))
|
||||
chain))
|
||||
|
||||
(defn depth
|
||||
"Number of ancestors."
|
||||
[nodes id]
|
||||
(dec (count (lineage nodes id))))
|
||||
|
||||
(defn children
|
||||
"The symbol's own clips — the nodes placed directly in it — in timeline order.
|
||||
|
||||
THE SYMBOL IS THE CONTAINER. There is no lane node to ask for its members: a
|
||||
symbol drawn as a lane draws THESE, and the sequence commands re-span THESE.
|
||||
See `docs/lane-is-a-view-plan.md`.
|
||||
|
||||
Sorted by where they START, not by `:z`: blocks in a sequence follow one
|
||||
another in time, and two of them cannot be in the same place for `:z` to
|
||||
decide between. Ties go to the id so the order is the same on every run.
|
||||
|
||||
A NODE WITH NO SPAN IS NOT IN THE SEQUENCE. A shape on screen for the whole
|
||||
shot has no `[in out)` to follow anything else, so it is not something an edge
|
||||
edit can trim or ripple, and a command that destructured its nil span would
|
||||
fail on the most ordinary node there is."
|
||||
[nodes]
|
||||
(->> (vals nodes)
|
||||
(filter #(and (nil? (:parent %)) (node/placed-span %)))
|
||||
(sort-by (juxt #(first (node/placed-span %)) #(str (:id %))))
|
||||
vec))
|
||||
|
||||
(defn frame-map
|
||||
"Node `id`'s own frames as a map from the containing symbol's, inverted:
|
||||
`{:at a :rate r}`, meaning symbol frame `p` is frame `r·(p − a)` of `id`.
|
||||
Identity for `nil`, which is a node sitting directly in the symbol.
|
||||
|
||||
THE FRAME SPACE A COMMAND IS GIVEN ITS COORDINATE IN. A direct child reads
|
||||
the symbol's own frames. A node grouped beneath another composes through that
|
||||
parent chain. One rule either way, so a caller holding a node does not have to
|
||||
ask what it is sitting in or how the timeline happens to draw the symbol.
|
||||
|
||||
Refuses floors and loops rather than pretend an affine map preserves them:
|
||||
through either, one frame of the symbol is not one frame of `id` and a command
|
||||
handed a single frame has no single answer to give."
|
||||
[nodes id]
|
||||
(loop [id id seen #{} chain []]
|
||||
(if (nil? id)
|
||||
(reduce node/then-time {:at 0 :rate 1} (map node/time-of (reverse chain)))
|
||||
(let [n (get nodes id) t (:time n)]
|
||||
(when (and n (not (contains? seen id))
|
||||
(not (:loop? t))
|
||||
(<= (or (:expose t) 1) 1)
|
||||
(empty? (:holds t)))
|
||||
(recur (:parent n) (conj seen id) (conj chain n)))))))
|
||||
|
||||
(defn lane?
|
||||
"Whether symbol `sym` is EDITED AND DRAWN as a lane: its clips as blocks on
|
||||
one row, following one another in time and claiming it from each other.
|
||||
|
||||
A DISPLAY HINT AND NOT A TYPE. Nothing in evaluation reads it, `problems` does
|
||||
not check it, and a symbol carrying it behaves identically on the stage — it
|
||||
says how the timeline draws the symbol and, because the editing rules follow
|
||||
the mode, which rules an edge drag inside it plays by. It is on the symbol
|
||||
rather than in editor state so that those rules are reproducible between two
|
||||
people looking at one document, and it is a field rather than something derived
|
||||
from \"the clips do not currently overlap\" because a symbol must not stop being
|
||||
a lane the moment something overlaps — that is when the rules are needed.
|
||||
|
||||
Editing and presentation may read it; evaluation may not. See
|
||||
`docs/lane-is-a-view-plan.md`."
|
||||
[sym]
|
||||
(= :lane (:display sym)))
|
||||
|
||||
(defn overlaps
|
||||
"The pairs of `sym`'s clips that are on screen over the same frames, as
|
||||
`[[a b] ...]` of ids. Empty for a symbol whose clips form a sequence.
|
||||
|
||||
A BUG REPORT, NOT A CONDITION TO DESIGN AROUND. In lane mode this cannot
|
||||
happen: placing claims time, so anything placed, moved or grown over occupied
|
||||
frames TRIMS what it lands on, and `span/finish` — the one commit path for
|
||||
every sequence command — refuses rather than committing one. So an overlap that
|
||||
appears anyway is a defect in a command.
|
||||
|
||||
Which is why it is NOT `clip/problems`, which means the document will not load:
|
||||
a display hint must never be able to stop a document loading, and a document
|
||||
that somehow arrives holding an overlap still opens and is drawn visibly wrong.
|
||||
Nor `clip/conflicts`, which means a person has a decision to make. This is
|
||||
neither."
|
||||
[sym]
|
||||
(let [spans (for [n (children (:nodes sym))
|
||||
:let [[lo hi] (node/placed-span n)]
|
||||
:when (and (node/finite-number? lo) (node/finite-number? hi))]
|
||||
[lo hi (:id n)])]
|
||||
(vec (for [[[_ b x] [c _ y]] (partition 2 1 (sort-by (juxt first second str) spans))
|
||||
:when (> b c)]
|
||||
[x y]))))
|
||||
|
||||
(defn order
|
||||
"Node ids in topological order: every node after its parent.
|
||||
|
||||
Sorting by parent depth is enough — it does not need Kahn's algorithm, because
|
||||
the only edge is parent, and a node's depth is by definition greater than its
|
||||
parent's. Ties are broken by id so the order is deterministic across runs,
|
||||
which matters because the draw-order sort below falls back on this position."
|
||||
[nodes]
|
||||
(vec (sort-by (juxt #(depth nodes %) str) (keys nodes))))
|
||||
|
||||
(defn z-path
|
||||
"The node's z index and every ancestor's, root first.
|
||||
|
||||
Draw order is depth-first by sibling z, so the key that sorts it is the chain
|
||||
of z values from the root. A parent's path is a PREFIX of its child's, which is
|
||||
why a parent draws before its children without that being a special case.
|
||||
|
||||
`:z` values are fractional-index STRINGS (\"a1\", \"a3\") and compare
|
||||
lexicographically, so a node can always be inserted between two siblings
|
||||
without renumbering either."
|
||||
[nodes id]
|
||||
(mapv #(:z (get nodes %)) (rseq (lineage nodes id))))
|
||||
|
||||
(defn z-between
|
||||
"A `:z` that sorts strictly between `a` and `b`, which must be in order; nil
|
||||
for either is no bound on that side. What makes restacking one write.
|
||||
|
||||
The midpoint of the first character they differ in, when there is room. When
|
||||
there is not, anything that starts with `a` and is longer sorts after it, and
|
||||
before `b` too unless `a` is a prefix of `b` — and then the room is found one
|
||||
character further into `b`. \"0\" is the floor, and nothing this makes ends
|
||||
in it, so there is always a further character to go to; only an authored key
|
||||
ending in \"0\" leaves none, and then one character less than it does."
|
||||
[a b]
|
||||
(let [a (or a "")]
|
||||
(if (nil? b)
|
||||
(str a "m")
|
||||
(let [i (count (take-while true? (map = a b)))
|
||||
hi (.charCodeAt b i)
|
||||
lo (if (< i (count a)) (.charCodeAt a i) 48)
|
||||
mid (quot (+ lo hi) 2)]
|
||||
(cond
|
||||
(> mid lo) (str (subs b 0 i) (char mid))
|
||||
(< i (count a)) (str a "m")
|
||||
(< (inc i) (count b)) (str (subs b 0 (inc i)) (z-between nil (subs b (inc i))))
|
||||
:else (str (subs b 0 i) (char (dec hi)) "m"))))))
|
||||
|
||||
(defn- z-lex
|
||||
"Lexicographic compare of two z paths, a prefix sorting first.
|
||||
|
||||
`compare` on vectors will not do: it compares COUNT first, so a deep
|
||||
descendant of \"a1\" would sort after a shallow \"a2\" and a painted cel would
|
||||
jump in front of the head that carries it.
|
||||
|
||||
`map` over two collections stops at the shorter and `first` short-circuits at
|
||||
the first difference, so this walks no further than it has to."
|
||||
[a b]
|
||||
(or (first (remove zero? (map compare a b)))
|
||||
(- (count a) (count b))))
|
||||
|
||||
(defn draw-rank
|
||||
"id -> its position in draw order.
|
||||
|
||||
Computed ONCE. Draw order is a function of the z paths, which are structural —
|
||||
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 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."
|
||||
[nodes ord]
|
||||
(let [paths (into {} (map (juxt identity #(z-path nodes %))) ord)]
|
||||
(into {} (map-indexed (fn [i id] [id i])) (sort-by paths z-lex ord))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; colour
|
||||
|
||||
(defn colour-index
|
||||
"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 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 symbols carry a
|
||||
`:palette` channel, the walk carries the palette in scope exactly as it already
|
||||
carries the parent transform and the local frame.
|
||||
|
||||
An unknown tone resolves to 255, which the palette expansion renders MAGENTA.
|
||||
Loud rather than fatal, and the same choice raster/->rgba already makes:
|
||||
naming a colour the ramp does not have is a bug in authored data, and it should
|
||||
be impossible to miss and should not take the frame down."
|
||||
[palette k]
|
||||
(cond
|
||||
(fn? palette) (palette k)
|
||||
(number? k) k
|
||||
(nil? k) 255
|
||||
:else (get palette k 255)))
|
||||
|
||||
(defn knockout?
|
||||
"Is colour `c` a KNOCKOUT rather than a tone? `:clear` clears every colour
|
||||
beneath it in its symbol, `[:clear tone]` clears that tone only. See
|
||||
`raster/plot!`."
|
||||
[c]
|
||||
(or (= :clear c) (and (vector? c) (= :clear (first c)))))
|
||||
|
||||
(defn remap?
|
||||
"Is colour `c` a REMAP: `[:remap {from to …}]`, slots of the palette in
|
||||
scope? It draws nothing of its own; what is already under it is drawn in
|
||||
other slots — a flashlight is a circle of this. See `raster/plot!`."
|
||||
[c]
|
||||
(and (vector? c) (= :remap (first c))))
|
||||
|
||||
(defn- knock-index [palette c]
|
||||
(if (= :clear c) -1 (colour-index palette (second c))))
|
||||
|
||||
(defn lut
|
||||
"Remap `[:remap m]` as a raster index -> index table in `palette`. Slots of
|
||||
any other palette are left as they are."
|
||||
[palette [_ m]]
|
||||
(let [t (js/Uint8Array. 256)]
|
||||
(dotimes [i 256] (aset t i i))
|
||||
(doseq [[a b] m] (aset t (colour-index palette a) (colour-index palette b)))
|
||||
t))
|
||||
|
||||
(defn knocks?
|
||||
"Does any node in `nodes` knock out, on any frame? Such a symbol is drawn into
|
||||
a layer of its own."
|
||||
[nodes]
|
||||
(some (fn [[_ n]]
|
||||
(let [c (get-in n [:channels [:style :color]])]
|
||||
(some knockout? (cons (:value c) (vals (:keys c))))))
|
||||
nodes))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the walk
|
||||
|
||||
(defn- in-span?
|
||||
"`:span` is Lottie's ip/op and Flash's PlaceObject/RemoveObject: the range over
|
||||
which the node EXISTS, tested in the PARENT's frame space and therefore before
|
||||
the node's own time map runs — an instance's own-time span is mapped out by
|
||||
`node/placed-span`. Distinct from `[:vis]`, which blinks an existing node on and
|
||||
off. Half-open, so two adjacent spans do not both own a frame."
|
||||
[n f]
|
||||
(if-let [[in out] (node/placed-span n)]
|
||||
(and (>= f in) (< f out))
|
||||
true))
|
||||
|
||||
(defn- finish
|
||||
"Resolve stencils, then sort into draw order.
|
||||
|
||||
A stencil is a COLOUR KEY, not a node reference: it is 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 without a per-part mask. So the
|
||||
stencil node's own colour is looked up here, after the walk, because the
|
||||
stencil may sit anywhere in the order. Two nodes sharing a palette entry share
|
||||
a stencil, which is inherent to the technique rather than a defect in it.
|
||||
|
||||
A node stencilled by something that drew NOTHING is DROPPED, not drawn
|
||||
unclipped: unclipped would be an iris floating over the cheek on exactly the
|
||||
frames where the eye is missing."
|
||||
[rank ops]
|
||||
(let [by-id (into {} (map (juxt :node :color)) ops)]
|
||||
(->> ops
|
||||
(keep (fn [op]
|
||||
(if-let [s (:stencil op)]
|
||||
(when-let [idx (get by-id s)]
|
||||
(assoc op :stencil idx))
|
||||
op)))
|
||||
(sort-by (comp rank :node))
|
||||
vec)))
|
||||
|
||||
(defn- n-points
|
||||
"Points in a flat [x0 y0 x1 y1 …] value, authored vector or dense view alike."
|
||||
[pts]
|
||||
(quot (if (vector? pts) (count pts) (.-length pts)) 2))
|
||||
|
||||
(defn- xform-at
|
||||
"The five transform components at the node's local frame, or nil when any of
|
||||
them has no value on it.
|
||||
|
||||
The pivot is read like the rest and is not special-cased to a default here:
|
||||
`node/channels` has already filled in `[0 0]` for a node that stores none, and
|
||||
a node whose pivot is absent on a frame it is otherwise on has the same nothing
|
||||
to be drawn at as one whose position is."
|
||||
[rd]
|
||||
(let [pos (rd [:xform :pos])
|
||||
piv (rd [:xform :pivot])
|
||||
rot (rd [:xform :rot])
|
||||
scl (rd [:xform :scale])
|
||||
skw (rd [:xform :skew])]
|
||||
(when-not (or (ch/nothing? pos) (ch/nothing? piv) (ch/nothing? rot)
|
||||
(ch/nothing? scl) (ch/nothing? skw))
|
||||
[pos piv rot scl skw])))
|
||||
|
||||
(defn- visible?
|
||||
"Is the node switched on this frame?
|
||||
|
||||
`[:vis]` IS A BOOLEAN, and this insists on it rather than testing truthiness,
|
||||
because the two obvious implementations are both wrong about a DENSE `[:vis]`.
|
||||
A dense block yields 0 or 1, and 0 is TRUTHY in CLJS — so `(if v …)` shows a
|
||||
hidden frame, and `(true? v)` hides every frame. Neither reads as an error.
|
||||
|
||||
docs/animation-model.md's parts table says `:mouth-in` carries `[:vis]` dense;
|
||||
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 symbol.
|
||||
|
||||
Absence is not a boolean and is not an error: a subject that is not on the
|
||||
frame has nothing to show."
|
||||
[id v]
|
||||
(cond
|
||||
(true? v) true
|
||||
(false? v) false
|
||||
(ch/nothing? v) false
|
||||
:else (throw (ex-info "[:vis] must sample to a boolean"
|
||||
{:node id :value v}))))
|
||||
|
||||
(defn- place
|
||||
"Where a node sits this frame, as {:m world :f local-frame :rd reader}, or nil
|
||||
when it is not on the frame at all.
|
||||
|
||||
Three gates, and nil from any of them removes the node's DESCENDANTS too,
|
||||
which is why this is one answer rather than three flags: a node outside its
|
||||
span does not exist, a switched-off feature takes its parts with it, and a node
|
||||
with no transform gives its children nowhere to be.
|
||||
|
||||
A missing [:geom :pts] is deliberately NOT one of them — that is `emit`'s
|
||||
business. An absent mouth outline has nothing to draw, but the head it hangs
|
||||
off is still exactly where it was, and that asymmetry is the whole reason
|
||||
presence is tracked per channel rather than per node."
|
||||
[{:keys [read mat-for pinv-for scratch]} n parent f pre]
|
||||
(let [pf (if parent (:f parent) f)
|
||||
;; Where the PREVIOUS grid slot landed, carried down the same path as `f`
|
||||
;; through the same time maps. `(:pre parent)` rather than a second walk,
|
||||
;; so an exposure fold or a retime on an ancestor is in it already — which
|
||||
;; is what makes a held exposure's interval come out right without the snap
|
||||
;; knowing exposure exists.
|
||||
ppf (if parent (:pre parent) pre)]
|
||||
(when (in-span? n pf)
|
||||
(let [id (:id n)
|
||||
chs (node/channels n)
|
||||
lf (node/local-frame n pf)
|
||||
plf (node/local-frame n ppf)
|
||||
rd (fn [path] (read id path (get chs path) lf plf))]
|
||||
(when (visible? id (rd [:vis]))
|
||||
(when-let [[pos piv rot scl skw] (xform-at rd)]
|
||||
;; dest aliases `local` here, which mul! allows: it reads both
|
||||
;; operands fully before writing either.
|
||||
(let [m (node/local! (mat-for id) pos piv rot scl skw)]
|
||||
{:m (node/world! m (:m parent) (pinv-for id) m scratch)
|
||||
:f lf
|
||||
:pre plf
|
||||
:rd rd})))))))
|
||||
|
||||
(defn- emit
|
||||
"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]
|
||||
;; Read only by the kinds that have a colour: a group or an instance has no
|
||||
;; `[:style :color]` to read.
|
||||
(let [paint (fn [op]
|
||||
(let [c (rd [:style :color])]
|
||||
(cond
|
||||
(knockout? c) (assoc op :knock (knock-index palette c) :color 0)
|
||||
(remap? c) (assoc op :lut (lut palette c) :color 0)
|
||||
:else (assoc op :color (colour-index palette c)))))]
|
||||
(case (:kind n)
|
||||
:group nil
|
||||
:instance nil
|
||||
|
||||
:poly
|
||||
(let [pts (rd [:geom :pts])]
|
||||
(when-not (ch/nothing? pts)
|
||||
(let [np (n-points pts)
|
||||
out (buf-for (:id n) np)]
|
||||
(dotimes [k np]
|
||||
(node/apply-pt! out k m
|
||||
(ch/component pts (* 2 k))
|
||||
(ch/component pts (inc (* 2 k)))))
|
||||
(paint (assoc base :kind :poly :pts out :n np)))))
|
||||
|
||||
:disc
|
||||
(let [rad (rd [:geom :radius])]
|
||||
(when-not (ch/nothing? rad)
|
||||
(paint (assoc base :kind :disc
|
||||
:cx (aget m 4) :cy (aget m 5)
|
||||
:r (* rad (node/mean-scale m))))))
|
||||
|
||||
:rect
|
||||
(let [size (rd [:geom :size])]
|
||||
(when-not (ch/nothing? size)
|
||||
(paint (assoc base :kind :rect
|
||||
:cx (aget m 4) :cy (aget m 5)
|
||||
:size (* size (node/mean-scale m))))))
|
||||
|
||||
(throw (ex-info "node kind is not implemented"
|
||||
{:node (:id n) :kind (:kind n)})))))
|
||||
|
||||
(defn- nodes-of
|
||||
"The symbol's node map, REFUSING a map that has none.
|
||||
|
||||
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.
|
||||
|
||||
Sounds are left out: they are heard, not drawn, and have no transform to
|
||||
evaluate. `audio/mix` is what plays them."
|
||||
[sym]
|
||||
(let [nodes (:nodes sym)]
|
||||
(when-not (map? nodes)
|
||||
(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)))})))
|
||||
(into {} (remove #(= :audio (:kind (val %)))) nodes)))
|
||||
|
||||
(defn- base-channel-frame
|
||||
"`:reads` holds the frames a node's own channels read; marked channels read
|
||||
instance pose choices, and the preserve-snap where nobody has made one.
|
||||
|
||||
`lf` is this slot's local frame and `plf` the PREVIOUS slot's, both already
|
||||
through placement and retime, so `(plf, lf]` is the interval this slot is the
|
||||
first to cover — the frames the grid shows to nobody. That is the only thing the
|
||||
snap needs from the grid, and it is why the pair is threaded this far down
|
||||
instead of the snap happening where the grid becomes a native frame: the snap is
|
||||
per pose group, and a group is a fact that only exists here."
|
||||
[choices marks reads nodes id c lf plf]
|
||||
(cond
|
||||
(contains? reads id)
|
||||
(node/hold (js/Math.floor lf) (get reads id))
|
||||
|
||||
(:pose-sampled? c)
|
||||
(let [group (or (:pose-group (get nodes id)) id)]
|
||||
(pose/source-frame choices
|
||||
(if (contains? choices [:node id]) [:node id] group)
|
||||
lf
|
||||
;; THE SNAP IS THE DEFAULT POSE. `source-frame` reaches a
|
||||
;; default only where the hand has said nothing, so seating
|
||||
;; it here leaves an explicit cut beating a snap for free —
|
||||
;; manual precedence is absolute — and costs no plumbing on
|
||||
;; the pose side, per-group choices being threaded already.
|
||||
(pose/snapped-frame marks group
|
||||
(js/Math.floor plf)
|
||||
(js/Math.floor lf))))
|
||||
|
||||
:else (js/Math.floor lf)))
|
||||
|
||||
(defn- holds-read
|
||||
"The frames node `n` reads its OWN channels at, held — not its children's,
|
||||
which is what makes this different from `:time :holds` — or nil for every
|
||||
frame. `{:holds [0]}` is a face's head at its start, and `{:holds-of :plate}`
|
||||
is the head holding wherever its footage holds, which is one list of frames
|
||||
read by two nodes rather than two lists kept in step."
|
||||
[nodes n]
|
||||
(let [{:keys [holds holds-of]} (:reads n)
|
||||
hs (if holds-of (get-in nodes [holds-of :time :holds]) holds)]
|
||||
(when (seq hs) hs)))
|
||||
|
||||
(defn- prepared-reads [nodes]
|
||||
(into {} (keep (fn [[id n]] (some->> (holds-read nodes n) (vector id)))) nodes))
|
||||
|
||||
(defn- eval-into
|
||||
"One frame, as a fold over the nodes in topological order.
|
||||
|
||||
`ctx` carries how a channel is read and where its points are written:
|
||||
|
||||
:read (fn [id path channel local-frame] -> v)
|
||||
:palette tone -> index, the ramp in scope
|
||||
:mat-for (fn [id] -> Float64Array) the node's world transform
|
||||
:pinv-for (fn [id] -> Float64Array|nil) its parent-inverse
|
||||
:buf-for (fn [id n-points] -> Float64Array)
|
||||
:scratch one spare 6-element matrix"
|
||||
[ctx nodes ord rank f pre]
|
||||
(-> (reduce
|
||||
(fn [{:keys [placed ops] :as acc} id]
|
||||
(let [n (get nodes id)
|
||||
pid (:parent n)
|
||||
parent (when pid (get placed pid))]
|
||||
;; A node whose parent was dropped is dropped with it, and so is
|
||||
;; everything under it. Topological order is what makes that one
|
||||
;; lookup instead of a subtree walk.
|
||||
(if (and pid (nil? parent))
|
||||
acc
|
||||
(if-let [p (place ctx n parent f pre)]
|
||||
(let [_ (when-let [on-place (:on-place ctx)] (on-place id p))
|
||||
op (emit ctx n p {:node id :stencil (:stencil n)})]
|
||||
(cond-> (update acc :placed assoc id p)
|
||||
op (update :ops conj op)))
|
||||
acc))))
|
||||
{:placed {} :ops []}
|
||||
ord)
|
||||
:ops
|
||||
(->> (finish rank))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the specification
|
||||
|
||||
(defn eval-frame
|
||||
"Symbol at frame f -> draw ops in z order. Pure, and allocates freely.
|
||||
|
||||
`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.
|
||||
|
||||
`pre` is where the grid slot BEFORE this one landed, which with `f` is the
|
||||
interval the preserve-snap may reach back into — see `pose/snapped-frame`. The
|
||||
shorter arity means one native frame per slot, so the interval is `f` alone and
|
||||
nothing snaps: that is what a caller rendering a native frame directly is asking
|
||||
for, and it is what keeps this evaluator and `resolver` the same answer.
|
||||
|
||||
This is the definition of what a frame means. `resolver` is what plays it."
|
||||
([sym f store palette opts] (eval-frame sym f (dec f) store palette opts))
|
||||
([sym f pre store palette {:keys [pose-tracks snap]}]
|
||||
(let [nodes (nodes-of sym)
|
||||
choices (pose/prepare pose-tracks)
|
||||
marks (when (and snap (snap (:id sym)))
|
||||
(pose/marks nodes (:frames sym) store))
|
||||
reads (prepared-reads nodes)
|
||||
ord (order nodes)]
|
||||
(eval-into {:read (fn [id path c lf plf]
|
||||
(ch/value-at c (base-channel-frame choices marks reads
|
||||
nodes id c lf plf)
|
||||
lf store))
|
||||
:palette palette
|
||||
:mat-for (fn [_id] (node/mat))
|
||||
:pinv-for (fn [id] (node/pinv (get nodes id)))
|
||||
:buf-for (fn [_id n] (js/Float64Array. (* 2 n)))
|
||||
:scratch (node/mat)}
|
||||
nodes ord (draw-rank nodes ord) f pre))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the playback path
|
||||
|
||||
(defn- point-capacity
|
||||
"How many points the widest value of a [:geom :pts] channel holds.
|
||||
|
||||
FIXED TOPOLOGY is what makes this a number at all: every key of a part carries
|
||||
the same vertex count with the same vertex meanings, so the buffer can be
|
||||
allocated once. A variable vertex count would force a per-frame offset table
|
||||
and a scan, which is why the aesthetic constraint is a performance asset rather
|
||||
than a cost."
|
||||
[c]
|
||||
(quot (cond
|
||||
(:dense c) (:stride (:dense c))
|
||||
(:animated? c) (transduce (map #(if (vector? %) (count %) (.-length %)))
|
||||
max 0 (vals (:keys c)))
|
||||
:else (let [v (:value c)] (if (vector? v) (count v) (.-length v))))
|
||||
2))
|
||||
|
||||
(defprotocol IResolver
|
||||
(world-of [this id]
|
||||
"The node's world transform AS OF THE LAST FRAME RESOLVED, or nil if it was
|
||||
not placed on that frame.
|
||||
|
||||
The matrices are the ones evaluation mutates in place, so this is a read of
|
||||
live state rather than a snapshot — which is exactly what the caller wants.
|
||||
A registered photo underlay has to ride the same transform the vectors went
|
||||
through or it is merely decorative, and it paints immediately after the frame
|
||||
it belongs to, so \"as of the last frame\" is the only answer that can be
|
||||
correct.")
|
||||
(frame-of [this id] "The placed node's local frame on the last resolve.")
|
||||
(pre-frame-of [this id]
|
||||
"The same node's local frame for the grid slot BEFORE the last resolve.
|
||||
|
||||
Half of the preserve-snap's interval, and the half only the walk can answer:
|
||||
the mapping from a grid slot to a node's local frame runs through every time
|
||||
map between them, so the frame the previous slot landed on is what the walk
|
||||
carried, not something a caller can recompute from the slot index."))
|
||||
|
||||
(defn resolver
|
||||
"(fn [f] -> ops). Holds everything that does not change per frame.
|
||||
|
||||
The point buffers are REUSED between frames, so a caller must consume the ops
|
||||
before asking for the next frame. That is the contract the rAF loop wants
|
||||
anyway — it reads, blits, and dispatches nothing — and it is what makes a frame
|
||||
cost a lookup and a blit rather than an allocation per vertex.
|
||||
|
||||
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.
|
||||
|
||||
`store` and `palette` are POSITIONAL because neither is optional: a dense
|
||||
channel cannot be read without the store it names, and every op carries a
|
||||
colour index. `opts` is a map because the rest genuinely are optional, and
|
||||
because a fifth of them later is then a key rather than a nil at every one of
|
||||
these call sites — which is what the arity ladder that used to be here was
|
||||
standing in for."
|
||||
[sym store palette {:keys [pose-tracks snap]}]
|
||||
(let [nodes (nodes-of sym)
|
||||
choices (pose/prepare pose-tracks)
|
||||
;; NO MARKS IS THE OFF STATE, and that it needs no second code path is
|
||||
;; the reason `snapped-frame` falls back to the slot's own default rather
|
||||
;; than being asked whether it should. Off is the cadence alone, which is
|
||||
;; what ships today, so it is also the DEFAULT: an opts map that says
|
||||
;; nothing gets the behaviour it got before the snap existed.
|
||||
;;
|
||||
;; `:snap` IS ASKED ABOUT THIS SYMBOL, not the resolver, because the marks
|
||||
;; are the FACE'S: the closure cuts are stored on its own nodes, so every
|
||||
;; placement of one face has the same ones and a per-placement answer would
|
||||
;; be a setting with nothing in it. A set of symbol ids is the usual
|
||||
;; argument, and any predicate on one will do.
|
||||
;;
|
||||
;; One option, and it is the snap's alone. The plate side will never want
|
||||
;; one — its proposal is materialised into the plate's `:time :holds` rather than
|
||||
;; computed on the render path — so this is not half of a pair.
|
||||
marks (when (and snap (snap (:id sym)))
|
||||
(pose/marks nodes (:frames sym) store))
|
||||
reads (prepared-reads nodes)
|
||||
ord (order nodes)
|
||||
rank (draw-rank nodes ord)
|
||||
cursors (into {}
|
||||
(map (fn [id]
|
||||
[id (into {} (map (fn [[p c]] [p (ch/cursor c store)]))
|
||||
(node/channels (get nodes id)))]))
|
||||
ord)
|
||||
mats (into {} (map (fn [id] [id (node/mat)])) ord)
|
||||
pinvs (into {} (keep (fn [id] (when-let [p (node/pinv (get nodes id))] [id p]))) ord)
|
||||
bufs (into {}
|
||||
(keep (fn [id]
|
||||
(when-let [c (get-in nodes [id :channels [:geom :pts]])]
|
||||
[id (js/Float64Array. (* 2 (point-capacity c)))])))
|
||||
ord)
|
||||
scratch (node/mat)
|
||||
;; Every call to mat-for is a placement: eval-into reaches it only after
|
||||
;; the span, visibility and transform gates have all passed. So wrapping
|
||||
;; it is how the resolver learns which nodes exist this frame without
|
||||
;; eval-into having to report it — and it covers groups, which are
|
||||
;; placed but emit no op, and which are exactly what an underlay rides.
|
||||
placed (volatile! {})
|
||||
ctx {:read (fn [id path c lf plf]
|
||||
(when-let [cursor (get-in cursors [id path])]
|
||||
(ch/sample! cursor
|
||||
(base-channel-frame choices marks reads
|
||||
nodes id c lf plf)
|
||||
lf)))
|
||||
:palette palette
|
||||
:mat-for (fn [id] (get mats id))
|
||||
:on-place (fn [id p] (vswap! placed assoc id p))
|
||||
:pinv-for (fn [id] (get pinvs id))
|
||||
:buf-for (fn [id _n] (get bufs id))
|
||||
:scratch scratch}
|
||||
step (fn [f pre]
|
||||
(vreset! placed {})
|
||||
(eval-into ctx nodes ord rank f pre))]
|
||||
(reify
|
||||
IFn
|
||||
;; One frame and no interval is one native frame per slot — see `eval-frame`.
|
||||
(-invoke [_ f] (step f (dec f)))
|
||||
(-invoke [_ f pre] (step f pre))
|
||||
IResolver
|
||||
(world-of [_ id] (:m (get @placed id)))
|
||||
(frame-of [_ id] (:f (get @placed id)))
|
||||
(pre-frame-of [_ id] (:pre (get @placed id))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(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 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.
|
||||
|
||||
`:name` is what a person calls it, and is not its id: an id is what instances
|
||||
and saved leaves point at, so renaming a symbol must not change it.
|
||||
|
||||
`:width` and `:height` are the symbol's own stage, and are absent until someone
|
||||
sets them: a symbol without them uses the clip's — see `clip/stage`.
|
||||
|
||||
`:display` is how the TIMELINE draws the symbol — `:lane` for its clips as
|
||||
blocks on one row — and is saved because two people editing one document must
|
||||
play by the same editing rules. See `lane?`.
|
||||
|
||||
`:media` is what a `:type :trace` symbol shows — `{:footage id :range [in
|
||||
out]}` or `{:image sha256}` — and such a symbol has no nodes: its frames are
|
||||
the footage's, its `:fps` the footage's own and its `:width`/`:height` its
|
||||
pixels. A still shows the same picture on every one of its frames, so how many
|
||||
it has is only how long it was made to last, and trimming its placement is
|
||||
how that changes. See `clip/trace-op`."
|
||||
#{:id :name :frames :fps :width :height :nodes :palette :palette-track
|
||||
:palette-channel :type :palette-ref :display :media})
|
||||
|
||||
(defn- media-problems
|
||||
"Why `sym`, a tracing symbol, does not say what it shows. Empty when it does."
|
||||
[{:keys [media frames width height nodes]}]
|
||||
(let [{:keys [footage image range]} media]
|
||||
(cond-> []
|
||||
(seq nodes)
|
||||
(conj "a tracing symbol cannot contain nodes")
|
||||
(not (= 1 (count (filter some? [footage image]))))
|
||||
(conj ":media must name exactly one of :footage or :image")
|
||||
(and footage (not (and (vector? range) (= 2 (count range))
|
||||
(every? integer? range) (apply < range)
|
||||
(= frames (- (second range) (first range))))))
|
||||
(conj "a footage :media needs a [in out) :range as long as the symbol's :frames")
|
||||
(not (and (integer? frames) (pos? frames)))
|
||||
(conj "a tracing symbol is a positive whole number of frames")
|
||||
(not (and (pos? width) (pos? height)))
|
||||
(conj "a tracing symbol needs its media's pixel :width and :height"))))
|
||||
|
||||
(defn problems
|
||||
"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
|
||||
layering nicety: a feature names nodes, and a library symbol's nodes are not
|
||||
the ones a face was tracked into.
|
||||
|
||||
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."
|
||||
[sym]
|
||||
(let [nodes (:nodes sym)]
|
||||
(if-not (map? nodes)
|
||||
[":nodes must be a map of id -> node"]
|
||||
(-> []
|
||||
(cond->
|
||||
(and (= :palette (:type sym)) (seq nodes))
|
||||
(conj "a palette symbol cannot contain nodes"))
|
||||
(into (when (= :trace (:type sym)) (media-problems sym)))
|
||||
(into (for [[id n] nodes
|
||||
:when (not= id (:id n))]
|
||||
(str "node under key " (pr-str id) " has :id " (pr-str (:id n)))))
|
||||
(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 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 symbol")))
|
||||
(into (for [[id n] nodes
|
||||
p (node/problems n)]
|
||||
(str "node " (pr-str id) ": " p)))
|
||||
(into (for [[id n] nodes
|
||||
:when (some? (:trace n))]
|
||||
(str "node " (pr-str id) ": :trace is gone — trace keys are the "
|
||||
"footage placement's :time :holds, and a head follows them "
|
||||
"with :reads {:holds-of …}; make the face again from its footage")))
|
||||
;; `:reads` names its holds or another node's, the way `:stencil` names
|
||||
;; a node: one that is here, and never one whose frame depends on the
|
||||
;; reader's own channels being read first.
|
||||
(into (for [[id n] nodes
|
||||
:let [{:keys [holds holds-of] :as r} (:reads n)]
|
||||
:when (some? r)
|
||||
p (cond
|
||||
(and holds holds-of)
|
||||
[":reads names its own :holds or a node's, not both"]
|
||||
holds-of
|
||||
(cond
|
||||
(not (contains? nodes holds-of))
|
||||
[(str ":reads :holds-of " (pr-str holds-of) " is not in the symbol")]
|
||||
(some #{holds-of} (lineage nodes id))
|
||||
[":reads :holds-of names the node itself or one above it"])
|
||||
:else
|
||||
(when-not (and (vector? holds) (every? node/finite-number? holds)
|
||||
(or (empty? holds) (apply < holds)))
|
||||
[":reads :holds must be a vector of increasing frames"]))]
|
||||
(str "node " (pr-str id) ": " p)))
|
||||
(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 (when (and (some? (:fps sym))
|
||||
(not (and (node/finite-number? (:fps sym)) (pos? (:fps sym)))))
|
||||
[":fps must be a positive finite native rate"]))
|
||||
(into (for [k [:width :height]
|
||||
:let [v (get sym k)]
|
||||
:when (and (some? v) (not (and (integer? v) (pos? v))))]
|
||||
(str k " is " (pr-str v) " — a symbol stage dimension must be a positive integer")))
|
||||
(into (try
|
||||
(doall (map #(depth nodes %) (keys nodes)))
|
||||
nil
|
||||
(catch :default e [(ex-message e)])))))))
|
||||
|
|
@ -1,573 +0,0 @@
|
|||
(ns arthur.domain.timeline
|
||||
"A TIMELINE: 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.
|
||||
|
||||
The clip-level facts are in `arthur.domain.clip`. A timeline 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.
|
||||
|
||||
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 —
|
||||
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,
|
||||
obviously correct. Use it in tests and for a
|
||||
one-off render.
|
||||
|
||||
(resolver tl 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
|
||||
maps and nothing else.
|
||||
|
||||
Both run the same walk — `eval-into` below — parameterised by how a channel is
|
||||
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
|
||||
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:
|
||||
ops carry palette indices and raster-space points, and the rasteriser knows
|
||||
nothing about nodes, channels or time.
|
||||
|
||||
Geometry is stored FLAT — [x0 y0 x1 y1 …] — in authored channels as well as
|
||||
dense ones. A dense block is a rectangular Int16Array and an authored ring is a
|
||||
vector of numbers, and they read the same way, which is what makes freezing
|
||||
fill in the same channel rather than convert into a second format."
|
||||
(:require [arthur.domain.channel :as ch]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.pose :as pose]
|
||||
[arthur.domain.palette :as pal]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; structure: depth, topological order, draw order
|
||||
|
||||
(defn lineage
|
||||
"The node's id and every ancestor's, nearest first and root last.
|
||||
|
||||
One walk, shared by `depth` and `z-path`, which otherwise duplicate it.
|
||||
|
||||
A cycle is caught by LENGTH rather than by a `seen` set: a chain that does not
|
||||
repeat cannot be longer than the number of nodes, so one step past that is
|
||||
proof of a loop and needs no bookkeeping. Caught rather than hung — a cycle is
|
||||
reachable from one bad `:node/set-parent`, and a hung tab is a far worse
|
||||
diagnostic than a stack trace naming the nodes."
|
||||
[nodes id]
|
||||
(let [up (fn [i]
|
||||
(when-let [p (:parent (get nodes i))]
|
||||
(if (contains? nodes p)
|
||||
p
|
||||
(throw (ex-info "node's :parent is not in the timeline"
|
||||
{: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})))
|
||||
chain))
|
||||
|
||||
(defn depth
|
||||
"Number of ancestors."
|
||||
[nodes id]
|
||||
(dec (count (lineage nodes id))))
|
||||
|
||||
(defn order
|
||||
"Node ids in topological order: every node after its parent.
|
||||
|
||||
Sorting by parent depth is enough — it does not need Kahn's algorithm, because
|
||||
the only edge is parent, and a node's depth is by definition greater than its
|
||||
parent's. Ties are broken by id so the order is deterministic across runs,
|
||||
which matters because the draw-order sort below falls back on this position."
|
||||
[nodes]
|
||||
(vec (sort-by (juxt #(depth nodes %) str) (keys nodes))))
|
||||
|
||||
(defn z-path
|
||||
"The node's z index and every ancestor's, root first.
|
||||
|
||||
Draw order is depth-first by sibling z, so the key that sorts it is the chain
|
||||
of z values from the root. A parent's path is a PREFIX of its child's, which is
|
||||
why a parent draws before its children without that being a special case.
|
||||
|
||||
`:z` values are fractional-index STRINGS (\"a1\", \"a3\") and compare
|
||||
lexicographically, so a node can always be inserted between two siblings
|
||||
without renumbering either."
|
||||
[nodes id]
|
||||
(mapv #(:z (get nodes %)) (rseq (lineage nodes id))))
|
||||
|
||||
(defn- z-lex
|
||||
"Lexicographic compare of two z paths, a prefix sorting first.
|
||||
|
||||
`compare` on vectors will not do: it compares COUNT first, so a deep
|
||||
descendant of \"a1\" would sort after a shallow \"a2\" and a painted cel would
|
||||
jump in front of the head that carries it.
|
||||
|
||||
`map` over two collections stops at the shorter and `first` short-circuits at
|
||||
the first difference, so this walks no further than it has to."
|
||||
[a b]
|
||||
(or (first (remove zero? (map compare a b)))
|
||||
(- (count a) (count b))))
|
||||
|
||||
(defn draw-rank
|
||||
"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
|
||||
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.
|
||||
|
||||
`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."
|
||||
[nodes ord]
|
||||
(let [paths (into {} (map (juxt identity #(z-path nodes %))) ord)]
|
||||
(into {} (map-indexed (fn [i id] [id i])) (sort-by paths z-lex ord))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; colour
|
||||
|
||||
(defn colour-index
|
||||
"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
|
||||
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
|
||||
`:palette` channel, the walk carries the palette in scope exactly as it already
|
||||
carries the parent transform and the local frame.
|
||||
|
||||
An unknown tone resolves to 255, which the palette expansion renders MAGENTA.
|
||||
Loud rather than fatal, and the same choice raster/->rgba already makes:
|
||||
naming a colour the ramp does not have is a bug in authored data, and it should
|
||||
be impossible to miss and should not take the frame down."
|
||||
[palette k]
|
||||
(cond
|
||||
(number? k) k
|
||||
(nil? k) 255
|
||||
:else (get palette k 255)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the walk
|
||||
|
||||
(defn- in-span?
|
||||
"`:span` is Lottie's ip/op and Flash's PlaceObject/RemoveObject: the range over
|
||||
which the node EXISTS, tested in the PARENT's frame space and therefore before
|
||||
the node's own time map runs. Distinct from `[:vis]`, which blinks an existing
|
||||
node on and off. Half-open, so two adjacent spans do not both own a frame."
|
||||
[n f]
|
||||
(if-let [[in out] (:span n)]
|
||||
(and (>= f in) (< f out))
|
||||
true))
|
||||
|
||||
(defn- finish
|
||||
"Resolve stencils, then sort into draw order.
|
||||
|
||||
A stencil is a COLOUR KEY, not a node reference: it is 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 without a per-part mask. So the
|
||||
stencil node's own colour is looked up here, after the walk, because the
|
||||
stencil may sit anywhere in the order. Two nodes sharing a palette entry share
|
||||
a stencil, which is inherent to the technique rather than a defect in it.
|
||||
|
||||
A node stencilled by something that drew NOTHING is DROPPED, not drawn
|
||||
unclipped: unclipped would be an iris floating over the cheek on exactly the
|
||||
frames where the eye is missing."
|
||||
[rank ops]
|
||||
(let [by-id (into {} (map (juxt :node :color)) ops)]
|
||||
(->> ops
|
||||
(keep (fn [op]
|
||||
(if-let [s (:stencil op)]
|
||||
(when-let [idx (get by-id s)]
|
||||
(assoc op :stencil idx))
|
||||
op)))
|
||||
(sort-by (comp rank :node))
|
||||
vec)))
|
||||
|
||||
(defn- n-points
|
||||
"Points in a flat [x0 y0 x1 y1 …] value, authored vector or dense view alike."
|
||||
[pts]
|
||||
(quot (if (vector? pts) (count pts) (.-length pts)) 2))
|
||||
|
||||
(defn- xform-at
|
||||
"The five transform components at the node's local frame, or nil when any of
|
||||
them has no value on it."
|
||||
[rd]
|
||||
(let [pos (rd [:xform :pos])
|
||||
rot (rd [:xform :rot])
|
||||
scl (rd [:xform :scale])
|
||||
skw (rd [:xform :skew])
|
||||
anc (rd [:xform :anchor])]
|
||||
(when-not (or (ch/nothing? pos) (ch/nothing? rot) (ch/nothing? scl)
|
||||
(ch/nothing? skw) (ch/nothing? anc))
|
||||
[pos rot scl skw anc])))
|
||||
|
||||
(defn- visible?
|
||||
"Is the node switched on this frame?
|
||||
|
||||
`[:vis]` IS A BOOLEAN, and this insists on it rather than testing truthiness,
|
||||
because the two obvious implementations are both wrong about a DENSE `[:vis]`.
|
||||
A dense block yields 0 or 1, and 0 is TRUTHY in CLJS — so `(if v …)` shows a
|
||||
hidden frame, and `(true? v)` hides every frame. Neither reads as an error.
|
||||
|
||||
docs/animation-model.md's parts table says `:mouth-in` carries `[:vis]` dense;
|
||||
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.
|
||||
|
||||
Absence is not a boolean and is not an error: a subject that is not on the
|
||||
frame has nothing to show."
|
||||
[id v]
|
||||
(cond
|
||||
(true? v) true
|
||||
(false? v) false
|
||||
(ch/nothing? v) false
|
||||
:else (throw (ex-info "[:vis] must sample to a boolean"
|
||||
{:node id :value v}))))
|
||||
|
||||
(defn- place
|
||||
"Where a node sits this frame, as {:m world :f local-frame :rd reader}, or nil
|
||||
when it is not on the frame at all.
|
||||
|
||||
Three gates, and nil from any of them removes the node's DESCENDANTS too,
|
||||
which is why this is one answer rather than three flags: a node outside its
|
||||
span does not exist, a switched-off feature takes its parts with it, and a node
|
||||
with no transform gives its children nowhere to be.
|
||||
|
||||
A missing [:geom :pts] is deliberately NOT one of them — that is `emit`'s
|
||||
business. An absent mouth outline has nothing to draw, but the head it hangs
|
||||
off is still exactly where it was, and that asymmetry is the whole reason
|
||||
presence is tracked per channel rather than per node."
|
||||
[{:keys [read mat-for pinv-for scratch]} n parent f]
|
||||
(let [pf (if parent (:f parent) f)]
|
||||
(when (in-span? n pf)
|
||||
(let [id (:id n)
|
||||
chs (node/channels n)
|
||||
lf (node/local-frame n pf)
|
||||
rd (fn [path] (read id path (get chs path) lf))]
|
||||
(when (visible? id (rd [:vis]))
|
||||
(when-let [[pos rot scl skw anc] (xform-at rd)]
|
||||
;; dest aliases `local` here, which mul! allows: it reads both
|
||||
;; operands fully before writing either.
|
||||
(let [m (node/local! (mat-for id) pos rot scl skw anc)]
|
||||
{:m (node/world! m (:m parent) (pinv-for id) m scratch)
|
||||
:f lf
|
||||
:rd rd})))))))
|
||||
|
||||
(defn- emit
|
||||
"Emit geometry in the timeline'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
|
||||
:audio nil
|
||||
|
||||
:poly
|
||||
(let [pts (rd [:geom :pts])]
|
||||
(when-not (ch/nothing? pts)
|
||||
(let [np (n-points pts)
|
||||
out (buf-for (:id n) np)]
|
||||
(dotimes [k np]
|
||||
(node/apply-pt! out k m
|
||||
(ch/component pts (* 2 k))
|
||||
(ch/component pts (inc (* 2 k)))))
|
||||
(assoc base :kind :poly :pts out :n np :color (colour)))))
|
||||
|
||||
:disc
|
||||
(let [rad (rd [:geom :radius])]
|
||||
(when-not (ch/nothing? rad)
|
||||
(assoc base :kind :disc
|
||||
:cx (aget m 4) :cy (aget m 5)
|
||||
:r (* rad (node/mean-scale m))
|
||||
:color (colour))))
|
||||
|
||||
:rect
|
||||
(let [size (rd [:geom :size])]
|
||||
(when-not (ch/nothing? size)
|
||||
(assoc base :kind :rect
|
||||
:cx (aget m 4) :cy (aget m 5)
|
||||
:size (* size (node/mean-scale m))
|
||||
:color (colour))))
|
||||
|
||||
(throw (ex-info "node kind is not implemented"
|
||||
{:node (:id n) :kind (:kind n)})))))
|
||||
|
||||
(defn- nodes-of
|
||||
"The timeline'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
|
||||
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)]
|
||||
(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)))})))
|
||||
nodes))
|
||||
|
||||
(defn- channel-frame
|
||||
"Anchors select measured frames; marked channels read instance pose choices."
|
||||
[choices anchors nodes source-fps picture-fps id c lf]
|
||||
(cond
|
||||
(contains? anchors id)
|
||||
(pose/held-frame (get anchors id) lf lf)
|
||||
|
||||
(:pose-sampled? c)
|
||||
(pose/source-frame choices
|
||||
(if (contains? choices [:node id])
|
||||
[:node id]
|
||||
(or (:pose-group (get nodes id)) id))
|
||||
lf
|
||||
(node/sample-frame lf source-fps picture-fps))
|
||||
|
||||
:else lf))
|
||||
|
||||
(defn- prepared-anchors [nodes]
|
||||
(into {}
|
||||
(for [[id n] nodes :when (seq (:anchors n))]
|
||||
[id (vec (sort-by first (:anchors n)))])))
|
||||
|
||||
(defn- eval-into
|
||||
"One frame, as a fold over the nodes in topological order.
|
||||
|
||||
`ctx` carries how a channel is read and where its points are written:
|
||||
|
||||
:read (fn [id path channel local-frame] -> v)
|
||||
:palette tone -> index, the ramp in scope
|
||||
:mat-for (fn [id] -> Float64Array) the node's world transform
|
||||
:pinv-for (fn [id] -> Float64Array|nil) its parent-inverse
|
||||
:buf-for (fn [id n-points] -> Float64Array)
|
||||
:scratch one spare 6-element matrix"
|
||||
[ctx nodes ord rank f]
|
||||
(-> (reduce
|
||||
(fn [{:keys [placed ops] :as acc} id]
|
||||
(let [n (get nodes id)
|
||||
pid (:parent n)
|
||||
parent (when pid (get placed pid))]
|
||||
;; A node whose parent was dropped is dropped with it, and so is
|
||||
;; everything under it. Topological order is what makes that one
|
||||
;; lookup instead of a subtree walk.
|
||||
(if (and pid (nil? parent))
|
||||
acc
|
||||
(if-let [p (place ctx n parent f)]
|
||||
(let [_ (when-let [on-place (:on-place ctx)] (on-place id p))
|
||||
op (emit ctx n p {:node id :stencil (:stencil n)})]
|
||||
(cond-> (update acc :placed assoc id p)
|
||||
op (update :ops conj op)))
|
||||
acc))))
|
||||
{:placed {} :ops []}
|
||||
ord)
|
||||
:ops
|
||||
(->> (finish rank))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the specification
|
||||
|
||||
(defn eval-frame
|
||||
"Timeline 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
|
||||
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)
|
||||
choices (pose/prepare pose-tracks)
|
||||
anchors (prepared-anchors nodes)
|
||||
{:keys [source-fps picture-fps]} opts
|
||||
ord (order nodes)]
|
||||
(eval-into {:read (fn [id path c lf]
|
||||
(ch/value-at c (channel-frame choices anchors nodes
|
||||
source-fps picture-fps id c lf)
|
||||
store))
|
||||
:palette palette
|
||||
:mat-for (fn [_id] (node/mat))
|
||||
:pinv-for (fn [id] (node/pinv (get nodes id)))
|
||||
:buf-for (fn [_id n] (js/Float64Array. (* 2 n)))
|
||||
:scratch (node/mat)}
|
||||
nodes ord (draw-rank nodes ord) f))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the playback path
|
||||
|
||||
(defn- point-capacity
|
||||
"How many points the widest value of a [:geom :pts] channel holds.
|
||||
|
||||
FIXED TOPOLOGY is what makes this a number at all: every key of a part carries
|
||||
the same vertex count with the same vertex meanings, so the buffer can be
|
||||
allocated once. A variable vertex count would force a per-frame offset table
|
||||
and a scan, which is why the aesthetic constraint is a performance asset rather
|
||||
than a cost."
|
||||
[c]
|
||||
(quot (cond
|
||||
(:dense c) (:stride (:dense c))
|
||||
(:animated? c) (transduce (map #(if (vector? %) (count %) (.-length %)))
|
||||
max 0 (vals (:keys c)))
|
||||
:else (let [v (:value c)] (if (vector? v) (count v) (.-length v))))
|
||||
2))
|
||||
|
||||
(defprotocol IResolver
|
||||
(world-of [this id]
|
||||
"The node's world transform AS OF THE LAST FRAME RESOLVED, or nil if it was
|
||||
not placed on that frame.
|
||||
|
||||
The matrices are the ones evaluation mutates in place, so this is a read of
|
||||
live state rather than a snapshot — which is exactly what the caller wants.
|
||||
A registered photo underlay has to ride the same transform the vectors went
|
||||
through or it is merely decorative, and it paints immediately after the frame
|
||||
it belongs to, so \"as of the last frame\" is the only answer that can be
|
||||
correct.")
|
||||
(frame-of [this id] "The placed node's local frame on the last resolve."))
|
||||
|
||||
(defn resolver
|
||||
"(fn [f] -> ops). Holds everything that does not change per frame.
|
||||
|
||||
The point buffers are REUSED between frames, so a caller must consume the ops
|
||||
before asking for the next frame. That is the contract the rAF loop wants
|
||||
anyway — it reads, blits, and dispatches nothing — and it is what makes a frame
|
||||
cost a lookup and a blit rather than an allocation per vertex.
|
||||
|
||||
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)
|
||||
choices (pose/prepare pose-tracks)
|
||||
anchors (prepared-anchors nodes)
|
||||
ord (order nodes)
|
||||
rank (draw-rank nodes ord)
|
||||
cursors (into {}
|
||||
(map (fn [id]
|
||||
[id (into {} (map (fn [[p c]] [p (ch/cursor c store)]))
|
||||
(node/channels (get nodes id)))]))
|
||||
ord)
|
||||
mats (into {} (map (fn [id] [id (node/mat)])) ord)
|
||||
pinvs (into {} (keep (fn [id] (when-let [p (node/pinv (get nodes id))] [id p]))) ord)
|
||||
bufs (into {}
|
||||
(keep (fn [id]
|
||||
(when-let [c (get-in nodes [id :channels [:geom :pts]])]
|
||||
[id (js/Float64Array. (* 2 (point-capacity c)))])))
|
||||
ord)
|
||||
scratch (node/mat)
|
||||
;; Every call to mat-for is a placement: eval-into reaches it only after
|
||||
;; the span, visibility and transform gates have all passed. So wrapping
|
||||
;; it is how the resolver learns which nodes exist this frame without
|
||||
;; eval-into having to report it — and it covers groups, which are
|
||||
;; placed but emit no op, and which are exactly what an underlay rides.
|
||||
placed (volatile! {})
|
||||
ctx {:read (fn [id path c lf]
|
||||
(ch/sample! (get-in cursors [id path])
|
||||
(channel-frame choices anchors nodes
|
||||
source-fps picture-fps id c lf)))
|
||||
:palette palette
|
||||
:mat-for (fn [id] (get mats id))
|
||||
:on-place (fn [id p] (vswap! placed assoc id p))
|
||||
:pinv-for (fn [id] (get pinvs id))
|
||||
:buf-for (fn [id _n] (get bufs id))
|
||||
:scratch scratch}
|
||||
step (fn [f]
|
||||
(vreset! placed {})
|
||||
(eval-into ctx nodes ord rank f))]
|
||||
(reify
|
||||
IFn
|
||||
(-invoke [_ f] (step f))
|
||||
IResolver
|
||||
(world-of [_ id] (:m (get @placed id)))
|
||||
(frame-of [_ id] (:f (get @placed id)))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def timeline-keys
|
||||
"Every field a timeline 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 —
|
||||
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.
|
||||
|
||||
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
|
||||
layering nicety: a feature names nodes, and a library symbol's nodes are not
|
||||
the ones a face was tracked into.
|
||||
|
||||
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)]
|
||||
(if-not (map? nodes)
|
||||
[":nodes must be a map of id -> node"]
|
||||
(-> []
|
||||
(into (for [[id n] nodes
|
||||
:when (not= id (:id n))]
|
||||
(str "node under key " (pr-str id) " has :id " (pr-str (:id n)))))
|
||||
(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")))
|
||||
(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")))
|
||||
(into (for [[id n] nodes
|
||||
p (node/problems n)]
|
||||
(str "node " (pr-str id) ": " p)))
|
||||
;; Anchors re-address the node's measurement, regardless of its name.
|
||||
(into (for [[id n] nodes
|
||||
:let [anchors (:anchors n)]
|
||||
:when (some? anchors)
|
||||
:when (not (and (map? anchors) (contains? anchors 0)
|
||||
(integer? (:frames tl))
|
||||
(every? #(and (integer? %) (<= 0 %)
|
||||
(< % (:frames tl)))
|
||||
(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 (try
|
||||
(doall (map #(depth nodes %) (keys nodes)))
|
||||
nil
|
||||
(catch :default e [(ex-message e)])))))))
|
||||
438
frontend/src/arthur/events/collab.cljs
Normal file
438
frontend/src/arthur/events/collab.cljs
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
(ns arthur.events.collab
|
||||
"Everything that makes a document somewhere other people are: its address, who
|
||||
you are, who else is in it, and their writes arriving while you work.
|
||||
|
||||
docs/architecture.md, Collaboration, and tl's model with the four additions it
|
||||
asks for. Writes stay on HTTP; the socket carries presence and the deltas the
|
||||
server broadcasts after a write commits.
|
||||
|
||||
ONE RULE FOR THE ADDRESS AND THE ROOM. They follow `[:project :id]`, whatever
|
||||
event changed it — open, save, new, a copy — through one interceptor, so no
|
||||
event that loads a document has to remember to join its room.
|
||||
|
||||
THE OUTBOX RULE, without an outbox. A remote leaf lands unless we have a change
|
||||
to that leaf the server has not seen — a leaf whose local value differs from the
|
||||
last value we synced. Otherwise their write would snap our unsaved edit back.
|
||||
The next save sends ours, and if theirs moved since, it answers 409 and we catch
|
||||
up, and the save after that is ours."
|
||||
(:require [arthur.domain.leaf :as leaf]
|
||||
[arthur.domain.project :as project]
|
||||
[arthur.events.edit :as edit]
|
||||
[arthur.events.playback :as pb]
|
||||
[arthur.events.project :as events.project]
|
||||
[arthur.footage.store :as store]
|
||||
[arthur.fx.http :as http]
|
||||
[clojure.string :as str]
|
||||
[re-frame.core :as rf]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the address
|
||||
|
||||
(defn- path-id
|
||||
"The project a path names: `/p/<uuid>/<slug>`. The slug is for people; the
|
||||
id is what finds it."
|
||||
[path]
|
||||
(second (re-matches #"/p/([0-9a-fA-F-]{36})(?:/.*)?" path)))
|
||||
|
||||
(defn slug [name]
|
||||
(or (not-empty (-> (str/lower-case (or name ""))
|
||||
(str/replace #"[^a-z0-9]+" "-")
|
||||
(str/replace #"^-+|-+$" "")))
|
||||
"untitled"))
|
||||
|
||||
(defn project-path [id name] (str "/p/" id "/" (slug name)))
|
||||
|
||||
(defn- route! []
|
||||
(rf/dispatch [::routed (path-id (.. js/window -location -pathname))]))
|
||||
|
||||
(defn navigate! [path]
|
||||
(.pushState js/history nil "" path)
|
||||
(route!))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::routed
|
||||
;; `/` is the index of your projects; a project is only ever at its address.
|
||||
(fn [{:keys [db]} [_ id]]
|
||||
(cond
|
||||
(nil? id) {:db (assoc db :route :index)
|
||||
:dispatch [::events.project/list]}
|
||||
(= id (get-in db [:project :id])) {:db (assoc db :route [:project id])}
|
||||
:else {:db (assoc db :route [:project id])
|
||||
:dispatch [::events.project/open id]})))
|
||||
|
||||
(rf/reg-sub ::route (fn [db _] (:route db)))
|
||||
|
||||
(rf/reg-fx
|
||||
::create!
|
||||
(fn [name]
|
||||
(-> (http/POST "/api/projects" #js {:name name})
|
||||
(.then (fn [^js made] (navigate! (project-path (.-id made) (.-name made)))))
|
||||
(.catch #(rf/dispatch [::refused (ex-message %)])))))
|
||||
|
||||
(rf/reg-event-fx ::create (fn [_ [_ name]] {::create! (or name "untitled")}))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the socket
|
||||
|
||||
(defonce ^:private socket (atom nil))
|
||||
(defonce ^:private conn (atom {:id nil :tries 0 :timer nil}))
|
||||
|
||||
(defn- ws-url [id]
|
||||
(str (if (= "https:" (.. js/window -location -protocol)) "wss://" "ws://")
|
||||
(.. js/window -location -host) "/ws/projects/" id))
|
||||
|
||||
(declare open!)
|
||||
|
||||
(defn- retry-later! [id]
|
||||
(let [tries (:tries @conn)
|
||||
delay (min 30000 (* 500 (js/Math.pow 2 tries)))]
|
||||
(swap! conn assoc :tries (inc tries)
|
||||
:timer (js/setTimeout #(when (= id (:id @conn)) (open! id)) delay))))
|
||||
|
||||
(defn- open! [id]
|
||||
(let [s (js/WebSocket. (ws-url id))]
|
||||
(reset! socket s)
|
||||
(set! (.-onopen s) (fn [_] (swap! conn assoc :tries 0)))
|
||||
(set! (.-onmessage s) (fn [e] (rf/dispatch [::message (js/JSON.parse (.-data e))])))
|
||||
;; Only the CURRENT socket clears the roster and retries: closing the last
|
||||
;; project's on a switch must not wipe the new one's.
|
||||
(set! (.-onclose s) (fn [_]
|
||||
(when (identical? s @socket)
|
||||
(reset! socket nil)
|
||||
(rf/dispatch [::peers-reset])
|
||||
(retry-later! id))))))
|
||||
|
||||
(defn- connect! [id]
|
||||
(some-> (:timer @conn) js/clearTimeout)
|
||||
(when-let [s @socket] (set! (.-onclose s) nil) (.close s))
|
||||
(reset! socket nil)
|
||||
(reset! conn {:id id :tries 0 :timer nil})
|
||||
(rf/dispatch [::peers-reset])
|
||||
(when id (open! id)))
|
||||
|
||||
(rf/reg-fx
|
||||
::follow!
|
||||
(fn [{:keys [id name]}]
|
||||
(when id
|
||||
(let [here (.. js/window -location -pathname)
|
||||
path (project-path id name)]
|
||||
(cond
|
||||
(= path here) nil
|
||||
;; Renamed: the same page, a new slug, and no new history entry.
|
||||
(= id (path-id here)) (.replaceState js/history nil "" path)
|
||||
:else (.pushState js/history nil "" path))))
|
||||
(set! (.-title js/document) (if name (str name " — arthur") "arthur"))
|
||||
(when (not= id (:id @conn))
|
||||
(connect! id))))
|
||||
|
||||
(rf/reg-fx ::reconnect! (fn [_] (connect! (:id @conn))))
|
||||
|
||||
(def autosave?
|
||||
"Every edit saves. Off only for tests that need an edit held unsaved."
|
||||
true)
|
||||
|
||||
(def ^:private follow
|
||||
"The address, the title and the room follow the open project, and the
|
||||
document saves itself on every edit — `:paint/revision` is what moves when
|
||||
the document does. A save with nothing to send sends nothing, and one made
|
||||
while another is in flight goes when it lands.
|
||||
|
||||
THERE IS NO BARE PROJECT. A document with no id on screen at a project's
|
||||
address — a built-in example, opened from the menu — is saved at once, and
|
||||
becomes a project with an address of its own."
|
||||
(rf/->interceptor
|
||||
:id ::follow
|
||||
:after (fn [ctx]
|
||||
(let [db (get-in ctx [:effects :db] (get-in ctx [:coeffects :db]))
|
||||
before (get-in ctx [:coeffects :db :project])
|
||||
after (:project db)]
|
||||
(cond-> ctx
|
||||
(not= (select-keys before [:id :name]) (select-keys after [:id :name]))
|
||||
(update-in [:effects :fx] (fnil conj [])
|
||||
[::follow! (select-keys after [:id :name])])
|
||||
|
||||
(and autosave? (:id after) (vector? (:route db))
|
||||
(not= (:paint/revision db) (get-in ctx [:coeffects :db :paint/revision])))
|
||||
(update-in [:effects :fx] (fnil conj [])
|
||||
[:dispatch [::events.project/save {:auto? true}]])
|
||||
|
||||
(and (nil? (:id after)) (vector? (:route db))
|
||||
(or (:id before) (not= (:cid before) (:cid after))))
|
||||
(update-in [:effects :fx] (fnil conj [])
|
||||
[:dispatch [::events.project/save]]))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; presence
|
||||
|
||||
(rf/reg-event-db ::peers-reset (fn [db _] (assoc db :peers {})))
|
||||
|
||||
(defn- peer [^js m] {:cid (.-cid m) :user (.-user m)})
|
||||
|
||||
(rf/reg-event-fx
|
||||
::message
|
||||
(fn [{:keys [db]} [_ ^js m]]
|
||||
(case (.-kind m)
|
||||
"welcome" {:db (assoc db :peers {} :peer-cid (.-cid m))
|
||||
;; Anything written between our GET and our joining the room
|
||||
;; was broadcast to a room we were not in yet.
|
||||
:dispatch [::catch-up]}
|
||||
"roster" {:db (update db :peers into (map (fn [^js p] [(.-cid p) (peer p)]))
|
||||
(array-seq (.-peers m)))}
|
||||
("join" "state") {:db (assoc-in db [:peers (.-cid m)] (peer m))}
|
||||
"leave" {:db (update db :peers dissoc (.-cid m))}
|
||||
"delta" {:dispatch [::delta m]}
|
||||
"access" {:dispatch [::catch-up]}
|
||||
{})))
|
||||
|
||||
(rf/reg-sub
|
||||
::peers
|
||||
(fn [db _]
|
||||
(->> (vals (:peers db))
|
||||
(remove #(= (:cid %) (:peer-cid db)))
|
||||
(sort-by (juxt (comp nil? :user) :user)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; their writes
|
||||
|
||||
(defn- put [m path v] (if (nil? v) (dissoc m path) (assoc m path v)))
|
||||
|
||||
(defn- landed
|
||||
"Their change laid over ours, as `[local synced behind]`; a nil value is a
|
||||
removal.
|
||||
|
||||
A leaf we have changed and not saved keeps our value, and theirs waits in
|
||||
`behind` rather than in `synced`: `synced` is what we have SEEN, and putting
|
||||
theirs there would let our next save overwrite it without a word. `take?` is
|
||||
the first write winning — theirs was, so it goes on screen over ours."
|
||||
[local synced behind theirs take?]
|
||||
(let [pending? #(not= (get local %) (get synced %))]
|
||||
(reduce-kv (fn [[now seen behind] path v]
|
||||
(cond
|
||||
(not (pending? path)) [(put now path v) (put seen path v) behind]
|
||||
take? [(put now path v) (put seen path v) (dissoc behind path)]
|
||||
:else [now seen (assoc behind path v)]))
|
||||
[local synced behind] theirs)))
|
||||
|
||||
(rf/reg-fx
|
||||
::fetch-blocks!
|
||||
(fn [{:keys [keys then]}]
|
||||
(-> (js/Promise.all (into-array (map #(http/GET (str "/api/blocks/" %)) keys)))
|
||||
(.then #(rf/dispatch (conj then (project/store %))))
|
||||
(.catch #(rf/dispatch [::events.project/failed (ex-message %)])))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::remote
|
||||
;; `written` and `removed` against what we last synced; `blocks` is the store
|
||||
;; of any the new leaves name that we do not hold, once fetched.
|
||||
(fn [{:keys [db]} [_ {:keys [by written removed take?] at :seq :as change} blocks]]
|
||||
(let [cid (get-in db [:project :cid])
|
||||
entry (store/entry (:clip/current db))
|
||||
local (leaf/leaves cid (:clip entry))
|
||||
theirs (merge written (zipmap removed (repeat nil)))
|
||||
lost (if take?
|
||||
(count (filter #(not= (get local %) (get (:synced entry) %)) (keys theirs)))
|
||||
0)
|
||||
[now synced behind] (landed local (:synced entry) (:behind entry) theirs take?)
|
||||
have (merge (:store entry) blocks)
|
||||
lack (remove #(contains? have %) (project/block-keys now))
|
||||
status (fn [now synced]
|
||||
(if (pos? lost)
|
||||
(str lost (if (= 1 lost) " change" " changes")
|
||||
" of yours lost to someone else's at the same moment — in your undo list")
|
||||
(str (or by "someone") " saved r" at
|
||||
(when (not= now synced) " · yours unsaved"))))]
|
||||
(cond
|
||||
(seq lack)
|
||||
{::fetch-blocks! {:keys lack :then [::remote change]}}
|
||||
|
||||
(= now local)
|
||||
{:db (-> db
|
||||
(update :clip/current
|
||||
#(or (store/edit-entry! % (fn [e] (assoc e :synced synced
|
||||
:behind behind)))
|
||||
%))
|
||||
(assoc-in [:project :seq] at)
|
||||
;; Our own write, back from the room, changes nothing to say.
|
||||
(cond-> (or take? (not= by (get-in db [:me :username])))
|
||||
(assoc-in [:project :status] (status now synced))))}
|
||||
|
||||
:else
|
||||
(let [clip (leaf/clip cid now)
|
||||
;; Theirs, so not a step of ours to undo.
|
||||
db' (-> (edit/replace-entry db #(-> %
|
||||
(assoc :clip clip :synced synced
|
||||
:behind behind)
|
||||
(update :store merge blocks)))
|
||||
(edit/transport clip)
|
||||
(update :project merge
|
||||
{:seq at :status (status now synced)}))]
|
||||
(cond-> {:db db'}
|
||||
(not= (:fps clip) (get-in db [:clip :fps]))
|
||||
(assoc ::pb/seek! [(:fps clip) (pb/frames db') (get-in db [:playback :frame])])))))))
|
||||
|
||||
(defn- ours
|
||||
"The clip in a delta or a document that is the one open here."
|
||||
[db clips]
|
||||
(let [cid (get-in db [:project :cid])]
|
||||
(first (filter #(= cid (.-cid ^js %)) (array-seq clips)))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::delta
|
||||
(fn [{:keys [db]} [_ ^js m]]
|
||||
(let [local (get-in db [:project :seq])
|
||||
seq (.-seq m)]
|
||||
(cond
|
||||
(or (nil? local) (<= seq local)) {}
|
||||
;; A missed delta is a stale document forever, unless it is noticed.
|
||||
(> seq (inc local)) {:dispatch [::catch-up]}
|
||||
:else
|
||||
(let [^js c (ours db (.-clips m))]
|
||||
(cond-> {:db (cond-> (assoc-in db [:project :seq] seq)
|
||||
(.-name m) (assoc-in [:project :name] (.-name m)))}
|
||||
c (assoc :dispatch [::remote {:seq seq :by (.-by m)
|
||||
:written (project/tier1 (.-leaves c))
|
||||
:removed (vec (.-removed c))}])))))))
|
||||
|
||||
(rf/reg-fx
|
||||
::catch-up!
|
||||
(fn [[id take?]]
|
||||
(-> (http/GET (str "/api/projects/" id))
|
||||
(.then #(rf/dispatch [::caught-up % take?]))
|
||||
(.catch #(js/console.warn "catching up failed" %)))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::catch-up
|
||||
(fn [{:keys [db]} [_ take?]]
|
||||
(if-let [id (get-in db [:project :id])]
|
||||
{::catch-up! [id take?]}
|
||||
{})))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::caught-up
|
||||
;; The whole document, diffed against what we last synced: which leaves they
|
||||
;; wrote, and which they deleted.
|
||||
(fn [{:keys [db]} [_ ^js loaded take?]]
|
||||
(let [^js c (ours db (.-clips loaded))
|
||||
synced (:synced (store/entry (:clip/current db)))
|
||||
theirs (when c (project/tier1 (.-leaves c)))
|
||||
access {:owner (.-owner loaded) :editors (vec (.-editors loaded))
|
||||
:can-edit? (.-can_edit loaded)}]
|
||||
(cond-> {:db (update db :project merge access)}
|
||||
(and c (or take? (not= (.-seq loaded) (get-in db [:project :seq]))))
|
||||
(assoc :dispatch [::remote {:seq (.-seq loaded) :by nil :take? take?
|
||||
:written (into {} (remove (fn [[p v]] (= v (get synced p))))
|
||||
theirs)
|
||||
:removed (remove #(contains? theirs %) (keys synced))}])))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; who you are, and who else may write
|
||||
|
||||
(rf/reg-fx
|
||||
::request!
|
||||
(fn [{:keys [method url body then]}]
|
||||
(-> (http/request! method url body)
|
||||
(.then #(rf/dispatch (conj then %)))
|
||||
(.catch #(rf/dispatch [::refused (ex-message %)])))))
|
||||
|
||||
(rf/reg-event-fx ::who (fn [_ _] {::request! {:method "GET" :url "/api/me" :then [::signed]}}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::sign-in
|
||||
(fn [_ [_ mode username password]]
|
||||
{::request! {:method "POST" :url (str "/api/" (name mode))
|
||||
:body #js {:username username :password password}
|
||||
:then [::signed]}}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::sign-out
|
||||
(fn [_ _] {::request! {:method "POST" :url "/api/logout" :then [::signed]}}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::signed
|
||||
;; Who you are changes what you may write and what the room calls you.
|
||||
(fn [{:keys [db]} [_ ^js who]]
|
||||
(let [username (.-username who)
|
||||
changed? (not= username (get-in db [:me :username]))]
|
||||
(cond-> {:db (assoc db :me {:username username})}
|
||||
(and changed? (contains? db :me)) (assoc ::reconnect! nil
|
||||
:fx [[:dispatch [::catch-up]]
|
||||
[:dispatch [::events.project/list]]])))))
|
||||
|
||||
(rf/reg-event-db ::refused (fn [db [_ message]] (assoc-in db [:me :error] message)))
|
||||
|
||||
(rf/reg-sub ::me (fn [db _] (:me db)))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::add-editor
|
||||
(fn [{:keys [db]} [_ username]]
|
||||
{::request! {:method "POST" :url (str "/api/projects/" (get-in db [:project :id]) "/editors")
|
||||
:body #js {:username username} :then [::editors]}}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::remove-editor
|
||||
(fn [{:keys [db]} [_ username]]
|
||||
{::request! {:method "DELETE"
|
||||
:url (str "/api/projects/" (get-in db [:project :id]) "/editors/"
|
||||
(js/encodeURIComponent username))
|
||||
:then [::editors]}}))
|
||||
|
||||
(rf/reg-event-db
|
||||
::editors
|
||||
(fn [db [_ ^js answer]]
|
||||
(-> (assoc-in db [:project :editors] (vec (.-editors answer)))
|
||||
(update :me dissoc :error))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; snapshots: named versions, now that every edit saves itself
|
||||
|
||||
(defn- snapshots-url [db] (str "/api/projects/" (get-in db [:project :id]) "/revisions"))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::snapshots
|
||||
(fn [{:keys [db]} _]
|
||||
{::request! {:method "GET" :url (snapshots-url db) :then [::snapshots-listed]}}))
|
||||
|
||||
(rf/reg-event-db
|
||||
::snapshots-listed
|
||||
(fn [db [_ ^js answer]]
|
||||
(assoc db :snapshots
|
||||
(mapv (fn [^js r] {:id (.-id r) :name (.-summary r) :author (.-author r)
|
||||
:seq (.-seq r) :created (.-created r)})
|
||||
(array-seq (.-revisions answer))))))
|
||||
|
||||
(rf/reg-sub ::snapshot-list (fn [db _] (:snapshots db)))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::snapshot
|
||||
(fn [{:keys [db]} [_ name]]
|
||||
{::request! {:method "POST" :url (snapshots-url db) :body #js {:summary name}
|
||||
:then [::snapshotted name]}}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::snapshotted
|
||||
(fn [{:keys [db]} [_ name _]]
|
||||
{:db (assoc-in db [:project :status] (str "snapshot \"" name "\" taken"))
|
||||
:dispatch [::snapshots]}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::restore
|
||||
;; An ordinary write on the server, which comes back to every open tab —
|
||||
;; this one included — as a delta.
|
||||
(fn [{:keys [db]} [_ {:keys [id name]}]]
|
||||
{::request! {:method "POST" :url (str (snapshots-url db) "/" id "/restore")
|
||||
:then [::restored name]}}))
|
||||
|
||||
(rf/reg-event-db
|
||||
::restored
|
||||
(fn [db [_ name _]] (assoc-in db [:project :status] (str "restored \"" name "\""))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn start!
|
||||
"Follow the open project from now on, show what the address names — the
|
||||
index, or a project — and answer the back button."
|
||||
[]
|
||||
(rf/reg-global-interceptor follow)
|
||||
(rf/dispatch [::who])
|
||||
(route!)
|
||||
(.addEventListener js/window "popstate" route!))
|
||||
83
frontend/src/arthur/events/edit.cljs
Normal file
83
frontend/src/arthur/events/edit.cljs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
(ns arthur.events.edit
|
||||
"The one way an event changes the loaded document.
|
||||
|
||||
Three things have to happen together and the bug is any one of them being
|
||||
forgotten: the clip in `footage/store` is edited, the id app-db refers to it by
|
||||
is updated — `edit-clip!` may INSTALL A COPY, because a built-in clip is a
|
||||
delayed value that must stay reusable — and `:paint/revision` is bumped so the
|
||||
layer-3 subs downstream of `::render/clip` recompute. The revision exists
|
||||
because the clip itself is behind a handle: app-db holds an id, the id does not
|
||||
change when the document does, and a sub keyed only on the id would never see
|
||||
the edit.
|
||||
|
||||
It started life private inside `events/paint`, which was right while polygons
|
||||
were the only thing anyone could edit. They are not.
|
||||
|
||||
It is also where UNDO is recorded, for the same reason: being the one way a
|
||||
person changes the document, it is the one place that sees every change they
|
||||
make — and nothing else. A collaborator's write and an undo itself go through
|
||||
`replace-entry`, which is this without the recording."
|
||||
(:require [arthur.domain.history :as history]
|
||||
[arthur.domain.leaf :as leaf]
|
||||
[arthur.footage.store :as store]))
|
||||
|
||||
(defn leaves
|
||||
"The clip as leaves, which is what a history step is made of; nil for a clip
|
||||
that has no leaf form."
|
||||
[clip]
|
||||
(try (leaf/leaves "u" clip) (catch :default _ nil)))
|
||||
|
||||
(defn- recorded [f]
|
||||
(fn [entry]
|
||||
(let [after (f entry)
|
||||
b (when-not (identical? (:clip entry) (:clip after)) (leaves (:clip entry)))
|
||||
a (when b (leaves (:clip after)))]
|
||||
(cond-> after
|
||||
a (assoc :history (history/record (:history entry) b a (js/Date.now)))))))
|
||||
|
||||
(defn replace-entry
|
||||
"Apply `f` to the loaded entry without recording it as a step of yours."
|
||||
[db f]
|
||||
(let [id (store/edit-entry! (:clip/current db) f)]
|
||||
(if id
|
||||
(-> db
|
||||
(assoc :clip/current id)
|
||||
(update :paint/revision (fnil inc 0))
|
||||
(update :project merge {:status "edited · unsaved"}))
|
||||
db)))
|
||||
|
||||
(defn edit-entry
|
||||
"Apply `f` to the loaded ENTRY — the document and the blocks, footage and
|
||||
source tracks beside it — and return the new db. For an edit that brings tier-2
|
||||
data in with it, which a document edit alone cannot."
|
||||
[db f]
|
||||
(replace-entry db (recorded f)))
|
||||
|
||||
(defn transport
|
||||
"App-db's copy of what the transport reads off the clip, after the clip was
|
||||
replaced under it — as `::events.project/project-setting` writes it."
|
||||
[db clip]
|
||||
(cond-> (update db :clip merge (select-keys clip [:width :height]))
|
||||
(not= (:fps clip) (get-in db [:clip :fps]))
|
||||
(update :clip merge {:fps (:fps clip)})))
|
||||
|
||||
(defn history
|
||||
"Apply `f` to the loaded entry's undo history, which is not an edit: nothing
|
||||
is redrawn and nothing becomes unsaved."
|
||||
[db f]
|
||||
(if-let [id (store/edit-entry! (:clip/current db) #(update % :history f))]
|
||||
(assoc db :clip/current id)
|
||||
db))
|
||||
|
||||
(defn edit
|
||||
"Apply `f` to the loaded clip and return the new db."
|
||||
[db f]
|
||||
(edit-entry db #(update % :clip f)))
|
||||
|
||||
(defn transaction
|
||||
"One command is one undo step, independent of neighboring edits or timing."
|
||||
[db f]
|
||||
(-> db
|
||||
(history history/hold)
|
||||
(edit f)
|
||||
(history history/settle)))
|
||||
|
|
@ -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.
|
||||
|
|
@ -10,7 +10,8 @@
|
|||
WHAT GOES IN THE DB IS THE REQUEST AND THE PROGRESS, never the frames. A
|
||||
megabyte of PNG in app-db would be compared by every mounted subscription on
|
||||
every tick."
|
||||
(:require [arthur.domain.palette :as pal]
|
||||
(:require [arthur.domain.clip :as clip]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.export :as export]
|
||||
[arthur.export.frames :as frames]
|
||||
[arthur.footage.store :as store]
|
||||
|
|
@ -44,84 +45,87 @@
|
|||
(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
|
||||
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))
|
||||
(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)
|
||||
Instances are ordered and labelled by `clip/node-label`, never by id: a uuid
|
||||
sorts at random and means nothing to read."
|
||||
[clip open]
|
||||
(let [label #(clip/node-label clip %1 %2)
|
||||
instances (->> (get-in clip [:symbols open :nodes])
|
||||
(filter (comp #{:instance} :kind val))
|
||||
(sort-by (fn [[id n]] [(label id n) (str id)])))]
|
||||
(into (mapv (fn [sid] {:symbol sid :label (name sid)})
|
||||
(sort-by str (keys (:symbols clip))))
|
||||
(mapv (fn [[id n]]
|
||||
{:timeline :main :isolate id
|
||||
:label (or (:name n) (str id))})
|
||||
placements))))
|
||||
{:symbol open :isolate id :label (label id n)})
|
||||
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
|
||||
:picture-fps (get-in db [:clip :display-fps])}))))
|
||||
{sid :symbol isolate :isolate} (target db)]
|
||||
(export/plan {:clip clip :symbol sid :zoom (get-in db [:export :zoom])
|
||||
:isolate isolate}))))
|
||||
|
||||
(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,30 +138,29 @@
|
|||
{}
|
||||
(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
|
||||
;; through. Read here rather than in the fx so that the effect
|
||||
;; takes data and nothing else.
|
||||
:palette (get {:arthur/default pal/index-of}
|
||||
(:palette db) pal/index-of)
|
||||
:ramp (get {:arthur/default pal/rgb} (:palette db) pal/rgb)
|
||||
:palette (pal/compile (:clip entry))
|
||||
:ramp (:ramp (pal/compile (:clip entry)))
|
||||
:zoom zoom
|
||||
: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 +200,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 /\"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
The frames come from the server by URL since step 9 — see `flow/ingest` — and the
|
||||
detector's identity comes from the server too, because it goes into the content
|
||||
address of every block this produces."
|
||||
(:require [arthur.domain.clip :as clip]
|
||||
(:require [arthur.domain.bring :as bring]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.span :as span]
|
||||
[arthur.events.edit :as edit]
|
||||
[arthur.events.ui :as ui]
|
||||
[arthur.events.playback :as pb]
|
||||
[arthur.flow.detect :as detect]
|
||||
[arthur.flow.ingest :as ingest]
|
||||
|
|
@ -14,6 +18,7 @@
|
|||
[arthur.footage.store :as store]
|
||||
[arthur.domain.landmarks :as lm]
|
||||
[arthur.fx.http :as http]
|
||||
[clojure.string :as string]
|
||||
[re-frame.core :as rf]))
|
||||
|
||||
(defonce ^:private clock (atom 0))
|
||||
|
|
@ -44,8 +49,12 @@
|
|||
|
||||
The work happens inside `decode!`'s callback, and the promise it returns is the
|
||||
backpressure: the decoder does not run ahead of the detector, so a 900-frame
|
||||
take does not hold 900 decoded frames at 1440x1920 in memory."
|
||||
[manifest model]
|
||||
take does not hold 900 decoded frames at 1440x1920 in memory.
|
||||
|
||||
Only source frames `[start end)` are measured. Decoding still begins at frame
|
||||
0, because every frame after the first is coded against the ones before it,
|
||||
and it stops at `end`; frames before `start` are decoded and dropped unread."
|
||||
[manifest model [start end]]
|
||||
(let [[w h] [(:width manifest) (:height manifest)]
|
||||
canvas (.createElement js/document "canvas")
|
||||
ctx (.getContext canvas "2d" #js {:willReadFrequently true})
|
||||
|
|
@ -53,22 +62,23 @@
|
|||
raw (atom [])
|
||||
crops (atom [])
|
||||
inner (atom [])
|
||||
total (:frames manifest)]
|
||||
total end]
|
||||
(set! (.-width canvas) w)
|
||||
(set! (.-height canvas) h)
|
||||
(rf/dispatch [::progress "loading the video…"])
|
||||
(-> (ingest/stream! (ingest/stream-url manifest) total)
|
||||
(-> (ingest/stream! (ingest/stream-url manifest) (:frames manifest))
|
||||
(.then
|
||||
(fn [stream]
|
||||
(ingest/decode!
|
||||
stream fps w h
|
||||
(update stream :units subvec 0 end) fps w h
|
||||
(fn [i frame]
|
||||
(when (>= i start)
|
||||
(.drawImage ctx frame 0 0)
|
||||
;; EVERY FACE ON THIS FRAME, each with its own mouth crop taken
|
||||
;; while the frame's pixels are still on the canvas. Which of these
|
||||
;; detections belongs to which subject is not decided here — the
|
||||
;; answer needs the whole take — so all three vectors stay in
|
||||
;; DETECTION ORDER and `detect/tracks` re-keys them afterwards.
|
||||
;; while the frame's pixels are still on the canvas. Which of
|
||||
;; these detections belongs to which subject is not decided here
|
||||
;; — the answer needs the whole take — so all three vectors stay
|
||||
;; in DETECTION ORDER and `detect/tracks` re-keys them afterwards.
|
||||
(let [faces (detect/detect! model canvas (ingest/frame-ms fps i))
|
||||
boxes (mapv (fn [face]
|
||||
(interior/crop (mapv #(nth face %) lm/LIPS-INNER)
|
||||
|
|
@ -89,9 +99,11 @@
|
|||
;; frame counter frozen on its last value — which reads as the
|
||||
;; decoder hanging, and was diagnosed as that twice.
|
||||
(swap! inner conj (mapv #(source/measure-crop take/knobs %)
|
||||
frame-crops)))
|
||||
frame-crops))))
|
||||
(when (or (zero? i) (zero? (mod (inc i) 4)) (= (inc i) total))
|
||||
(rf/dispatch [::progress (str "detecting " (inc i) "/" total)]))
|
||||
(rf/dispatch [::progress (if (< i start)
|
||||
(str "seeking " (inc i) "/" start)
|
||||
(str "detecting " (- (inc i) start) "/" (- end start)))]))
|
||||
;; Yield, so the status and the transport paint between synchronous
|
||||
;; MediaPipe calls. `decode!` waits on this before feeding more.
|
||||
(js/Promise. (fn [done] (js/setTimeout done 0)))))))
|
||||
|
|
@ -139,11 +151,11 @@
|
|||
:detector detector})
|
||||
_ (mark! "build-clip: freeze")
|
||||
built (:clip frozen)
|
||||
source-blocks (source/pack-subjects (:id (:analysis built)) subjects)
|
||||
analysis-id (-> built :analyses keys first)
|
||||
source-blocks (source/pack-subjects analysis-id 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
|
||||
:source-inputs (assoc source-inputs :subjects with-presence)
|
||||
|
|
@ -252,11 +264,15 @@
|
|||
(js/Promise.resolve track)
|
||||
(:subjects track)))
|
||||
|
||||
(rf/reg-fx
|
||||
::begin!
|
||||
(fn [footage-id]
|
||||
(defn- analyse!
|
||||
"Promise of source frames `[start end)` of footage `footage-id`, detected,
|
||||
measured and frozen — `{:clip :store ...}` as `build-clip` makes it, with the
|
||||
footage reading as though it were only those frames. A saved analysis of
|
||||
exactly that range is reused instead of detecting again."
|
||||
[footage-id [start end]]
|
||||
(-> (js/Promise.all #js [(ingest/manifest! footage-id) (ingest/detector!)])
|
||||
(.then (fn [[manifest detector]]
|
||||
(.then (fn [[full detector]]
|
||||
(let [manifest (ingest/slice full start end)]
|
||||
(reset! clock (js/Date.now))
|
||||
(rf/dispatch [::progress "looking for saved analysis…"])
|
||||
(-> (cached-source! manifest detector)
|
||||
|
|
@ -270,20 +286,22 @@
|
|||
(= done total))
|
||||
(rf/dispatch
|
||||
[::progress (str "measuring " done "/" total)]))))
|
||||
(.then (fn [measured]
|
||||
(build-clip manifest detector measured)))))
|
||||
(.then #(build-clip manifest detector %))))
|
||||
(do (rf/dispatch [::progress "loading MediaPipe…"])
|
||||
(-> (detect/landmarker!)
|
||||
(.then (fn [model]
|
||||
(mark! "MediaPipe ready")
|
||||
(rf/dispatch [::progress "opening the video…"])
|
||||
(-> (detect-frames! manifest model)
|
||||
(.then (fn [fresh]
|
||||
(build-clip manifest detector fresh))))))))))))))
|
||||
(.then (fn [entry]
|
||||
(detect-frames! full model [start end])))
|
||||
(.then #(build-clip manifest detector %)))))))))))))
|
||||
|
||||
(rf/reg-fx
|
||||
::convert!
|
||||
(fn [{:keys [footage-id range] :as request}]
|
||||
(-> (analyse! footage-id range)
|
||||
(.then (fn [built]
|
||||
(mark! "build-clip: done")
|
||||
(let [id (store/install! entry)]
|
||||
(rf/dispatch [::loaded id (:summary entry)]))))
|
||||
(rf/dispatch [::converted request built])))
|
||||
(.catch (fn [error]
|
||||
(js/console.error error)
|
||||
;; A run that ended badly may have ended on a MediaPipe graph
|
||||
|
|
@ -295,8 +313,12 @@
|
|||
(rf/reg-fx
|
||||
::list!
|
||||
(fn [_]
|
||||
(-> (ingest/available!)
|
||||
(.then (fn [footage] (rf/dispatch [::listed footage])))
|
||||
(-> (js/Promise.all #js [(ingest/available!)
|
||||
(.then (http/GET "/api/sounds")
|
||||
#(:sounds (js->clj % :keywordize-keys true)))
|
||||
(.then (http/GET "/api/images")
|
||||
#(:images (js->clj % :keywordize-keys true)))])
|
||||
(.then (fn [[footage sounds images]] (rf/dispatch [::listed footage sounds images])))
|
||||
(.catch (fn [error]
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
|
||||
|
||||
|
|
@ -312,12 +334,32 @@
|
|||
(.catch (fn [error]
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))])))))
|
||||
|
||||
(defn- sending
|
||||
"A progress callback that puts the whole percentage sent in the status line.
|
||||
|
||||
ONLY WHEN THE PERCENTAGE MOVES. The browser fires upload progress as often as
|
||||
it pleases and a status line has something new to say a hundred times at most,
|
||||
and each dispatch here re-renders the pane.
|
||||
|
||||
Worth having at all because the transfer is the longest part of an import on
|
||||
anything but a local server, and it was the part with no number on it:
|
||||
`uploading video…` sat unchanged from the first byte to the last, however many
|
||||
there were, and only the extraction that followed it ever counted. See
|
||||
`arthur.fx.http/POST-form`."
|
||||
[label]
|
||||
(let [reported (atom -1)]
|
||||
(fn [fraction]
|
||||
(let [percent (js/Math.round (* 100 fraction))]
|
||||
(when (not= percent @reported)
|
||||
(reset! reported percent)
|
||||
(rf/dispatch [::progress (str label " " percent "%")]))))))
|
||||
|
||||
(rf/reg-fx
|
||||
::upload!
|
||||
(fn [file]
|
||||
(let [form (js/FormData.)]
|
||||
(.append form "file" file)
|
||||
(-> (http/POST-form "/api/sources" form)
|
||||
(-> (http/POST-form "/api/sources" form (sending "uploading video…"))
|
||||
(.then (fn [^js source]
|
||||
(rf/dispatch [::progress "queued for extraction…"])
|
||||
(http/POST "/api/extractions" #js {:source (.-id source)
|
||||
|
|
@ -326,19 +368,55 @@
|
|||
(.catch (fn [error]
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))])))))))
|
||||
|
||||
(rf/reg-fx
|
||||
::upload-sound!
|
||||
(fn [file]
|
||||
(let [form (js/FormData.)]
|
||||
(.append form "file" file)
|
||||
(-> (http/POST-form "/api/sounds" form (sending "uploading sound…"))
|
||||
(.then (fn [^js sound] (rf/dispatch [::uploaded (.-id sound) "sound imported"])))
|
||||
(.catch (fn [error]
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))])))))))
|
||||
|
||||
(rf/reg-fx
|
||||
::upload-image!
|
||||
(fn [file]
|
||||
(let [form (js/FormData.)]
|
||||
(.append form "file" file)
|
||||
(-> (http/POST-form "/api/images" form (sending "uploading image…"))
|
||||
(.then (fn [^js image] (rf/dispatch [::uploaded (.-id image) "image imported"])))
|
||||
(.catch (fn [error]
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))])))))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::upload
|
||||
(fn [{:keys [db]} [_ file]]
|
||||
(fn [{:keys [db]} [_ ^js file]]
|
||||
;; By type, and by name for a browser that leaves the type empty.
|
||||
(let [kind (when file
|
||||
(cond
|
||||
(or (string/starts-with? (.-type file) "audio/")
|
||||
(re-find #"(?i)\.(mp3|wav|aiff?|flac|ogg|m4a|aac)$" (.-name file)))
|
||||
:sound
|
||||
(or (string/starts-with? (.-type file) "image/")
|
||||
(re-find #"(?i)\.(png|jpe?g|gif|webp)$" (.-name file)))
|
||||
:image
|
||||
:else :video))]
|
||||
(if (or (nil? file) (get-in db [:footage :loading?]))
|
||||
{}
|
||||
{:db (update db :footage merge {:loading? true :status "uploading video…"})
|
||||
::upload! file})))
|
||||
{:db (update db :footage merge {:loading? true
|
||||
:status (str "uploading " (name kind) "…")})
|
||||
(case kind :sound ::upload-sound! :image ::upload-image! ::upload!) file}))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::uploaded
|
||||
(fn [{:keys [db]} [_ footage-id]]
|
||||
{:db (update db :footage merge {:loading? false :chosen footage-id
|
||||
:status "video extracted — load frames to analyze"})
|
||||
(fn [{:keys [db]} [_ id status]]
|
||||
;; Into the pool, and no further. An upload is media for this project; turning
|
||||
;; it into a symbol or a sound is a separate decision — which frames, what
|
||||
;; name, where — made by dropping it where it should go.
|
||||
{:db (update db :footage #(-> %
|
||||
(merge {:loading? false :status (or status "video extracted")})
|
||||
(cond-> (not status) (assoc :chosen id))
|
||||
(update :uploaded (fnil conj #{}) id)))
|
||||
:dispatch [::refresh]}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
|
|
@ -347,28 +425,72 @@
|
|||
|
||||
(rf/reg-event-db
|
||||
::listed
|
||||
(fn [db [_ footage]]
|
||||
(fn [db [_ footage sounds images]]
|
||||
(update db :footage merge
|
||||
(cond-> {:available (vec footage)
|
||||
:sounds (vec sounds)
|
||||
:images (vec images)
|
||||
:chosen (or (:chosen (:footage db)) (:id (first footage)))}
|
||||
(empty? footage) (assoc :status "upload a video to begin")))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::choose
|
||||
(fn [db [_ id]] (assoc-in db [:footage :chosen] id)))
|
||||
(fn [db [_ id]]
|
||||
(-> db
|
||||
(assoc-in [:footage :chosen] id)
|
||||
(ui/selected [:footage id]))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; renaming an asset
|
||||
;;
|
||||
;; A SERVER WRITE, NOT A DOCUMENT EDIT, and so not on the undo list. Footage and
|
||||
;; sounds live beside projects rather than inside one — the pool's ALL ASSETS
|
||||
;; folder is exactly that — so a label is shared by every project that uses the
|
||||
;; row, and undoing an edit to this document must not reach out and rename
|
||||
;; something another one is showing.
|
||||
;;
|
||||
;; Written through optimistically. The lists in app-db are what the pool draws
|
||||
;; from; waiting for the round trip would leave the old name under the cursor for
|
||||
;; as long as the request takes, and the failure is visible and recoverable —
|
||||
;; `::failed` says so, and `::refresh` puts back whatever the server actually
|
||||
;; holds.
|
||||
|
||||
(defn- relabelled
|
||||
"Replace one row's `:label` in a list held by id."
|
||||
[rows id label]
|
||||
(mapv #(cond-> % (= id (:id %)) (assoc :label label)) rows))
|
||||
|
||||
(rf/reg-fx
|
||||
::relabel!
|
||||
(fn [{:keys [url label]}]
|
||||
(-> (http/PATCH url #js {:label label})
|
||||
(.then (fn [_]
|
||||
;; Only a CLEARED label needs the answer. The server's fallback
|
||||
;; is the name the file was uploaded under, which this client
|
||||
;; cannot reconstruct — footage falls back to its source and a
|
||||
;; sound to its filename — so the one case the optimistic write
|
||||
;; cannot guess is the one case that re-lists.
|
||||
(when (empty? label) (rf/dispatch [::refresh]))))
|
||||
(.catch (fn [error]
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))])
|
||||
(rf/dispatch [::refresh]))))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::load
|
||||
(fn [{:keys [db]} _]
|
||||
(let [chosen (get-in db [:footage :chosen])]
|
||||
(cond
|
||||
(get-in db [:footage :loading?]) {}
|
||||
(nil? chosen)
|
||||
{:db (assoc-in db [:footage :status] "upload a video to begin")}
|
||||
:else
|
||||
{:db (update db :footage merge {:loading? true :status "reading the manifest…"})
|
||||
::pb/pause! nil
|
||||
::begin! chosen}))))
|
||||
::relabel
|
||||
(fn [{:keys [db]} [_ kind id value]]
|
||||
;; `kind` is `:footage`, `:sound` or `:image`: resources with one field between
|
||||
;; them, and one event rather than two that differ by a path and a URL.
|
||||
(let [label (string/trim (str value))
|
||||
[key url] (case kind
|
||||
:footage [:available (str "/api/footage/" id)]
|
||||
:sound [:sounds (str "/api/sounds/" id)]
|
||||
:image [:images (str "/api/images/" id)]
|
||||
[nil nil])]
|
||||
(if (or (nil? id) (nil? key))
|
||||
{}
|
||||
{:db (cond-> db
|
||||
(seq label) (update-in [:footage key] relabelled id label))
|
||||
::relabel! {:url url :label label}}))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::progress
|
||||
|
|
@ -380,15 +502,116 @@
|
|||
(assoc db :footage (assoc (:footage db)
|
||||
:loading? false :status (str "footage failed: " message)))))
|
||||
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; footage -> a symbol
|
||||
;;
|
||||
;; Dropping a video asks first. `[:ui :convert]` is the question — which footage,
|
||||
;; which of its frames, what to call the result — and where the answer will be
|
||||
;; placed: `:host`, `:frame` and `:point` are the drop's, captured when it happened
|
||||
;; so that switching tabs while detection runs does not move where it lands.
|
||||
|
||||
(rf/reg-event-db
|
||||
::ask-convert
|
||||
(fn [db [_ {:keys [frames label] :as footage} frame point target]]
|
||||
(assoc-in db [:ui :convert]
|
||||
(merge (select-keys footage [:id :label :frames :fps :video :width :height])
|
||||
{:range [0 frames]
|
||||
:name (string/replace (str label) #"\.[^.]*$" "")
|
||||
:host (get-in db [:ui :open]) :frame frame :point point
|
||||
:target target}))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::convert-set
|
||||
(fn [db [_ k v]] (assoc-in db [:ui :convert k] v)))
|
||||
|
||||
(rf/reg-event-db
|
||||
::convert-cancel
|
||||
(fn [db _]
|
||||
(if (get-in db [:footage :loading?]) db (update db :ui dissoc :convert))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::loaded
|
||||
(fn [{:keys [db]} [_ id summary]]
|
||||
(let [clip (store/entry id)]
|
||||
::convert
|
||||
(fn [{:keys [db]} _]
|
||||
(let [{:keys [id range] :as request} (get-in db [:ui :convert])]
|
||||
(if (or (nil? request) (get-in db [:footage :loading?]))
|
||||
{}
|
||||
{:db (update db :footage merge {:loading? true :status "starting…"})
|
||||
::pb/pause! nil
|
||||
::convert! {:footage-id id :range range :request request}}))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::convert-tracing
|
||||
;; The same question answered the other way: the chosen frames become a tracing
|
||||
;; layer to draw over, with nothing detected and nothing measured. Its frames
|
||||
;; are the footage's own and its rate the footage's, so it plays at the speed it
|
||||
;; was filmed in a project at any rate.
|
||||
(fn [{:keys [db]} _]
|
||||
(let [{:keys [id range fps width height name frame point target] :as request}
|
||||
(get-in db [:ui :convert])]
|
||||
(if (or (nil? request) (get-in db [:footage :loading?]))
|
||||
{}
|
||||
{:db (update db :ui dissoc :convert)
|
||||
:dispatch [::ui/drop-tracing
|
||||
{:name name :type :trace
|
||||
:media {:footage id :range range}
|
||||
:frames (- (second range) (first range)) :fps fps
|
||||
:width width :height height :nodes {}}
|
||||
frame point target]}))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::converted
|
||||
;; A TAKE IS A CLIP LIKE ANY OTHER, placed into whichever symbol the drop names
|
||||
;; — see `ui/drop-destination` — rather than into a container invented for it.
|
||||
(fn [{:keys [db]} [_ {{:keys [name frame point range target]} :request footage-id :footage-id}
|
||||
built]]
|
||||
(let [entry (store/entry (:clip/current db))
|
||||
uuid (random-uuid)
|
||||
fps (get-in db [:clip :fps])
|
||||
{:keys [clip sid subject-ids]}
|
||||
(bring/take (:clip entry) (:clip built) name footage-id range)
|
||||
st (merge (:store entry) (:store built))
|
||||
imported-frames (clip/output-frames clip sid)
|
||||
source-fps (get-in built [:clip :fps])
|
||||
where (if point
|
||||
(ui/creation-destination db clip st frame)
|
||||
(ui/drop-destination db clip st frame target))
|
||||
point (ui/destination-point where point)
|
||||
result (if (:refused where)
|
||||
where
|
||||
(span/place-symbol (:clip where) st (:sid where)
|
||||
uuid sid (:at where)
|
||||
{:extent :grow-symbol :point point
|
||||
:remainder-id (random-uuid)}))]
|
||||
(if-let [why (or (:refused where) (:refused result))]
|
||||
{: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))
|
||||
::pb/pause! nil})))
|
||||
(update :ui dissoc :convert)
|
||||
(update :footage merge {:loading? false :status why}))}
|
||||
(let [db (edit/edit-entry
|
||||
db
|
||||
#(let [analysis-id (-> built :clip :analyses keys first)
|
||||
remap-subjects
|
||||
(fn [inputs]
|
||||
(update inputs :subjects
|
||||
(fn [subjects]
|
||||
(into {} (map (fn [[old new]] [new (get subjects old)]))
|
||||
subject-ids))))]
|
||||
(-> (assoc % :clip (:clip result) :store st)
|
||||
(update-in [:sources analysis-id]
|
||||
(fn [source]
|
||||
{:footage-id (:footage-id built)
|
||||
:source-blocks (:source-blocks built)
|
||||
:source-inputs
|
||||
(update (remap-subjects (:source-inputs built))
|
||||
:subjects merge
|
||||
(get-in source [:source-inputs :subjects]))})))))]
|
||||
{:db (-> db
|
||||
(update :ui dissoc :convert)
|
||||
(ui/selected
|
||||
[:node (:sid where) uuid (conj (vec (:path where)) uuid)])
|
||||
(update :footage merge
|
||||
{:loading? false
|
||||
:status (str "made " name " · " imported-frames " frames at " fps " fps"
|
||||
(when (not= fps source-fps)
|
||||
(str " · sampled from " source-fps " fps")))}))
|
||||
:dispatch [::pb/refresh-clock]})))))
|
||||
|
|
|
|||
98
frontend/src/arthur/events/history.cljs
Normal file
98
frontend/src/arthur/events/history.cljs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
(ns arthur.events.history
|
||||
"Undo and redo: `domain/history` against the open document, and the keys.
|
||||
|
||||
An undone step is an ordinary unsaved edit afterwards, and the next save sends
|
||||
it — so undo reaches a collaborator the way any change of yours does."
|
||||
(:require [arthur.domain.history :as history]
|
||||
[arthur.domain.leaf :as leaf]
|
||||
[arthur.events.edit :as edit]
|
||||
[arthur.events.playback :as pb]
|
||||
[arthur.events.ui :as ui]
|
||||
[arthur.footage.store :as store]
|
||||
[re-frame.core :as rf]))
|
||||
|
||||
(defn- step
|
||||
"One step of `move` on `db`: `{:db :fps :ok?}`, `:ok?` false when there was
|
||||
nothing to do or the step was refused."
|
||||
[db move done]
|
||||
(let [entry (store/entry (:clip/current db))
|
||||
leaves (edit/leaves (:clip entry))
|
||||
r (when leaves (move (:history entry) leaves))]
|
||||
(cond
|
||||
(nil? r)
|
||||
{:db (assoc-in db [:project :status] (str "nothing to " (subs done 0 4))) :ok? false}
|
||||
|
||||
(:blocked r)
|
||||
{:db (-> (edit/replace-entry db #(assoc % :history (:history r)))
|
||||
(assoc-in [:project :status]
|
||||
(str "not " done ": " (:label (:blocked r))
|
||||
" — someone else has changed it since")))
|
||||
:ok? false}
|
||||
|
||||
:else
|
||||
(let [clip (leaf/clip "u" (:leaves r))
|
||||
valid? (fn [[kind host node]]
|
||||
(or (not= :node kind)
|
||||
(nil? node)
|
||||
(get-in clip [:symbols host :nodes node])))
|
||||
selections (vec (filter valid? (get-in db [:ui :selections])))
|
||||
primary (get-in db [:ui :selection])
|
||||
primary (if (valid? primary) primary (peek selections))
|
||||
label (:label (peek (get (:history r) (if (= done "undone") :undone :done))))]
|
||||
{:ok? true
|
||||
:db (-> (edit/replace-entry db #(assoc % :clip clip :history (:history r)))
|
||||
(edit/transport clip)
|
||||
(assoc-in [:ui :selections] selections)
|
||||
(assoc-in [:ui :selection] primary)
|
||||
(assoc-in [:project :status] (str done " " label " · unsaved")))}))))
|
||||
|
||||
(defn- steps
|
||||
"`n` steps, stopping at the first that cannot be taken."
|
||||
[db move done n]
|
||||
(let [fps (get-in db [:clip :fps])
|
||||
db (loop [db db n n]
|
||||
(let [r (step db move done)]
|
||||
(if (and (:ok? r) (< 1 n)) (recur (:db r) (dec n)) (:db r))))]
|
||||
(cond-> {:db db}
|
||||
(not= fps (get-in db [:clip :fps]))
|
||||
(assoc ::pb/seek! [(get-in db [:clip :fps]) (pb/frames db) (get-in db [:playback :frame])]))))
|
||||
|
||||
(rf/reg-event-fx ::undo (fn [{:keys [db]} [_ n]] (steps db history/undo "undone" (or n 1))))
|
||||
(rf/reg-event-fx ::redo (fn [{:keys [db]} [_ n]] (steps db history/redo "redone" (or n 1))))
|
||||
|
||||
(rf/reg-event-db ::hold (fn [db _] (edit/history db history/hold)))
|
||||
(rf/reg-event-db ::settle (fn [db _] (edit/history db history/settle)))
|
||||
|
||||
(rf/reg-sub
|
||||
::steps
|
||||
;; The history is on the entry, outside app-db; the revision is what moves
|
||||
;; when the entry does, undo and redo included.
|
||||
(fn [db _]
|
||||
(:paint/revision db)
|
||||
(history/steps (:history (store/entry (:clip/current db))))))
|
||||
|
||||
(defn- typing? [^js target]
|
||||
(or (#{"INPUT" "TEXTAREA" "SELECT"} (.-tagName target)) (.-isContentEditable target)))
|
||||
|
||||
(defn install-keys!
|
||||
"The document edit keys. Not while typing in a field, where the browser's own
|
||||
clipboard and undo stack are the ones wanted."
|
||||
[]
|
||||
(.addEventListener
|
||||
js/window "keydown"
|
||||
(fn [^js e]
|
||||
(when-not (typing? (.-target e))
|
||||
(let [k (.toLowerCase (.-key e))]
|
||||
(when-let [ev (if (and (or (.-metaKey e) (.-ctrlKey e))
|
||||
(not (.-altKey e)))
|
||||
(cond (and (= k "z") (.-shiftKey e)) ::redo
|
||||
(= k "z") ::undo
|
||||
(= k "y") ::redo
|
||||
(= k "c") ::ui/copy
|
||||
(= k "x") ::ui/cut
|
||||
(= k "v") ::ui/paste
|
||||
(and (= k "d") (.-shiftKey e)) ::ui/duplicate-unique
|
||||
(= k "d") ::ui/duplicate)
|
||||
(when (#{"delete" "backspace"} k) ::ui/delete-selected))]
|
||||
(.preventDefault e)
|
||||
(rf/dispatch [ev])))))))
|
||||
|
|
@ -1,33 +1,25 @@
|
|||
(ns arthur.events.paint
|
||||
"Polygon edits. Every one of them is `edit/edit` plus a pure `domain/paint`
|
||||
function, which is the shape every document edit in this app should have."
|
||||
(:require [arthur.domain.paint :as paint]
|
||||
[arthur.footage.store :as store]
|
||||
[arthur.events.edit :as edit]
|
||||
[re-frame.core :as rf]))
|
||||
|
||||
(defn- edit [db f]
|
||||
(let [id (store/edit-clip! (:clip/current db) f)]
|
||||
(if id
|
||||
(-> db
|
||||
(assoc :clip/current id)
|
||||
(update :paint/revision (fnil inc 0))
|
||||
(update :project merge {:status "paint edited · unsaved"}))
|
||||
db)))
|
||||
|
||||
(rf/reg-event-db
|
||||
::new-shape
|
||||
(fn [db [_ id points color]]
|
||||
(edit db #(paint/new-shape % id (get-in db [:playback :frame]) points color))))
|
||||
;; `frame` is the symbol's own: a shape drawn into an instance starts on the
|
||||
;; frame that instance is showing, not the transport's.
|
||||
(fn [db [_ sid id frame points color]]
|
||||
(edit/edit db #(paint/new-shape % sid id frame points color))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::add-key
|
||||
(fn [db [_ id]]
|
||||
(edit db #(paint/add-key % id (get-in db [:playback :frame])))))
|
||||
;; `frame` is the shape's own, which is the transport's only for a shape in the
|
||||
;; open symbol with no time map of its own.
|
||||
(fn [db [_ sid id frame]]
|
||||
(edit/edit db #(paint/add-key % sid id frame))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-vertex
|
||||
(fn [db [_ id key-frame vertex point]]
|
||||
(edit db #(paint/set-vertex % id key-frame vertex point))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-segment-interp
|
||||
(fn [db [_ id key-frame interp]]
|
||||
(edit db #(paint/set-segment-interp % id key-frame interp))))
|
||||
(fn [db [_ sid id key-frame vertex point]]
|
||||
(edit/edit db #(paint/set-vertex % sid id key-frame vertex point))))
|
||||
|
|
|
|||
|
|
@ -10,29 +10,64 @@
|
|||
traversals a second, which is the one genuinely expensive thing you can do to
|
||||
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]
|
||||
(:require [arthur.audio.mix :as mix]
|
||||
[arthur.clock :as clock]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.db :as app-db]
|
||||
[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]))
|
||||
|
||||
(rf/reg-event-db
|
||||
(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/output-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]))
|
||||
(update :ui merge
|
||||
{:open sid :tabs (if sid [sid] [])
|
||||
;; The layers switched off were another document's, and their
|
||||
;; ids mean nothing in this one. Whether tracing shows at all,
|
||||
;; and how strongly, is the person's and carries over.
|
||||
:tracing (assoc (get-in db [:ui :tracing] app-db/tracing) :hidden #{})})
|
||||
;; Occurrence addresses belong to the document being left. Creation is
|
||||
;; derived from the primary selection, so carrying one across documents
|
||||
;; could otherwise make a coincidentally equal id a nested destination.
|
||||
(update :ui dissoc :selection :selections :points)
|
||||
(assoc-in [:playback :frame] 0)
|
||||
(assoc-in [:playback :playing?] false))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::tick
|
||||
(fn [db [_ f]]
|
||||
(fn [{:keys [db]} [_ f]]
|
||||
;; Written from the rAF loop when the DERIVED frame changes — not every
|
||||
;; animation frame, and never as the thing the blit waits on. The picture is
|
||||
;; painted from the clock directly; this only brings the document's idea of
|
||||
;; the playhead up to date so the readout and the scrubber agree with it.
|
||||
(if (= f (get-in db [:playback :frame]))
|
||||
db
|
||||
(assoc-in db [:playback :frame] f))))
|
||||
{:db db}
|
||||
(cond-> {:db (assoc-in db [:playback :frame] f)}
|
||||
(get-in db [:ui :gesture :auto-key?])
|
||||
(assoc :dispatch [:arthur.events.ui/record-gesture f])))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::play
|
||||
(fn [{:keys [db]} _]
|
||||
{:db (assoc-in db [:playback :playing?] true)
|
||||
::play! nil}))
|
||||
::play-from! [(fps db) (frames db) (get-in db [:playback :frame] 0)]}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::pause
|
||||
|
|
@ -45,7 +80,8 @@
|
|||
(fn [{:keys [db]} _]
|
||||
(if (get-in db [:playback :playing?])
|
||||
{:db (assoc-in db [:playback :playing?] false) ::pause! nil}
|
||||
{:db (assoc-in db [:playback :playing?] true) ::play! nil})))
|
||||
{:db (assoc-in db [:playback :playing?] true)
|
||||
::play-from! [(fps db) (frames db) (get-in db [:playback :frame] 0)]})))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::seek
|
||||
|
|
@ -65,16 +101,16 @@
|
|||
{:db (assoc-in db [:playback :rate] r)
|
||||
::rate! r}))
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-picture-fps
|
||||
(fn [db [_ target]]
|
||||
(if (and (number? target) (pos? target) (<= target (fps db)))
|
||||
(assoc-in db [:clip :display-fps] target)
|
||||
db)))
|
||||
|
||||
;; --- effects: every DOM touch on the audio element is one of these ---
|
||||
|
||||
(rf/reg-fx ::play! (fn [_] (clock/play!)))
|
||||
(rf/reg-fx ::play-from!
|
||||
(fn [[fps frames f]]
|
||||
;; The transport is the audio clock; app-db's frame is the visible
|
||||
;; playhead. Re-anchor before starting so Play always honors the frame
|
||||
;; currently shown, including after the clock has reached the clip end.
|
||||
(clock/seek! fps frames f)
|
||||
(clock/play!)))
|
||||
(rf/reg-fx ::pause! (fn [_] (clock/pause!)))
|
||||
(rf/reg-fx ::rate! (fn [r] (clock/set-rate! r)))
|
||||
(rf/reg-fx ::seek! (fn [[fps frames f]] (clock/seek! fps frames f)))
|
||||
|
|
@ -99,14 +135,120 @@
|
|||
;; 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)]
|
||||
(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 was open before.
|
||||
(assoc :project {:id nil :cid cid :name label
|
||||
:seq nil :busy? false
|
||||
:status "built-in example · not a saved project"}))]
|
||||
{:db db
|
||||
::pause! nil
|
||||
::seek! [(fps db) (frames db) 0]
|
||||
::clock! {:id id :sid (get-in db [:ui :open])}})))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; tabs
|
||||
;;
|
||||
;; A tab is an open symbol. `:open` is the one on screen and `:tabs` the ones
|
||||
;; beside it, in the order they were opened. Switching is `:open` and a clock:
|
||||
;; the stage, the timeline rows, the transport's length and a new shape's home
|
||||
;; all follow `:open` through their subscriptions without being told.
|
||||
|
||||
(defonce ^:private clock-url (atom nil))
|
||||
|
||||
(rf/reg-fx
|
||||
::clock!
|
||||
;; WHAT THE OPEN SYMBOL SOUNDS LIKE, fetched once per thing that can change it:
|
||||
;; a document opening, a tab switch, an edit to a track. The graph backend is
|
||||
;; handed the buffer; the element backend needs a URL, which means a WAV, which
|
||||
;; is the whole of what this used to cost.
|
||||
(fn [{:keys [id sid]}]
|
||||
(let [{:keys [clip audio store]} (footage/entry id)]
|
||||
(-> (if (clock/graph?)
|
||||
(mix/clock-source! clip sid audio store)
|
||||
(.then (mix/clock! clip sid audio store) (fn [u] {:url u})))
|
||||
(.then #(rf/dispatch [::clock-ready id sid %]))
|
||||
(.catch #(js/console.error %))))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::clock-ready
|
||||
(fn [{:keys [db]} [_ id sid {:keys [url buffer seconds]}]]
|
||||
(if-not (and (= id (:clip/current db)) (= sid (get-in db [:ui :open])))
|
||||
{}
|
||||
(if (clock/graph?)
|
||||
(do
|
||||
(clock/attach-buffer! buffer seconds)
|
||||
;; RATE, LOOP AND MUTE ARE RE-APPLIED. They are transport state and
|
||||
;; outlive the clock they were set on, but a fresh graph starts at its
|
||||
;; defaults — so without this a tab switch would quietly drop the take
|
||||
;; back to 1x, unlooped and unmuted. The element kept them because they
|
||||
;; were properties of a node that survived its own `src` changing.
|
||||
{:fx [[::rate! (get-in db [:playback :rate] 1.0)]
|
||||
[::loop! (boolean (get-in db [:playback :loop?]))]
|
||||
[::mute! (boolean (get-in db [:playback :muted?]))]
|
||||
[::seek! [(fps db) (frames db) (get-in db [:playback :frame] 0)]]
|
||||
;; An edit under a playing take resumes it. The element stopped
|
||||
;; dead here, which made adjusting a fade while listening to it
|
||||
;; a thing you could only do once.
|
||||
(when (get-in db [:playback :playing?]) [::play! nil])]})
|
||||
(do
|
||||
;; A blob URL made for the last tab is released when the next one lands,
|
||||
;; and never the document's own file.
|
||||
(when-let [old @clock-url]
|
||||
(when (not= old url) (js/URL.revokeObjectURL old)))
|
||||
(reset! clock-url (when (and (not= url (:audio (footage/entry id)))
|
||||
(.startsWith url "blob:"))
|
||||
url))
|
||||
{:db (assoc-in db [:clip :audio] url)})))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::paint-failed
|
||||
(fn [db [_ message]]
|
||||
(update db :project merge {:status (str "cannot draw: " message)})))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::refresh-clock
|
||||
;; After an edit that changes what the open symbol sounds like.
|
||||
(fn [{:keys [db]} _]
|
||||
{::clock! {:id (:clip/current db) :sid (get-in db [:ui :open])}}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::open-symbol
|
||||
(fn [{:keys [db]} [_ sid]]
|
||||
(let [clip (:clip (footage/entry (:clip/current db)))]
|
||||
;; A tracing symbol has no inside to open: it is footage, which is seen by
|
||||
;; placing it.
|
||||
(if (or (nil? (clip/symbol clip sid)) (= sid (get-in db [:ui :open]))
|
||||
(clip/trace? (clip/symbol clip sid)))
|
||||
{}
|
||||
{: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]))
|
||||
(update-in [:ui :tabs] #(if (some #{sid} %) % (conj (vec %) sid)))
|
||||
(assoc-in [:ui :open] sid)
|
||||
;; A node address is relative to the tab being left. Opening a
|
||||
;; symbol therefore arrives at its root; a pool `[:symbol _]`
|
||||
;; selection is not an occurrence address and may survive.
|
||||
(update :ui (fn [ui]
|
||||
(cond-> ui
|
||||
(= :node (first (:selection ui)))
|
||||
(dissoc :selection :selections))))
|
||||
(assoc-in [:playback :frame] 0)
|
||||
(assoc-in [:playback :playing?] false))
|
||||
::pause! nil
|
||||
::seek! [fps frames 0]})))
|
||||
::clock! {:id (:clip/current db) :sid sid}}))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::close-tab
|
||||
(fn [{:keys [db]} [_ sid]]
|
||||
;; The last tab cannot be closed: something is always on screen, because the
|
||||
;; transport and the stage have no meaning without a symbol.
|
||||
(let [tabs (get-in db [:ui :tabs])
|
||||
left (vec (remove #{sid} tabs))]
|
||||
(cond
|
||||
(empty? left) {}
|
||||
(not= sid (get-in db [:ui :open])) {:db (assoc-in db [:ui :tabs] left)}
|
||||
:else (let [i (.indexOf tabs sid)]
|
||||
{:db (assoc-in db [:ui :tabs] left)
|
||||
:dispatch [::open-symbol (get left (min i (dec (count left))))]})))))
|
||||
|
|
|
|||
|
|
@ -21,14 +21,22 @@
|
|||
|
||||
Nothing here touches app-db except through events. The promise chain lives in an
|
||||
fx, which is the only thing in this namespace that is not pure."
|
||||
(:require [arthur.domain.clip :as clip]
|
||||
[arthur.audio.mix :as mix]
|
||||
(:require [arthur.db :as db]
|
||||
[arthur.domain.bring :as bring]
|
||||
[arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.span :as span]
|
||||
[arthur.domain.leaf :as leaf]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.events.edit :as edit]
|
||||
[arthur.demo.stage :as stage]
|
||||
[arthur.domain.feature :as feature]
|
||||
[arthur.domain.project :as project]
|
||||
[arthur.domain.wire :as wire]
|
||||
[arthur.events.footage :as footage]
|
||||
[arthur.events.playback :as pb]
|
||||
[arthur.events.ui :as ui]
|
||||
[arthur.footage.store :as store]
|
||||
[arthur.flow.address :as address]
|
||||
[arthur.flow.ingest :as ingest]
|
||||
|
|
@ -37,6 +45,7 @@
|
|||
[arthur.flow.take :as take]
|
||||
[arthur.fx.http :as http]
|
||||
[arthur.synth :as synth]
|
||||
[clojure.string :as str]
|
||||
[re-frame.core :as rf]))
|
||||
|
||||
(defn- analysis-payload [analysis]
|
||||
|
|
@ -92,73 +101,270 @@
|
|||
(.then (fn [^js created] (.-id created))))))
|
||||
|
||||
(defn- opened-entry! [^js clip-json]
|
||||
(let [footage-id (.-footage clip-json)]
|
||||
(-> (js/Promise.all
|
||||
#js [(js/Promise.all
|
||||
(into-array (map #(http/GET (str "/api/blocks/" %))
|
||||
(array-seq (.-blocks clip-json)))))
|
||||
(if footage-id
|
||||
(http/GET (str "/api/footage/" footage-id))
|
||||
(js/Promise.resolve nil))])
|
||||
(.then (fn [[blocks ^js footage]]
|
||||
(.then (fn [blocks]
|
||||
(let [cid (.-cid clip-json)
|
||||
loaded (project/load
|
||||
cid #js {:leaves (.-leaves clip-json)
|
||||
:blocks blocks})
|
||||
built (:clip loaded)]
|
||||
;; Project FPS and the root timeline are one clock. This
|
||||
;; also normalizes documents saved by the earlier model,
|
||||
;; where changing project FPS left the root on its old
|
||||
;; editing grid.
|
||||
built (-> (:clip loaded)
|
||||
clip/pin-root
|
||||
(clip/set-root-fps (:fps (: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)
|
||||
:display-fps (:fps built)
|
||||
:cid cid
|
||||
;; What the server holds, as of the seq
|
||||
;; this was opened at: a save sends what
|
||||
;; differs from it, and a collaborator's
|
||||
;; write lands on what does not.
|
||||
:synced (project/tier1 (.-leaves clip-json))
|
||||
: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))
|
||||
(.then (fn [audio] (assoc entry :audio audio)))))))))))
|
||||
;; The document's OWN file, unmixed. What
|
||||
;; the symbol actually sounds like is the
|
||||
;; clock's business and is fetched once,
|
||||
;; by `::pb/clock!`, when it goes on
|
||||
;; screen — this used to mix it here as
|
||||
;; well, and the two encodes of the same
|
||||
;; audio were most of a project open.
|
||||
:audio "/static/arthur/audio.wav"})]
|
||||
entry))))))
|
||||
|
||||
(defn- saved-clip!
|
||||
"Promise of clip `cid` of saved project `pid`, as `{:clip :store}`: its
|
||||
document and the blocks it names, and nothing played or mixed."
|
||||
[pid cid]
|
||||
(-> (http/GET (str "/api/projects/" pid))
|
||||
(.then (fn [^js doc]
|
||||
(let [^js c (or (first (filter #(= cid (.-cid ^js %)) (array-seq (.-clips doc))))
|
||||
(throw (ex-info "that project no longer has that clip" {:cid cid})))]
|
||||
(-> (js/Promise.all (into-array (map #(http/GET (str "/api/blocks/" %))
|
||||
(array-seq (.-blocks c)))))
|
||||
(.then (fn [blocks]
|
||||
(project/load cid #js {:leaves (.-leaves c) :blocks blocks})))))))))
|
||||
|
||||
(rf/reg-fx
|
||||
::import!
|
||||
(fn [{:keys [project cid] :as request}]
|
||||
(-> (saved-clip! project cid)
|
||||
(.then #(rf/dispatch [::imported request %]))
|
||||
(.catch (fn [error]
|
||||
(js/console.error error)
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::import
|
||||
;; A symbol out of another saved project, dropped at `frame` of the open symbol
|
||||
;; and, from the stage, with its middle on `point`.
|
||||
(fn [{:keys [db]} [_ carried frame point target]]
|
||||
{:db (update db :project merge {:status (str "fetching " (:label carried) "…")})
|
||||
::import! (assoc (select-keys carried [:project :cid :symbol :label])
|
||||
:host (get-in db [:ui :open]) :frame frame :point point
|
||||
:target target)}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::import-palette
|
||||
(fn [{:keys [db]} [_ {:keys [project cid palette name]}]]
|
||||
{:db (update db :project merge {:status (str "fetching " name "…")})
|
||||
::import-palette! {:project project :cid cid :palette palette}}))
|
||||
|
||||
(rf/reg-fx
|
||||
::import-palette!
|
||||
(fn [{:keys [project cid palette]}]
|
||||
(-> (saved-clip! project cid)
|
||||
(.then (fn [{other :clip}]
|
||||
(let [source (leaf/unsegment palette)
|
||||
p (get-in other [:palettes source])]
|
||||
(if p
|
||||
(rf/dispatch [::palette-imported p])
|
||||
(rf/dispatch [::failed "that palette no longer exists"])))))
|
||||
(.catch (fn [error]
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::palette-imported
|
||||
(fn [db [_ palette]]
|
||||
(let [id (random-uuid)]
|
||||
(-> (edit/edit db #(assoc-in % [:palettes id]
|
||||
(assoc palette :id id :name (str (:name palette) " copy"))))
|
||||
(assoc-in [:ui :palette] id)
|
||||
(assoc-in [:project :status] (str "imported " (:name palette)))))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::imported
|
||||
;; As drawing: its tracking stays with the analysis that measured it. See
|
||||
;; `arthur.domain.bring`.
|
||||
(fn [{:keys [db]} [_ {:keys [symbol frame point label target]} other]]
|
||||
(let [entry (store/entry (:clip/current db))
|
||||
sid (leaf/unsegment symbol)
|
||||
uuid (random-uuid)
|
||||
{:keys [clip ids]} (bring/symbols (:clip entry) (:clip other) [sid] {})
|
||||
st (merge (:store entry) (:store other))
|
||||
;; A symbol from another project arrives as an ordinary clip, the same
|
||||
;; as one from this project's pool.
|
||||
where (if point
|
||||
(ui/creation-destination db clip st frame)
|
||||
(ui/drop-destination db clip st frame target))
|
||||
point (ui/destination-point where point)
|
||||
result (if (:refused where)
|
||||
where
|
||||
(span/place-symbol (:clip where) st (:sid where)
|
||||
uuid (ids sid) (:at where)
|
||||
{:extent :grow-symbol :point point
|
||||
:remainder-id (random-uuid)}))]
|
||||
(if-let [why (or (:refused where) (:refused result))]
|
||||
{:db (update db :project merge {:status why})}
|
||||
{:db (-> (edit/edit-entry db #(assoc % :clip (:clip result) :store st))
|
||||
(ui/selected
|
||||
[:node (:sid where) uuid (conj (vec (:path where)) uuid)])
|
||||
(update :project merge {:status (str "brought in " label)}))
|
||||
:dispatch [::pb/refresh-clock]}))))
|
||||
|
||||
(defn- clip-payload
|
||||
"One clip of a save. With `base` — the seq the open document last caught up
|
||||
to — only the leaves that differ from what the server held then, and the ones
|
||||
since deleted: a collaborator's leaves are not ours to write back. Without it,
|
||||
the whole clip."
|
||||
[^js doc base local synced]
|
||||
(if base
|
||||
(let [all (.-leaves doc)
|
||||
out (js-obj)]
|
||||
(doseq [[path v] local :when (not= v (get synced path))]
|
||||
(aset out path (aget all path)))
|
||||
#js {:leaves out
|
||||
:removed (into-array (remove #(contains? local %) (keys synced)))})
|
||||
#js {:leaves (.-leaves doc)}))
|
||||
|
||||
(defonce ^:private uploaded-blocks
|
||||
;; Block keys this page has already put on the server. Content addressed, so
|
||||
;; once there they are there: a save of a moved vertex asks for none again.
|
||||
(atom #{}))
|
||||
|
||||
(defonce ^:private linked-analyses (atom #{}))
|
||||
|
||||
(defn- upload-new! [^js doc]
|
||||
(let [keys (array-seq (block-keys doc))]
|
||||
(if (every? @uploaded-blocks keys)
|
||||
(js/Promise.resolve 0)
|
||||
(.then (upload-missing! doc) (fn [n] (swap! uploaded-blocks into keys) n)))))
|
||||
|
||||
(defn- analyses-of [entry]
|
||||
(vals (get-in entry [:clip :analyses])))
|
||||
|
||||
(defn- upload-sources! [sources]
|
||||
(reduce
|
||||
(fn [chain [analysis-id {:keys [source-blocks]}]]
|
||||
(.then chain
|
||||
(fn [_]
|
||||
(when (and (seq source-blocks)
|
||||
(not (@linked-analyses analysis-id)))
|
||||
(-> (upload-missing! #js {:blocks (source/upload-blocks source-blocks)})
|
||||
(.then #(http/PUT
|
||||
(str "/api/analyses/" analysis-id)
|
||||
#js {:source_blocks
|
||||
(into-array (source/block-keys source-blocks))}))
|
||||
(.then (fn [answer]
|
||||
(swap! linked-analyses conj analysis-id)
|
||||
answer)))))))
|
||||
(js/Promise.resolve nil)
|
||||
sources))
|
||||
|
||||
(rf/reg-fx
|
||||
::save!
|
||||
(fn [{:keys [id cid label clip]}]
|
||||
(let [analysis (:analysis (:clip clip))
|
||||
doc (project/save cid clip)
|
||||
source-blocks (:source-blocks clip)]
|
||||
(fn [{:keys [id cid label clip base]}]
|
||||
(let [entry clip
|
||||
analyses (analyses-of entry)
|
||||
doc (project/save cid entry)
|
||||
local (leaf/leaves cid (:clip entry))
|
||||
base (when (and id (:synced entry)) base)
|
||||
sources (:sources entry)]
|
||||
(-> (ensure-project! id label)
|
||||
(.then (fn [pid]
|
||||
(-> (if analysis
|
||||
(http/POST "/api/analyses" (analysis-payload analysis))
|
||||
(js/Promise.resolve nil))
|
||||
(-> (reduce (fn [chain one]
|
||||
(.then chain
|
||||
#(http/POST "/api/analyses"
|
||||
(analysis-payload one))))
|
||||
(js/Promise.resolve nil)
|
||||
analyses)
|
||||
(.then (fn [_]
|
||||
(when (seq source-blocks)
|
||||
(-> (upload-missing!
|
||||
#js {:blocks (source/upload-blocks source-blocks)})
|
||||
(upload-sources! sources)))
|
||||
(.then (fn [_]
|
||||
(http/PUT
|
||||
(str "/api/analyses/" (:id analysis))
|
||||
;; One set per tracked subject, in
|
||||
;; the order `source/unpack` does
|
||||
;; not depend on.
|
||||
#js {:source_blocks
|
||||
(into-array
|
||||
(source/block-keys source-blocks))})))))))
|
||||
(.then (fn [_] (upload-missing! doc)))
|
||||
(upload-new! doc)))
|
||||
(.then (fn [uploaded]
|
||||
(-> (http/PUT (str "/api/projects/" pid)
|
||||
#js {:name label
|
||||
:clips #js [#js {:cid cid
|
||||
:base base
|
||||
:clips #js [(js/Object.assign
|
||||
#js {:cid cid
|
||||
:name label
|
||||
:analysis (:id analysis)
|
||||
:footage (:footage-id clip)
|
||||
:leaves (.-leaves doc)
|
||||
:blocks (block-keys doc)}]})
|
||||
:analyses (into-array
|
||||
(keys (get-in entry [:clip :analyses])))
|
||||
:blocks (block-keys doc)}
|
||||
(clip-payload doc base local
|
||||
(:synced entry)))]})
|
||||
(.then (fn [^js saved]
|
||||
(rf/dispatch [::saved pid cid label
|
||||
(.-seq saved)
|
||||
(count (array-seq (.-written saved)))
|
||||
uploaded])))))))))
|
||||
uploaded
|
||||
{:synced local :base base}])))))))))
|
||||
(.catch (fn [error]
|
||||
(js/console.error error)
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))])))))))
|
||||
(if-let [conflicts (get-in (ex-data error) [:body :conflicts])]
|
||||
(rf/dispatch [::conflicted (count conflicts)])
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))))
|
||||
|
||||
(rf/reg-fx
|
||||
::list!
|
||||
(fn [_]
|
||||
(-> (http/GET "/api/projects")
|
||||
(.then (fn [^js listed]
|
||||
(rf/dispatch [::listed
|
||||
(mapv (fn [^js row]
|
||||
{:id (.-id row) :name (.-name row)
|
||||
:owner (.-owner row)
|
||||
:seq (.-seq row) :updated (.-updated row)})
|
||||
(array-seq (.-projects listed)))])))
|
||||
(.catch (fn [error]
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
|
||||
|
||||
(rf/reg-fx
|
||||
::list-symbols!
|
||||
(fn [_]
|
||||
(-> (http/GET "/api/symbols")
|
||||
(.then (fn [^js listed]
|
||||
(rf/dispatch [::symbols-listed
|
||||
(mapv (fn [^js r]
|
||||
{:project (.-project r) :project-name (.-project_name r)
|
||||
:cid (.-cid r) :symbol (.-symbol r)
|
||||
:name (.-name r) :frames (.-frames r)})
|
||||
(array-seq (.-symbols listed)))
|
||||
(mapv (fn [^js r]
|
||||
{:project (.-project r) :project-name (.-project_name r)
|
||||
:cid (.-cid r) :palette (.-palette r) :name (.-name r)})
|
||||
(array-seq (.-palettes listed)))])))
|
||||
(.catch (fn [error]
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::list-symbols
|
||||
(fn [{:keys [db]} _]
|
||||
{:db (assoc-in db [:assets :loading?] true)
|
||||
::list-symbols! nil}))
|
||||
|
||||
(rf/reg-event-db
|
||||
::symbols-listed
|
||||
(fn [db [_ rows palettes]]
|
||||
(assoc db :assets {:symbols rows :palettes palettes :loading? false})))
|
||||
|
||||
(rf/reg-sub ::assets (fn [db _] (:assets db)))
|
||||
|
||||
(declare blank-entry)
|
||||
|
||||
(rf/reg-fx
|
||||
::open!
|
||||
|
|
@ -174,15 +380,26 @@
|
|||
(.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 clip-json
|
||||
(throw (ex-info "that project has no clips" {})))
|
||||
(-> (opened-entry! clip-json)
|
||||
(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)
|
||||
{})))
|
||||
;; A project made from the index has nothing in it yet: it
|
||||
;; opens on a blank document, of which the server has seen
|
||||
;; nothing, so the first save sends all of it.
|
||||
(-> (if clip-json
|
||||
(opened-entry! clip-json)
|
||||
(js/Promise.resolve (assoc (blank-entry) :synced {})))
|
||||
(.then (fn [entry]
|
||||
(rf/dispatch [::opened
|
||||
(store/install! entry "project")
|
||||
(.-id loaded)
|
||||
(.-name loaded)
|
||||
(.-seq loaded)])))))))
|
||||
(.-seq loaded)
|
||||
{:owner (.-owner loaded)
|
||||
:editors (vec (.-editors loaded))
|
||||
:can-edit? (.-can_edit loaded)}])))))))
|
||||
(.catch (fn [error]
|
||||
(js/console.error error)
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
|
||||
|
|
@ -199,13 +416,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))
|
||||
(.then (fn [audio]
|
||||
(rf/dispatch
|
||||
[::stage-opened
|
||||
(store/install! (assoc entry :audio audio) "stage")])))))))
|
||||
(rf/dispatch [::stage-opened (store/install! entry "stage")]))))
|
||||
(.catch (fn [error]
|
||||
(js/console.error error)
|
||||
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
|
||||
|
|
@ -236,39 +449,38 @@
|
|||
(assoc measured :interior-key block-key))))))))
|
||||
(throw error)))))))
|
||||
|
||||
(defn- source-for! [entry]
|
||||
(if-let [inputs (:source-inputs entry)]
|
||||
(js/Promise.resolve inputs)
|
||||
(let [analysis (get-in entry [:clip :analysis])]
|
||||
(if (= (:id analysis) (:id @retained-source))
|
||||
(defn- source-for! [entry edit]
|
||||
(let [subject (:subject (regenerate/plan (:clip entry) edit))
|
||||
subject-record (get-in entry [:clip :subjects subject])
|
||||
analysis-id (:analysis subject-record)
|
||||
analysis (get-in entry [:clip :analyses analysis-id])
|
||||
source-subject (:source-subject subject-record)
|
||||
local (get-in entry [:sources analysis-id :source-inputs :subjects subject])]
|
||||
(if local
|
||||
(js/Promise.resolve {:subjects {subject local}})
|
||||
(if (= [analysis-id subject] (:key @retained-source))
|
||||
(:promise @retained-source)
|
||||
(let [promise
|
||||
(if (= "synth" (:detector analysis))
|
||||
;; The synthetic take tracks one face and regenerating it reads
|
||||
;; that face's landmarks, so it arrives in the same shape real
|
||||
;; footage does rather than in a flat one only this branch uses.
|
||||
(js/Promise.resolve
|
||||
{:subjects
|
||||
{:face-1 {:dense (synth/synth-dense (:frames analysis)
|
||||
{subject {:dense (synth/synth-dense (:frames analysis)
|
||||
{:seed (:seed analysis)})}}})
|
||||
(-> (ingest/manifest! (:footage-id entry))
|
||||
(.then (fn [manifest]
|
||||
(-> (footage/saved-source! (:id analysis)
|
||||
[(:width manifest)
|
||||
(:height manifest)])
|
||||
(.then (fn [inputs]
|
||||
(.then (ingest/manifest! (:footage subject-record))
|
||||
(fn [manifest]
|
||||
(.then (footage/saved-source!
|
||||
analysis-id [(:width manifest) (:height manifest)])
|
||||
(fn [inputs]
|
||||
(when-not inputs
|
||||
(throw (ex-info "saved analysis has no source blocks" {})))
|
||||
(update inputs :subjects
|
||||
(fn [subjects]
|
||||
(into {}
|
||||
(map (fn [[id one]]
|
||||
[id (assoc one :presence
|
||||
(footage/presence-for
|
||||
manifest id))]))
|
||||
subjects))))))))))]
|
||||
{:subjects
|
||||
{subject
|
||||
(assoc (get-in inputs [:subjects source-subject])
|
||||
:presence
|
||||
(footage/presence-for manifest
|
||||
source-subject))}})))))]
|
||||
(do
|
||||
(reset! retained-source {:id (:id analysis) :promise promise})
|
||||
(reset! retained-source {:key [analysis-id subject] :promise promise})
|
||||
promise))))))
|
||||
|
||||
(defn- inputs-for-edit!
|
||||
|
|
@ -281,18 +493,19 @@
|
|||
one (get-in inputs [:subjects subject])]
|
||||
(if (and teeth (:crops one))
|
||||
(let [settings (merge take/knobs (feature/effective-params clip teeth))
|
||||
analysis (get-in clip [:analysis :id])
|
||||
analysis (get-in clip [:subjects subject :analysis])
|
||||
source-subject (get-in clip [:subjects subject :source-subject])
|
||||
frames (count (:crops one))
|
||||
key (source/interior-key analysis subject settings frames)
|
||||
key (source/interior-key analysis source-subject settings frames)
|
||||
done (fn [measured] (assoc-in inputs [:subjects subject] measured))]
|
||||
(if (and (:interior one)
|
||||
(or (= (:interior-key one) key)
|
||||
(and (nil? (:interior-key one))
|
||||
(= key (source/interior-key analysis subject take/knobs frames)))))
|
||||
(= key (source/interior-key analysis source-subject take/knobs frames)))))
|
||||
(js/Promise.resolve inputs)
|
||||
(.then (if (= key (:key @retained-interior))
|
||||
(:promise @retained-interior)
|
||||
(let [promise (retained-interior! analysis subject settings one)]
|
||||
(let [promise (retained-interior! analysis source-subject settings one)]
|
||||
(reset! retained-interior {:key key :promise promise})
|
||||
promise))
|
||||
done)))
|
||||
|
|
@ -301,7 +514,7 @@
|
|||
(rf/reg-fx
|
||||
::preview-settings!
|
||||
(fn [{:keys [id entry edit request]}]
|
||||
(-> (source-for! entry)
|
||||
(-> (source-for! entry edit)
|
||||
(.then (fn [inputs] (inputs-for-edit! entry edit inputs)))
|
||||
(.then (fn [inputs]
|
||||
(regenerate/change (assoc entry :source-inputs inputs) edit)))
|
||||
|
|
@ -316,7 +529,7 @@
|
|||
(fn [{:keys [db]} [_ edit]]
|
||||
(let [id (:clip/current db)
|
||||
entry (store/entry id)]
|
||||
(if (or (get-in db [:project :busy?]) (nil? (:analysis (:clip entry))))
|
||||
(if (or (get-in db [:project :busy?]) (empty? (:analyses (:clip entry))))
|
||||
{}
|
||||
(let [plan (regenerate/plan (:clip entry) edit)
|
||||
report (select-keys plan [:features :roles])
|
||||
|
|
@ -330,6 +543,7 @@
|
|||
:request request}})))))
|
||||
|
||||
(rf/reg-sub ::regeneration (fn [db _] (:regeneration db)))
|
||||
(rf/reg-sub ::listing (fn [db _] (:projects db)))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::settings-previewed
|
||||
|
|
@ -345,27 +559,326 @@
|
|||
;; ---------------------------------------------------------------------------
|
||||
;; events
|
||||
|
||||
(def blank-audio
|
||||
"The audio a new document opens on, until it gets one of its own.
|
||||
|
||||
It is no longer load-bearing. The frame is derived from a position, and the
|
||||
graph backend can hold one for a symbol with no sound at all — see
|
||||
`mix/clock-source!` — so a silent stage has time and `play` works. This stays
|
||||
because a new document borrowing the synthetic take's soundtrack is a
|
||||
convenience worth keeping, not because the clock would stop without it."
|
||||
"/static/arthur/audio.wav")
|
||||
|
||||
(defn blank-entry
|
||||
"A blank clip, in the shape `footage/store` and the transport expect."
|
||||
[]
|
||||
(let [c (clip/blank)]
|
||||
{:label "untitled" :clip c :store nil
|
||||
:audio blank-audio
|
||||
;; A content id of its own from the start: `::save!` addresses the clip by
|
||||
;; it, and two untitled documents saved from two tabs are two documents.
|
||||
:cid (str (random-uuid))
|
||||
:fps (:fps c) :width (:width c) :height (:height c)}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::new
|
||||
(fn [{:keys [db]} _]
|
||||
;; A document with no id on the server, so the next `save` creates one. This
|
||||
;; is also what the app opens on: nothing is loaded until something is asked
|
||||
;; 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 (-> (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"}))
|
||||
;; The readout goes home with the document; so must the clock, or play
|
||||
;; picks up wherever the last document's audio had got to.
|
||||
::pb/seek! [(:fps entry) (clip/output-frames (:clip entry) (clip/opens-on (:clip entry))) 0]
|
||||
::pb/pause! nil
|
||||
::pb/clock! {:id id :sid (clip/opens-on (:clip entry))}})))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::save
|
||||
(fn [{:keys [db]} _]
|
||||
;; `auto?` is the save an edit schedules (see `arthur.events.collab`): it does
|
||||
;; not lock the controls the way a save you asked for does, and it never saves
|
||||
;; somebody else's project as a copy. Either kind waits its turn behind one in
|
||||
;; flight, and writes nothing when nothing changed. `force?` saves what is not a leaf —
|
||||
;; the name.
|
||||
(fn [{:keys [db]} [_ {:keys [auto? force?] :as how}]]
|
||||
(let [id (:clip/current db)
|
||||
clip (store/entry id)]
|
||||
(if (or (:busy? (:project db)) (nil? clip))
|
||||
clip (store/entry id)
|
||||
{pid :id :keys [busy? saving? can-edit?]} (:project db)
|
||||
cid (or (:cid clip) (name id))]
|
||||
(cond
|
||||
(nil? clip)
|
||||
{}
|
||||
{:db (update db :project merge {:busy? true :status "saving…"})
|
||||
::save! {:id (:id (:project db))
|
||||
:cid (or (:cid clip) (name id))
|
||||
:label (or (:label clip) (name id))
|
||||
|
||||
;; Behind the one in flight, never instead of it: an edit made while a
|
||||
;; save is on the wire is not in that save. One request at a time, and
|
||||
;; the next carries everything that changed meanwhile — so a drag goes
|
||||
;; out as fast as the round trip allows, and no faster.
|
||||
(or busy? saving?)
|
||||
{:db (assoc-in db [:project :again] (or how {}))}
|
||||
|
||||
(and auto? (false? can-edit?))
|
||||
{}
|
||||
|
||||
(and pid (:synced clip) (not force?) (empty? (:behind clip))
|
||||
(= (:synced clip) (try (leaf/leaves cid (:clip clip)) (catch :default _ nil))))
|
||||
{}
|
||||
|
||||
;; Somebody else wrote leaves we had changed too, first. The first
|
||||
;; write wins: theirs goes on screen over ours, which stays in our undo
|
||||
;; list. See `arthur.events.collab/landed`.
|
||||
(seq (:behind clip))
|
||||
{:dispatch [:arthur.events.collab/remote
|
||||
{:seq (get-in db [:project :seq]) :take? true
|
||||
:written (into {} (remove (comp nil? val)) (:behind clip))
|
||||
:removed (keep (fn [[p v]] (when (nil? v) p)) (:behind clip))}]}
|
||||
|
||||
:else
|
||||
;; Somebody else's project, which we may look at and not write, saves as
|
||||
;; a copy of our own.
|
||||
{:db (update db :project merge {(if auto? :saving? :busy?) true :status "saving…"})
|
||||
::save! {:id (when-not (false? can-edit?) pid)
|
||||
:base (get-in db [:project :seq])
|
||||
:cid cid
|
||||
:label (or (not-empty (get-in db [:project :name]))
|
||||
(:label clip) (name id))
|
||||
:clip clip}}))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::open
|
||||
::rename
|
||||
(fn [{:keys [db]} [_ value]]
|
||||
{:db (assoc-in db [:project :name] (not-empty (str/trim value)))
|
||||
:dispatch [::save {:auto? true :force? true}]}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::project-setting
|
||||
(fn [{:keys [db]} [_ key value]]
|
||||
(if (or (not (#{:fps :width :height} key))
|
||||
(not (and (integer? value) (pos? value))))
|
||||
{}
|
||||
(let [db' (edit/edit db #(if (= key :fps) (clip/set-root-fps % value) (assoc % key value)))
|
||||
db' (assoc-in db' [:clip key] value)
|
||||
frame (min (dec (pb/frames db'))
|
||||
(js/Math.floor (* (get-in db [:playback :frame])
|
||||
(/ value (get-in db [:clip :fps])))))]
|
||||
(cond-> {:db db'}
|
||||
(= key :fps) (assoc :db (assoc-in db' [:playback :frame] frame)
|
||||
::pb/seek! [value (pb/frames db') frame]
|
||||
:dispatch [::pb/refresh-clock]))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::rename-symbol
|
||||
(fn [db [_ sid value]]
|
||||
;; A transaction, so one rename is one undo step: `edit/edit` alone would let
|
||||
;; a rename coalesce with whatever edit happened next.
|
||||
;;
|
||||
;; BLANK REMOVES THE NAME rather than storing an empty one. `clip/symbol-name`
|
||||
;; falls back to the id, so a symbol cleared of its name reads as `main`
|
||||
;; again instead of as a row with nothing on it — and the document carries no
|
||||
;; field it did not need.
|
||||
(let [value (not-empty (str/trim (str value)))]
|
||||
(if-not (clip/symbol (:clip (store/entry (:clip/current db))) sid)
|
||||
db
|
||||
(edit/transaction db #(if value
|
||||
(assoc-in % [:symbols sid :name] value)
|
||||
(update-in % [:symbols sid] dissoc :name)))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::symbol-setting
|
||||
(fn [db [_ sid key value]]
|
||||
(if-not (and (#{:frames :fps :width :height} key)
|
||||
(or (nil? value) (and (integer? value) (pos? value))))
|
||||
db
|
||||
(edit/edit db
|
||||
(fn [c]
|
||||
(if (nil? value)
|
||||
(update-in c [:symbols sid] dissoc key)
|
||||
(assoc-in c [:symbols sid key] value)))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::new-palette
|
||||
(fn [db _]
|
||||
(let [id (random-uuid)
|
||||
p {:id id :name "Untitled palette"
|
||||
:slots (mapv #(select-keys % [:name :hex]) (:slots pal/default-palette))}]
|
||||
(-> (edit/edit db #(assoc-in % [:palettes id] p))
|
||||
(assoc-in [:ui :palette] id)))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::duplicate-palette
|
||||
;; A copy of an existing palette, selected so the next colour edit lands on the
|
||||
;; copy rather than the original. The point is a variant: start from a palette
|
||||
;; that works and change two tones, instead of 16 colour pickers from black.
|
||||
;; `pal/palettes` is the source so the implicit default - a project that has
|
||||
;; never had a palette asset of its own - can be duplicated like any other.
|
||||
(fn [db [_ id]]
|
||||
(let [clip (:clip (store/entry (:clip/current db)))
|
||||
p (get (pal/palettes clip) id)]
|
||||
(if-not p
|
||||
db
|
||||
(let [new-id (random-uuid)]
|
||||
(-> (edit/edit db #(assoc-in % [:palettes new-id]
|
||||
(assoc p :id new-id
|
||||
:name (str (:name p) " copy"))))
|
||||
(assoc-in [:ui :palette] new-id)))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::palette-name
|
||||
(fn [db [_ id value]]
|
||||
(let [value (str/trim (str value))]
|
||||
(if (str/blank? value) db
|
||||
(edit/edit db #(assoc-in % [:palettes id :name] value))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::palette-color
|
||||
(fn [db [_ id slot hex]]
|
||||
(if-not (re-matches #"#[0-9a-fA-F]{6}" (str hex))
|
||||
db
|
||||
(edit/edit db #(assoc-in % [:palettes id :slots slot :hex] (str/lower-case hex))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::default-palette
|
||||
(fn [db [_ id]]
|
||||
(edit/edit db #(if (get-in % [:palettes id]) (assoc % :default-palette id) %))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::symbol-palette
|
||||
(fn [db [_ sid id]]
|
||||
(edit/edit db #(if id
|
||||
(assoc-in % [:symbols sid :palette] id)
|
||||
(update-in % [:symbols sid] dissoc :palette)))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-channel
|
||||
;; `frame` is the node's own, as for a drawing key.
|
||||
(fn [db [_ sid id path frame value]]
|
||||
(let [put (if (get-in db [:ui :auto-key?]) node/set-keyed-channel node/set-channel)]
|
||||
(edit/edit db #(update-in % [:symbols sid :nodes id] put path frame value)))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::instance-playback
|
||||
(fn [db [_ sid id field value]]
|
||||
(let [valid? (case field
|
||||
:mode (#{:once :loop :frame} value)
|
||||
(:in :speed) (and (node/finite-number? value) (<= 0 value))
|
||||
false)]
|
||||
(if-not valid?
|
||||
db
|
||||
(edit/edit
|
||||
db
|
||||
(fn [document]
|
||||
(let [path [:symbols sid :nodes id]
|
||||
n (get-in document path)]
|
||||
(if-not (= :instance (:kind n))
|
||||
document
|
||||
(let [{:keys [speed]} (node/playback-of n)
|
||||
updated (if (= :mode field)
|
||||
(-> n
|
||||
(update :time dissoc :loop?)
|
||||
(assoc-in [:playback :end] (if (= :loop value) :loop :stop))
|
||||
(assoc-in [:playback :speed]
|
||||
(if (= :frame value) 0 (if (pos? speed) speed 1))))
|
||||
(assoc-in n [:playback field] value))]
|
||||
(assoc-in document path updated))))))))))
|
||||
|
||||
(defn- seed-palette-choice [document sid id]
|
||||
(if (get-in document [:symbols sid :nodes id :channels [:palette]])
|
||||
document
|
||||
(let [source (get-in document [:symbols sid :nodes id :source :symbol])
|
||||
palette-id (or (get-in document [:symbols source :palette-ref]) pal/inherit)]
|
||||
(assoc-in document [:symbols sid :nodes id :channels [:palette]]
|
||||
(assoc (ch/framed palette-id) :semantic :palette)))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-palette-choice
|
||||
(fn [db [_ sid id frame palette-id]]
|
||||
(let [put (if (get-in db [:ui :auto-key?]) node/set-keyed-channel node/set-channel)]
|
||||
(edit/edit db
|
||||
(fn [document]
|
||||
(update-in (seed-palette-choice document sid id)
|
||||
[:symbols sid :nodes id]
|
||||
put [:palette] frame palette-id))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::toggle-palette-key
|
||||
(fn [db [_ sid id frame]]
|
||||
(let [st (:store (store/entry (:clip/current db)))]
|
||||
(edit/edit db
|
||||
(fn [document]
|
||||
(update-in (seed-palette-choice document sid id)
|
||||
[:symbols sid :nodes id]
|
||||
node/toggle-key [:palette] frame st))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-channels
|
||||
;; One property assignment over a stage selection is one document edit.
|
||||
(fn [db [_ edits]]
|
||||
(let [put (if (get-in db [:ui :auto-key?]) node/set-keyed-channel node/set-channel)]
|
||||
(edit/edit db
|
||||
#(reduce (fn [c {:keys [sid id path frame value]}]
|
||||
(update-in c [:symbols sid :nodes id] put path frame value))
|
||||
% edits)))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::toggle-key
|
||||
(fn [db [_ sid id path frame]]
|
||||
(let [st (:store (store/entry (:clip/current db)))]
|
||||
(edit/edit db #(update-in % [:symbols sid :nodes id] node/toggle-key path frame st)))))
|
||||
|
||||
;; A hold on node `id`'s own frame `frame`, or none there if there was one: the
|
||||
;; frames a tracing layer holds its picture on — a face's trace keys are its
|
||||
;; plate's — and, for any node, `node/hold`. Taking the last one off removes the
|
||||
;; floor rather than leaving an empty list behind.
|
||||
(rf/reg-event-db
|
||||
::toggle-hold
|
||||
(fn [db [_ sid id frame]]
|
||||
(edit/edit db #(update-in % [:symbols sid :nodes id :time]
|
||||
(fn [t]
|
||||
(let [hs (get t :holds [])
|
||||
hs (vec (sort (if (some #{frame} hs)
|
||||
(remove #{frame} hs)
|
||||
(conj hs frame))))]
|
||||
(if (seq hs) (assoc t :holds hs) (dissoc t :holds))))))))
|
||||
|
||||
;; How a face's head moves between its footage's holds — `symbol`'s `:reads`.
|
||||
;; Nil reads every frame.
|
||||
(rf/reg-event-db
|
||||
::set-reads
|
||||
(fn [db [_ sid reads]]
|
||||
(edit/edit db #(update-in % [:symbols sid :nodes :head]
|
||||
(fn [n] (if reads (assoc n :reads reads) (dissoc n :reads)))))))
|
||||
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-segment-interp
|
||||
(fn [db [_ sid id path left interp]]
|
||||
(edit/edit db #(update-in % [:symbols sid :nodes id] node/set-segment-interp path left interp))))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::list
|
||||
(fn [{:keys [db]} _]
|
||||
{:db (assoc-in db [:projects :loading?] true)
|
||||
::list! nil}))
|
||||
|
||||
(rf/reg-event-db
|
||||
::listed
|
||||
(fn [db [_ rows]] (assoc db :projects {:items rows :loading? false})))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::open
|
||||
(fn [{:keys [db]} [_ id]]
|
||||
;; `id` names which project. Without one it is the open document's own id, and
|
||||
;; without that the most recently updated — which is what "open" meant when
|
||||
;; there was no list to pick from.
|
||||
(if (:busy? (:project db))
|
||||
{}
|
||||
{:db (update db :project merge {:busy? true :status "opening…"})
|
||||
::pb/pause! nil
|
||||
::open! (:id (:project db))})))
|
||||
::open! (or id (:id (:project db)))})))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::load-stage
|
||||
|
|
@ -379,41 +892,70 @@
|
|||
(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]
|
||||
::pb/clock! {:id clip-id :sid (get-in db [:ui :open])}})))
|
||||
|
||||
(rf/reg-event-db
|
||||
(rf/reg-event-fx
|
||||
::saved
|
||||
(fn [db [_ id cid label seq written uploaded]]
|
||||
(update db :project merge
|
||||
{:id id :cid cid :name label :seq seq :busy? false
|
||||
(fn [{:keys [db]} [_ id cid label seq written uploaded {:keys [synced base]}]]
|
||||
(let [fresh? (not= id (get-in db [:project :id]))]
|
||||
{:db (-> db
|
||||
(update :clip/current #(or (store/edit-entry! % (fn [e] (-> (assoc e :synced synced)
|
||||
(dissoc :behind))))
|
||||
%))
|
||||
(update :project dissoc :again)
|
||||
(update :project merge
|
||||
{:id id :cid cid :name label :seq seq :busy? false :saving? false
|
||||
:status (str "saved r" seq " · " written
|
||||
(if (= 1 written) " leaf" " leaves")
|
||||
" · " uploaded (if (= 1 uploaded) " block" " blocks"))})))
|
||||
" · " uploaded (if (= 1 uploaded) " block" " blocks"))}
|
||||
(when fresh?
|
||||
{:owner (get-in db [:me :username]) :editors [] :can-edit? true})))
|
||||
:fx [;; The all-assets folder lists saved symbols, so a save can add rows to it.
|
||||
[:dispatch [::list-symbols]]
|
||||
;; Somebody wrote between what we last saw and this save. Their
|
||||
;; deltas may still be on the wire, and a seq we have jumped past
|
||||
;; would drop them, so ask for the document instead.
|
||||
(when (and base (not= seq (inc base)))
|
||||
[:dispatch [:arthur.events.collab/catch-up]])
|
||||
(when-let [how (get-in db [:project :again])]
|
||||
[:dispatch [::save how]])]})))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::conflicted
|
||||
;; Nothing was written: somebody else's write to the same leaves got there
|
||||
;; first. Catching up TAKES theirs, over ours — then the rest of ours saves.
|
||||
(fn [{:keys [db]} [_ n]]
|
||||
{:db (-> db
|
||||
(update :project dissoc :again)
|
||||
(update :project merge {:busy? false :saving? false}))
|
||||
:fx [[:dispatch [:arthur.events.collab/catch-up true]]
|
||||
[:dispatch [::save {:auto? true}]]]}))
|
||||
|
||||
(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]))
|
||||
(update :project merge
|
||||
{:id project-id :name name :seq seq :cid (:cid clip)
|
||||
(fn [{:keys [db]} [_ clip-id project-id name seq access]]
|
||||
{:db (-> (pb/show db clip-id)
|
||||
(update :project merge access
|
||||
{: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
|
||||
::pb/seek! (let [{c :clip fps :fps} (store/entry clip-id)]
|
||||
[fps (clip/output-frames c (clip/opens-on c)) 0])
|
||||
::pb/clock! {:id clip-id
|
||||
:sid (clip/opens-on (:clip (store/entry clip-id)))}}))
|
||||
|
||||
(rf/reg-event-db
|
||||
(rf/reg-event-fx
|
||||
::failed
|
||||
(fn [db [_ message]]
|
||||
(update db :project merge {:busy? false :status (str "failed: " message)})))
|
||||
(fn [{:keys [db]} [_ message]]
|
||||
(cond-> {:db (-> db
|
||||
(update :project dissoc :again)
|
||||
(update :project merge {:busy? false :saving? false
|
||||
:status (str "failed: " message)}))}
|
||||
(get-in db [:project :again]) (assoc :dispatch [::save (get-in db [:project :again])]))))
|
||||
|
|
|
|||
1487
frontend/src/arthur/events/ui.cljs
Normal file
1487
frontend/src/arthur/events/ui.cljs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -34,17 +34,18 @@
|
|||
(:refer-clojure :exclude [run!])
|
||||
(:require [arthur.audio.mix :as mix]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.palette :as pal]
|
||||
[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 +61,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 +72,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.")
|
||||
|
||||
|
|
@ -84,15 +85,15 @@
|
|||
Four things, and each for its own reason:
|
||||
|
||||
the node itself;
|
||||
everything ABOVE it, because a placement's transform is relative to its
|
||||
everything ABOVE it, because an instance's transform is relative to its
|
||||
parent and dropping the chain would move the thing being isolated;
|
||||
everything BELOW it, because a group instance is its children;
|
||||
any audio track `:linked-to` it, because the link is the statement that this
|
||||
sound belongs to that placement, and a face exported without its voice is
|
||||
sound belongs to that instance, and a face exported without its voice is
|
||||
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."
|
||||
Siblings go. That is the whole point: what comes out is one instance, where it
|
||||
sits, in the symbol it sits in."
|
||||
[nodes id]
|
||||
(let [up (loop [i id acc #{}]
|
||||
(if (or (nil? i) (contains? acc i))
|
||||
|
|
@ -114,18 +115,18 @@
|
|||
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
|
||||
its own time, identically for all seven placements. Isolating one placement
|
||||
renders the STAGE — its length, its rate, the placement's span, drift and scale
|
||||
the symbol an instance plays. Rooting at `:sym/face-8625` renders the drawing in
|
||||
its own time, identically for all seven instances. Isolating one instance
|
||||
renders the STAGE — its length, its rate, the instance'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,68 +141,63 @@
|
|||
(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] sid :symbol isolate-id :isolate}]
|
||||
(let [sym (some-> (clip/symbol clip sid) (isolate isolate-id))
|
||||
[width height] (clip/stage clip sid)
|
||||
zoom (max 1 (js/Math.floor (or zoom 1)))]
|
||||
(when tl
|
||||
{:frames (:frames tl)
|
||||
(when sym
|
||||
{:frames (clip/output-frames clip sid)
|
||||
:fps (:fps clip)
|
||||
:zoom zoom
|
||||
:width (* (:width clip) zoom)
|
||||
:height (* (:height clip) zoom)
|
||||
:seconds (/ (:frames tl) (: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))})))
|
||||
:width (* width zoom)
|
||||
:height (* height zoom)
|
||||
:seconds (/ (clip/output-frames clip sid) (:fps clip))})))
|
||||
|
||||
(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 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
|
||||
{:picture-fps picture-fps})
|
||||
ras (raster/make (:width clip) (:height clip))
|
||||
bg (get palette :bg 0)]
|
||||
(-> (audio! doc timeline store audio-url)
|
||||
[width height] (clip/stage clip sid)
|
||||
;; 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 sid store palette nil)
|
||||
ras (raster/make width height)]
|
||||
(-> (audio! doc sid store audio-url)
|
||||
(.then (fn [audio]
|
||||
(js/Promise.resolve
|
||||
(begin! exporter {:name name :width (:width clip)
|
||||
:height (:height clip) :zoom zoom
|
||||
(begin! exporter {:name name :width width
|
||||
:height height :zoom zoom
|
||||
:fps fps :frames frames :ramp ramp
|
||||
:audio audio}))))
|
||||
(.then (fn [_]
|
||||
|
|
@ -214,14 +210,19 @@
|
|||
(fn [chain i]
|
||||
(.then chain
|
||||
(fn [_]
|
||||
(let [ops (resolve-frame i)
|
||||
active (clip/active-palette resolve-frame)]
|
||||
(-> ras
|
||||
(raster/clear! bg)
|
||||
(raster/draw-ops! (resolve-frame i)))
|
||||
(-> (js/Promise.resolve (frame! exporter i ras))
|
||||
(raster/clear! (pal/background-index palette active))
|
||||
(raster/draw-ops! ops))
|
||||
(-> (js/Promise.resolve
|
||||
(frame! exporter i
|
||||
(assoc ras :palette-ramp
|
||||
(pal/effective-ramp palette active))))
|
||||
(.then (fn [_]
|
||||
(when on-progress
|
||||
(on-progress (inc i) frames))
|
||||
(yield!)))))))
|
||||
(yield!))))))))
|
||||
(js/Promise.resolve)
|
||||
(range frames))))
|
||||
(.then (fn [_] (finish! exporter)))))))
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
;; raster: keeping a reference to it and encoding later would encode
|
||||
;; the last frame N times, and every frame would be a valid PNG of the
|
||||
;; wrong picture.
|
||||
(-> (encode raster ramp)
|
||||
(-> (encode raster (or (:palette-ramp raster) ramp))
|
||||
(.then (fn [bytes]
|
||||
(swap! state update :entries conj
|
||||
{:name (str name "/" (pad (inc i) digits) ".png")
|
||||
|
|
|
|||
|
|
@ -67,8 +67,15 @@
|
|||
over the same frames and the same model they disagree by up to 0.013 of frame
|
||||
width, which is a visible difference on a mouth. Optional, because the synthetic
|
||||
take has no running mode to declare and an absent field is how the other
|
||||
optional inputs already say \"not applicable\"."
|
||||
[{:keys [detector version source footage frames fps aspect seed mode tracking]}]
|
||||
optional inputs already say \"not applicable\".
|
||||
|
||||
`:range` is `[first end)` source frames when only part of the footage was
|
||||
analysed, and absent for all of it — so every analysis made before ranges
|
||||
existed keeps its address. It is in here because a partial analysis is a
|
||||
different artifact: without it, detecting frames 40–90 would be saved under the
|
||||
same key as the whole take, and the next conversion of the whole take would be
|
||||
handed fifty frames."
|
||||
[{:keys [detector version source footage frames fps aspect seed mode tracking range]}]
|
||||
(when-not (and (string? detector) (seq detector) (string? version) (seq version))
|
||||
(throw (ex-info "an analysis names its detector and the detector's VERSION: an upgrade that silently reuses old landmarks is the failure content addressing exists to prevent"
|
||||
{:detector detector :version version})))
|
||||
|
|
@ -82,7 +89,8 @@
|
|||
footage (assoc :footage footage)
|
||||
seed (assoc :seed seed)
|
||||
mode (assoc :mode mode)
|
||||
tracking (assoc :tracking tracking))))
|
||||
tracking (assoc :tracking tracking)
|
||||
range (assoc :range range))))
|
||||
|
||||
(defn analysis
|
||||
"An analysis record with its `:id` filled in. The record is tier 1 — it says
|
||||
|
|
@ -150,6 +158,12 @@
|
|||
"head-pos" [:anchor-avg]
|
||||
"head-rot" [:anchor-avg]
|
||||
"head-scale" [:anchor-avg]
|
||||
;; The head's fit itself, registering the footage under it — see
|
||||
;; `freeze/plate-part`. The image height it divides by is the footage's, which
|
||||
;; the analysis already names.
|
||||
"plate-pos" [:anchor-avg]
|
||||
"plate-rot" [:anchor-avg]
|
||||
"plate-scale" [:anchor-avg]
|
||||
"eyes" [:anchor-avg :contour-avg :eye-verts :lash-weight]
|
||||
"iris-pos" [:anchor-avg :contour-avg :gaze-gain :gaze-step]
|
||||
"brows" [:anchor-avg :contour-avg :brow-verts :brow-gain :brow-step :brow-weight]
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue